You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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_type — a 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:
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.
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
Fix (2) — it is the only category where a user can be actively misled today (they pass --default / an env type and it vanishes).
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'spackages/service-cloud/src/routes/environment-lifecycle.ts— reads its body explicitly, field by field, and then hardcodes most of the provisioning arguments.organization_idbody.organizationId ?? body.organization_iddisplay_namebody.displayName ?? body.display_namemetadatais_defaultBoolean(body.isDefault)— camelCase onlyis_defaultenv_typebody.environmentType ?? body.environment_type— a third spellingdriverdriver: 'turso'planplan: FREE_PLANstorage_limit_mbstorageLimitMb: 1024slugproject_typeregionis_systemclone_from_environment_idclone_environmentaction insteadVerified by grep across
packages/service-cloud/src/**/*.tsoncloud@main: nobody.slug,body.region,body.project_type,body.is_system,body.storage_limit_mb,body.driver. (body.plan/body.visibilitydo appear — in billing and in the environment PATCH path, not at create. Soplanis settable later, just not here.)The reverse also holds: the handler reads
body.hostnameandbody.subdomain, neither of which the SDK's type declares.Three flavours in one body, and they deserve different fixes:
slug,project_type,region,is_system,clone_from_environment_id) — nothing reads them, ever. Trim, likeclient.projects.listTemplates()targets/api/v1/cloud/templates, which nothing mounts #3702/os env create --templateandprojects.create({ template_id })send a field no control plane reads #3731.is_default,env_type) — the handler accepts snake_case fordisplay_nameandorganization_id, so a caller reasonably assumes it does everywhere. It does not.os env create --no-activate --defaultstyle 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 acceptsis_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.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 --driverand--plan(the latter even defaulting tofree), so users are being offered choices with no effect.Why no guard caught this
Both halves of the route audit compare URLs.
projects.createtargets 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 butdisplay_namewere 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 resultingsys_environmentrow (or the provisioning arguments) reflects each one — the same "drive it for real" shapeprojects-namespace-coverage.test.tsalready uses, one layer down. That guard would have to live incloud, like its sibling.Suggested order
--default/ an env type and it vanishes).client.projects.listTemplates()targets/api/v1/cloud/templates, which nothing mounts #3702 /os env create --templateandprojects.create({ template_id })send a field no control plane reads #3731.