Skip to content

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

Description

@os-zhuang

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 fieldwhat the handler doesverdict
organization_idbody.organizationId ?? body.organization_id✅ read
display_namebody.displayName ?? body.display_name✅ read
metadatapassed through✅ read
is_defaultBoolean(body.isDefault)camelCase only❌ dropped: the SDK sends is_default
env_typebody.environmentType ?? body.environment_typea third spelling❌ dropped
driverhardcoded driver: 'turso'⚠️ typed as settable, overridden
planhardcoded plan: FREE_PLAN⚠️ same
storage_limit_mbhardcoded 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions