Skip to content

projects.create() declares 13 body fields; the control plane reads 4 — the guards compare URLs, not bodies #3739

Description

@os-zhuang

Restart-when: the triage seat performs the contract-first split (parent + per-repo sub-issues) — decide the category-2 wire spelling first (cloud handler accepts is_default / env_type snake_case vs SDK sends camelCase), since it selects the landing repo; categories 1+3 are independently dispatchable objectstack / domain:cli cards the moment they exist

Found while trimming template_id (#3731), which turned out to be one row of a bigger table. Sibling findings: #3702 (the listing method), #3731 (the flag + field).

The gap

client.projects.create() (packages/client/src/index.ts) declares 13 body fields. POST /api/v1/cloud/environments — the route the ledger points at, cloud's packages/service-cloud/src/routes/environment-lifecycle.ts — reads its body explicitly, field by field, and then hardcodes most of the provisioning arguments.

SDK field what the handler does verdict
organization_id body.organizationId ?? body.organization_id ✅ read
display_name body.displayName ?? body.display_name ✅ read
metadata passed through ✅ read
is_default Boolean(body.isDefault)camelCase only ❌ dropped: the SDK sends is_default
env_type body.environmentType ?? body.environment_typea third spelling ❌ dropped
driver hardcoded driver: 'turso' ⚠️ typed as settable, overridden
plan hardcoded plan: FREE_PLAN ⚠️ same
storage_limit_mb hardcoded storageLimitMb: 1024 ⚠️ same
slug ❌ never read
project_type ❌ never read
region ❌ never read
is_system ❌ never read from the request (only off the stored row, by the reapers)
clone_from_environment_id ❌ never read; cloning exists as the clone_environment action instead

Verified by grep across packages/service-cloud/src/**/*.ts on cloud@main: no body.slug, body.region, body.project_type, body.is_system, body.storage_limit_mb, body.driver. (body.plan / body.visibility do appear — in billing and in the environment PATCH path, not at create. So plan is settable later, just not here.)

The reverse also holds: the handler reads body.hostname and body.subdomain, neither of which the SDK's type declares.

Three flavours in one body, and they deserve different fixes:

  1. Dead (slug, project_type, region, is_system, clone_from_environment_id) — nothing reads them, ever. Trim, like client.projects.listTemplates() targets /api/v1/cloud/templates, which nothing mounts #3702/os env create --template and projects.create({ template_id }) send a field no control plane reads #3731.
  2. Dialect-dropped (is_default, env_type) — the handler accepts snake_case for display_name and organization_id, so a caller reasonably assumes it does everywhere. It does not. os env create --no-activate --default style calls silently lose the flag. This is the sharpest one: it is a live feature that quietly does nothing. Fix at one end — either the handler accepts is_default / env_type (matching how it already treats the other two), or the SDK sends the camelCase the handler wants. Per the contract-first directive, one spelling should win, not both.
  3. Policy-overridden (driver, plan, storage_limit_mb) — deliberate ("paid drivers/plans/storage are not self-serve here"), so the server is right and the type is what lies. Either drop them from the create body or document them as advisory. A typed field the server always ignores is a promise the SDK cannot keep. Note the CLI exposes both: os env create --driver and --plan (the latter even defaulting to free), so users are being offered choices with no effect.

Why no guard caught this

Both halves of the route audit compare URLs. projects.create targets a route that really is mounted, so the framework capstone (#3642) and cloud's control-plane ledger (#3655) are both green — and both would stay green if every field but display_name were dropped on the floor. A body field the server never reads is invisible to a route-level guard.

That is the next edge of the #3563 family, and it is worth deciding whether it gets a guard at all before deciding these 13 fields one by one. A cheap version exists: the create route already names the fields it reads, so a test could drive projects.create() with every declared field populated and assert the resulting sys_environment row (or the provisioning arguments) reflects each one — the same "drive it for real" shape projects-namespace-coverage.test.ts already uses, one layer down. That guard would have to live in cloud, like its sibling.

Suggested order

  1. Fix (2) — it is the only category where a user can be actively misled today (they pass --default / an env type and it vanishes).
  2. Trim (1) with a changeset, same treatment as client.projects.listTemplates() targets /api/v1/cloud/templates, which nothing mounts #3702 / os env create --template and projects.create({ template_id }) send a field no control plane reads #3731.
  3. Decide (3) deliberately — trim or document; do not leave a typed field the server discards.
  4. Then decide whether the body-level guard is worth building, informed by 1-3.

Generated by Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions