Repository navigation
@objectstack/spec/cloud declares camelCase rows but the /api/v1/cloud/* control plane speaks snake_case — the cloud contracts are not the wire types, so 15 client methods cannot be bound to them #12036
Description
Activity
READING from
objectstack-ai/cloud— the control plane's actual EMIT / ACCEPT casing, answering #12412repo:cloudexecution seat (seat post #6026), sessionsession_017Wu48ZSjk7HJjyJmGNBtUk, PM loop R35 reading batch C. Posted here because #12412 says the answer lands on this card. ⛔ Read-only: no branch, no edit, no install, no build.git status --porcelainempty.Ref read.
objectstack-ai/cloudorigin/mainat9b6abe0f2fd58e39f1f5ca329940f5cf5d75f50d(9b6abe0), detached read-only worktree. Its framework pin.objectstack-sha=655b106ce2804d2cdb9ed4386df795152535d372. Everything below is read from the control-plane source, not from the spec, as the request asked.Short answer, before the evidence
The fork in #12036 has a fourth branch that nobody proposed, and it is what is actually shipping. The wire is not uniformly snake_case. The rule the code follows — nowhere stated as a rule — is:
ROW payloads are the
sys_environment/sys_package_installationDB row spread verbatim (snake_case); ENVELOPE keys and non-row scalars are camelCase — EXCEPT onPOST /cloud/environments, where the emittedenvironmentis the spec's camelCaseEnvironmentobject, and EXCEPTGET …/packages, whose rows carrypackageIdANDpackage_idfor the same value.⇒
GET /api/v1/cloud/environments/:idandPOST /api/v1/cloud/environmentsreturn a key calledenvironmentin two different casings. That is measured below, not inferred.And:
@objectstack/spec/cloudis NOT zero-consumer on the cloud side. It has 12 import sites, one of which.parse()s a cloud schema at runtime.Control — the route registrations exist and were found
$ git grep -nE "(app|router|r)\.(get|post|patch|put|delete)\(\s*['\"\`][^'\"\`]*environments" -- 'packages/service-cloud/src/**/*.ts'→ 39 registrations across
branches.ts,cloud.ts,domains.ts,environment-crud.ts,environment-lifecycle.ts,package-export.ts,package-install.ts. The two families #12412 names:packages/service-cloud/src/routes/environment-crud.ts:1712-1783—GET /cloud/environments,GET|PATCH|DELETE /cloud/environments/:id,…/credentials/rotate,…/activate,…/retry, and all six…/:id/packages…arms.packages/service-cloud/src/routes/environment-lifecycle.ts:763—POST /cloud/environments(creation is deliberately NOT inenvironment-crud.ts; see its docblock at:31-40).
Every
environment-crud.tsroute is a thin forwarder into one in-file dispatcher,handle(path, method, body, query, req)atenvironment-crud.ts:341, whose internal paths are still spelled/projects/….Q1a — what the handlers EMIT
⭐ The serializer, and it is a no-op.
environment-crud.ts:620-630:// Data crosses the wire as snake_case — matching the canonical // `sys_environment` schema. The only post-processing is JSON-parsing the // `metadata` column so consumers don't need to unwrap it. const cleanProjectRow = (row: any): any => { if (!row) return row; let metadata: any = row.metadata; if (typeof metadata === 'string') { try { metadata = JSON.parse(metadata); } catch { /* keep raw string if not JSON */ } } return { ...row, metadata }; };
{ ...row }— the ObjectQL row is spread verbatim. There is no key mapping of any kind. Call sites::904(list),:944,:979,:1306,:1318.GET /cloud/environments/:id—environment-crud.ts:943-956, responsesuccess({ environment, database, credential: credMeta, membership }):block keys emitted casing environmentcleanProjectRow(envRow)—display_name,organization_id,is_default,is_system,database_url,database_driver,storage_limit_mb,provisioned_at,created_at,console_url,api_base_url,environment_type, …snake_case, verbatim DB row databasedriver,database_name,database_url,storage_limit_mb,provisioned_at(:945-952)snake_case membership{ user_id, organization_id, role }(:931, with the docblock at:922saying it is shaped that way "so the CLI/SDK 'your role' surface keeps working")snake_case ⚠️ credential{ id, status, authorization, activatedAt, expiresAt }(:934-940) —activatedAt: credRow.created_at,expiresAt: credRow.expires_atcamelCase, hand-mapped from snake columns ⇒ Inside one response object, three blocks are snake_case and the fourth is camelCase, by a two-line hand transform written inline.
GET /cloud/environments—:904,list.map((r) => stripInfraSecrets(cleanProjectRow(r)))⇒ snake_case rows.PATCH /cloud/environments/:id—:979,success({ environment: cleanProjectRow(envRow) })⇒ snake_case.DELETE /cloud/environments/:id—:986,success({ deleted: true, environmentId: id, warnings })⇒ camelCaseenvironmentId.POST /cloud/environments/:id/retry—:1318,success({ environment: cleanProjectRow(envRow), sessionUpdated: false })⇒ snake_case row inside a camelCase envelope key. (#12036 notedsessionUpdatedwas findable only as the client's own declaration; here is its producer.)⭐
POST /cloud/environments— the one that breaks the "wire is snake_case" reading.environment-lifecycle.ts:1021-1026:return res.status(201).json(ok({ environment: result.environment, warnings: result.warnings, durationMs: result.durationMs, ...(result.hostnameAssignment ? { hostnameAssignment: result.hostnameAssignment } : {}), }));
resultcomes from the provisioning service, which returns (packages/service-tenant/src/environment-provisioning.ts:1488){ environment: environment, credential, durationMs, warnings }whereenvironmentis declaredconst environment: Environment = { … }at:1094— the spec's own camelCase type, literally spelledorganizationId,displayName,isDefault,isSystem,createdBy,createdAt,updatedAt,databaseUrl,databaseDriver,storageLimitMb,provisionedAt.⇒
POSTemitsenvironmentin camelCase;GET/PATCHemitenvironmentin snake_case. Same key, same route family, different verbs. Neither is documented as differing from the other.GET /cloud/environments/:environmentId/packages—environment-crud.ts:1372-1382,success({ packages, total })where each row isdenormalizeInstall(r)(:571-593):return { ...row, // sys_package_installation, snake_case packageId: manifestId, // camelCase package_id: manifestId ?? row.package_id, // snake_case — SAME VALUE version: versionStr ?? row.version ?? null, };
⇒ dual-key emission on one field. The row is snake_case with a camelCase duplicate of
package_idwelded on.POST …/packages(:1470-1474) emits{ …result.data, package_id: packageId, package: denormalizeInstall(record) }.Q1b — what the handlers ACCEPT
route body keys read casing PATCH /cloud/environments/:id(environment-crud.ts:959-973)body.display_name,body.plan,body.status,body.visibility,body.metadatasnake_case only — no camelCase alias POST /cloud/environments(environment-lifecycle.ts:769,775-777,847)body.displayName ?? body.display_name;body.organizationId || body.organization_id;body.environmentType ?? body.environment_typeboth, camelCase first POST /cloud/environments/:id/packages(environment-crud.ts:1397)const { packageId, version, settings, enableOnInstall } = bodycamelCase only — same arm ( :1445,:1455)body?.seed_sample_data || body?.seedSampleData;body?.reconsent || body?.re_consentboth POST …/set_audience_posture(environment-lifecycle.ts:1973-1976)body.allowedEmailDomains ?? body.audience_allowed_email_domains, etc.both, with an inline note ( :1967) that this is "a spelling alias on the REQUEST only"⇒ The ACCEPT side is per-route and inconsistent in the opposite direction from EMIT:
PATCHon an environment takes snake_case only,POSTof a package takes camelCase only, andPOSTof an environment takes either.⚠️ clone_from_environment_id— which #12036 records the CLI'screate.ts:84-91as SENDING — is read by nothing in cloud.$ git grep -niE "clone_from|cloneFrom" -- ':!**/node_modules/**' ':!**/dist/**' (no output)Control for that zero, on the same tree: the neighbouring key from the same CLI request body IS found —
git grep -ncE "display_name" -- packages/service-cloud/src/routes/environment-{crud,lifecycle}.ts→environment-crud.ts:1,environment-lifecycle.ts:7. ⇒ zero clone-key hits is a reading, not a dead pattern. The CLI sends a key the control plane silently drops.Q2 — is there a case-transform layer, and is it documented?
There is no snake↔camel transform layer on those routes. What exists instead is two things:
- ⭐ One real, exported, tested, one-way transform — but it is a PERSISTENCE transform, not a wire one.
packages/service-tenant/src/environment-provisioning.ts:598export function buildEnvironmentRow(environment: Environment, options)returning a plain string-keyed record maps the spec's camelCase object onto the snake_casesys_environmentcolumns, field by field:organization_id: environment.organizationId,display_name: environment.displayName,is_default: environment.isDefault,created_by: environment.createdBy,database_url: environment.databaseUrl,storage_limit_mb: environment.storageLimitMb,provisioned_at: environment.provisionedAt, and so on. It is called at:1167and:1477, and its key set is pinned bypackages/service-tenant/src/environment-provisioning-audience.test.ts:155,197,199.
⚠️ It has no inverse. Nothing maps a read row back to camelCase — which is precisely why the read routes emit snake_case while the create route emits camelCase. The camel form exists only on the way IN. - 20 hand-written per-field spelling aliases standing in for the missing layer. Measured across
packages/service-cloud/src/routes/: 20body.camelKey ?? body.snake_key(or the reverse) sites, against a control of 184 totalbody.KEYreads in the same directory — i.e. ~11% of request keys accept both spellings and 89% accept exactly one, chosen per field by whoever wrote the line.
Documented? Only in one inline comment, and that comment is now falsified by the
POSTarm:environment-crud.ts:620-622, "Data crosses the wire as snake_case — matching the canonicalsys_environmentschema." Nothing indocs/states the rule for these routes.$ git grep -niE "snake_case|camelcase|snake case|camel case" -- 'docs/**' 'content/**' '*.md' '*.mdx'returns 10 lines, and exactly one is about a
/cloud/*wire shape —docs/design/sys-domain.md:705, documenting a different route:POST /cloud/domains/:id/set-primary → 200 { id, environmentId, hostname } // camelCase, and it // echoes the row idControl that this grep works: it returns non-empty (10 lines, incl.
docs/adr/0071…:96describing a camelCase→snake_case adapter mapping) andgit grep -ric "environment" -- 'docs/**'matches 49 files — so the docs tree was genuinely read.
⇒ Thesys-domain.mdnote is the repo's proof that when someone intends camelCase on a/cloud/*route they say so in a design doc. No equivalent note exists for/cloud/environments/*, in either casing.Q3 — does cloud import
@objectstack/spec/cloud?Yes — 12 code sites, one of which parses at runtime. ⛔ Not zero-consumer.
file what packages/service-tenant/src/environment-provisioning.ts:6-14import type { EnvironmentCredential, EnvironmentDriver, Environment, ProvisionEnvironmentRequest, ProvisionEnvironmentResponse, ProvisionOrganizationRequest, ProvisionOrganizationResponse }— typepackages/service-tenant/src/environment-provisioning.ts:15-18⭐ import { ProvisionEnvironmentRequestSchema, ProvisionOrganizationRequestSchema }— value, and:750isconst parsed = ProvisionEnvironmentRequestSchema.parse(request);— a live runtime parse of a cloud spec schemapackages/service-tenant/src/tenant-context.ts:3,4-9TenantRoutingConfigSchema(value) + 4 typespackages/service-tenant/src/tenant-provisioning.ts:3-7ProvisionTenantRequest,ProvisionTenantResponse,TenantDatabase— typepackages/service-tenant/src/tenant-plugin.ts:5,tenant-context.test.ts:5,tenant-integration.test.ts:6type / test packages/service-cloud/src/billing.ts:25TenantPlan— typepackages/service-cloud/src/registry-reader.ts:26,packages/objectos-runtime/src/artifact-api-client.ts:25EnvironmentArtifact— typepackages/service-cloud/test/billing-plan-vocabulary.test.ts:30TenantPlanSchema— value, in a test⭐ The
Environmenttype #12036 says cannot be bound IS imported and IS instantiated —environment-provisioning.ts:1094and:1429,const environment: Environment = { … }. So the spec type is not unused; it is used as the service-layer shape, one layer above the DB and one layer below HTTP, andbuildEnvironmentRowconverts it down to the row. What is absent is any binding between that type and the read routes' output.Control — another
@objectstack/specsubpath cloud does import, same tree, same grep shape:$ git grep -ohE "@objectstack/spec(/[a-z0-9-]+)*" -- '*.ts' '*.tsx' '*.json' '*.md' | sort | uniq -c | sort -rn 280 @objectstack/spec 208 @objectstack/spec/contracts 70 @objectstack/spec/ai 65 @objectstack/spec/data 23 @objectstack/spec/ui 20 @objectstack/spec/kernel … 13 @objectstack/spec/cloude.g.
apps/cloud/scripts/lib/control-driver.ts:53import type { IDataDriver } from '@objectstack/spec/contracts'. ⇒/cloud's 13 is a genuine count from a working grep, not an artefact.What this does to the fork on this card
The three options here were written on the premise that one casing is on the wire. Measured, that premise is false, so the options read differently:
- "The spec is right, the control plane should emit camelCase" — smaller than it looks on the create path (
POSTalready emits the spec object) and larger than it looks on the read path: it means givingcleanProjectRowan inverse ofbuildEnvironmentRow, and it breaks thedatabase/membershipblocks and the CLI reads atshow.ts:52-64/list.ts:53-72simultaneously. - "The wire is right, the spec should declare snake_case" — would make the spec disagree with cloud's OWN
Environmentinstantiation atenvironment-provisioning.ts:1094, withProvisionEnvironmentRequestSchema.parseat:750, and with thecredentialblock and every camelCase envelope key. - "There is a documented transform" — ⛔ closest to reality but as stated it is false: the transform exists (
buildEnvironmentRow) but runs one way into the DB, not on the wire, and the only wire-casing sentence in the repo (environment-crud.ts:620) is contradicted by thePOSTarm three files away.
⚠️ Two defects fall out of this reading that are independent of which way the fork is decided, and are stated so they are not lost with it: (a)GETandPOSTon/cloud/environmentsdisagree about the casing of a key both callenvironment; (b)clone_from_environment_idis sent by the CLI and read by nobody.⛔ No fix is proposed and nothing was changed — casing is a contract decision, and the reading request asked for facts.
Generated by Claude Code
Unlock scan → 决策箱。
pm:blocked→needs-user-decision,按本卡自己写下的去向,⛔ 不是回队列。domain:spec席,session_01T6HeZvT9wdSJD1ZxJb5Eno,R1。读数取自 2026-09-06T02:33Z;origin/main1f2a02b。半状态巡查锚 #9857(sweep 2026-09-06T01:58:05Z,run 34005012908)以 H19 行点名本卡:
pm:blocked而 1/1 个Blocked-by:目标已关闭。巡查只报不裁,放行是本席的。放行双查
查 读数 ① 只按最近一次转换评论的条件放行 该评论是 5419504299(2026-08-26,分诊)。它写明的去向不是队列,原话:"When #12412 closes, this card goes to the decision inbox WITH those readings attached (contract-shape choice on a published surface is a maintainer call), not straight back to the queue." ⇒ 本席照此执行。② 卡上若有比该转换评论更新的 MERGED PR 则拒绝放行 closed_by_pull_requests= 0,本卡从无 PR。干净。#12412 已关闭,且它索要的读数已由
repo:cloud执行席落在本卡上:5520459478(2026-09-03,读objectstack-ai/cloud9b6abe0)。条件完全兑现 ⇒ 转决策箱。⭐ 那份读数把本卡的分叉改写了 —— 裁决前必须先读这一句
正文列的三个选项,都建立在「线上是某一种大小写」这个前提上。实测:该前提为假。 读数发现了没人提过的第四种现实,而它正是今天在跑的:
行(row)载荷是
sys_environment/sys_package_installation数据库行的逐字展开(snake_case);信封键与非行标量是 camelCase —— 但POST /cloud/environments例外,它吐的environment是 spec 自己的 camelCaseEnvironment对象;GET …/packages也例外,同一个值同时带packageId和package_id两个键。⇒
GET与POST /api/v1/cloud/environments对同一个叫environment的键吐两种大小写。 这是实测,不是推断(environment-crud.ts:943-956对environment-lifecycle.ts:1021-1026)。另外两条同样推翻旧前提:
@objectstack/spec/cloud在 cloud 侧不是零消费:12 个 import 站点,其中environment-provisioning.ts:750是一次运行时ProvisionEnvironmentRequestSchema.parse();Environment类型本身在:1094/:1429被实例化。- 唯一的一句线上大小写文档(
environment-crud.ts:620-622「Data crosses the wire as snake_case」)已被三个文件外的POST分支证伪。
<!-- os-decision-facets -->
- ① 项目长远合理性(权重 ≥50%,领起推荐) —— 本卡真正的发现不是「选哪种大小写」,而是这条线上根本没有规则:同一路由族的两个动词对同一个键吐不同形状,一个列表接口对同一个值吐两个键。今天把 15 个 client 方法绑到任何一种拼写上,都是绑到一个不成立的事实上。而 camelCase 已经是 cloud 自己内部的真相:服务层实例化的是 spec 的
Environment,运行时 parse 的是 spec 的 schema,buildEnvironmentRow(environment-provisioning.ts:598)是一条只有去程、没有回程的 camel→snake 持久化映射。⇒ 线上的 snake_case 不是一个被选择的契约,而是数据库行经{...row}逐字展开漏到 HTTP 层。①因此指向 A(线上收敛到 camelCase):反过来把持久化细节固化成已发布契约,是把漏出当成规范。 - ② 实际业务拉动 —— 拉动是实的:A fifth client-SDK erasure spelling no grep in #8140's census counted: 38 methods with NO return annotation, typed from
unwrapResponse< …any… >#11925 就是撞上这件事才立的本卡,15 个 client 方法(projects.*9 个 +projects.packages.*6 个)今天绑不上。⚠️ 但拉动指向的是可绑定,不是某一种拼写;三个 CLI 调用点(show.ts:52-64、list.ts:53-72、create.ts:84-91)是仓内代码,随同一次改动移动,不是外部用户面。 - ③ 防 AI 犯错 —— 本卡最锋利的一棱,且它把 C 排到最后。今天的形状是教人推错规则的标准形状:读一个路由推出一条规则,下一个路由就错;
GET …/packages同值双键(packageId和package_id)正是「消费端宽容」的温床 —— 两边都能读通,于是没人发现自己读的是哪一个。不报错,只是安静地不一致。 收敛成一种声明形状、另一种响亮拒绝,才让写错在结构上不可能;而 C(记录一个转换)恰恰是「靠记忆遵守的规则」,是三个里唯一不让机器兜底的。 - ④ 创业阶段不扩散 —— 现在的消费者全在仓内,窗口是开的;每多一个客户端绑上现状,这堆不一致就多一份永久兼容义务。
⚠️ 而且渐进方案已经试过并且失败了:读数量出packages/service-cloud/src/routes/下有 20 处手写的body.camelKey ?? body.snake_key逐字段别名,对照 184 处 body 键读取 —— 即约 11% 的键接受两种拼写、89% 只接受一种,由写那一行的人各自决定。这就是「不设分阶段窗口、不留双拼写宽限」这条纪律所防的那个终局,它已经在这里了。
推荐:A —— 线上收敛到 camelCase,spec 即契约,15 个方法随即可绑。 ①领起(删掉漏出,而不是把漏出封为契约);③让跨路由推错在结构上不可能;④关掉那条已经长出 20 处别名的渐进路;②的拉动不反对它,且三个受影响的调用点都在仓内。
回退:B(spec 改成 snake_case)。 代价写明:会让 spec 与 cloud 自己的 camelCaseEnvironment实例化(:1094)、运行时.parse()(:750)、credential块与每一个 camelCase 信封键同时冲突 —— 即为了对齐读路由,让 spec 与该服务的其余部分不一致。
⛔ 不荐 C(声明存在一个转换)。 读数表明它如所述即为假:转换确实存在(buildEnvironmentRow),但它跑在进数据库的方向上、不在线上,而仓里唯一那句线上大小写的说明已被POST分支证伪。C 会把一个不一致记录下来而不是移除它,③④都反对。
置信缺口(本分析看不见什么): 未量 A 在 cloud 侧的施工成本 —— 它需要给cleanProjectRow一个buildEnvironmentRow的逆映射,并同时移动database/membership两个块与三个 CLI 读取点。读数明确说这是「读路径上比看起来大」的一边。⚠️ 这是裁决的前置输入,不是执行中顺手确认的事:若维护者倾向 A,该由repo:cloud席先出一份逆映射的施工面读数。Governing text:
AGENTS.md:230Prime Directive #12 —— "Contract-first — fix the metadata, not the runtime.packages/specis the one contract between metadata…"。三个选项里只有 A 与它同向;B 让契约去追实现,C 让两者都不动。⚠️ 两条与分叉无关的独立缺陷,单独记下以免随分叉一起被丢掉GET与POST /cloud/environments对environment键吐两种大小写 —— 无论怎么裁,这都是缺陷。clone_from_environment_id由 CLI 的create.ts:84-91发送,cloud 侧无任何代码读取(git grep -niE "clone_from|cloneFrom"零命中,同树对照词display_name命中 8 处 ⇒ 零命中成立)。CLI 发了一个被静默丢弃的键。
⇒ 这两条不该等本卡的裁决。本席在裁决落地前不立卡(避免与裁决产生的施工卡重复);若维护者选 A 或 B,它们随该次施工一并修掉,派发令会点名。若维护者选择先不动本卡,本席会把这两条单独立进
repo:cloud队列。状态转换(同笔)
pm:blocked→needs-user-decision;domain:spec、pm:blocking保留(本卡确有开放下游依赖者)。⛔ 本席不代裁:命中人工地板「协议/公开契约变化」类。
维护者速读
这是什么事 —— 我们的云控制台 API(
/api/v1/cloud/*)返回的字段名,没有统一规则。同一个接口族,GET拿到的是display_name这种下划线写法,POST拿到的却是displayName这种驼峰写法 —— 同一个字段,两种名字。有一个列表接口更直接,同一个值同时给两个名字。为什么现在要你拍板 —— 因为 spec(我们对外的唯一契约)里这些云端类型全是驼峰写法,于是 15 个客户端方法今天没法绑定到类型上:绑了就是错的,还会把本来写对的 CLI 代码标成错的。这不是小事上的洁癖 —— 客户端写代码时读一个接口推出一条规则,换个接口就错,而且不会报错,只是安静地读到空值。
选项 —— A:让服务端统一成驼峰(spec 不动,15 个方法立刻能绑;代价是云端要写一段反向映射,3 个 CLI 调用点跟着改,都在我们自己仓里)。B:让 spec 改成下划线(代价:会跟云端自己内部已经在用的驼峰类型打架)。C:不改,只写文档说明存在这个差异(我不推荐 —— 实测发现仓里唯一那句相关说明本身就已经是错的)。
我的意见 —— 推荐 A。现在线上的下划线写法并不是谁选定的契约,而是数据库的行结构直接漏到了 HTTP 层;把它固化成对外契约是把漏洞写进规范。另外提醒一点:「先兼容两种写法、以后再收」这条路已经在走了,结果是云端 20 处手写的双拼写兼容代码 —— 这正是我们定过「不留双拼写宽限」的原因。
你要做的 —— 选一个:A / B / C。
Generated by Claude Code
Status note — handed outside the PM seats by the maintainer (summon #16, director seat, 2026-09-06T04:51:19Z)
Provenance (who / verbatim / where): maintainer, 2026-09-06, live director chat, answering batch #52 (this card was item 1, recommendation A). Verbatim: 「#12036 已转交他人,其他同意」.
Effect: no ruling is recorded by this seat, no label changes, and this seat does not follow the card further (the #13753 / #15622 / #15706 precedent). Labels stay
needs-user-decision · domain:spec · pm:blockinguntil whoever holds it now records the ruling and its state transition here.For whoever takes it, the readings this seat would have written into a ruling are already on the card: the cloud-side wire reading (5520459478), the spec seat's four-facet analysis and 维护者速读 (5556354985), and two defects independent of the casing choice —
GETandPOST /cloud/environmentsemit theenvironmentkey in two casings, andclone_from_environment_idis sent by the CLI and read by nothing in cloud. This seat's recommendation was A (the wire converges on camelCase; the spec is unchanged), with the cloud seat's reverse-mapping construction-surface reading as the first premise.
Generated by Claude Code
needs-user-decision→pm:blocked— this card's fork is now downstream of #16325domain:specPM seat, direct maintainer session, 2026-09-06T04:42Z readings.
objectstackorigin/maind5d8d50db,clouddaaac081,objectui0558e0f78.Why, and on whose instruction
Maintainer, in a direct PM session on 2026-09-06, verbatim (⛔ quoted, not translated):
这个只是 cloud 内部使用,目前不改动影响客户使用吗
我一直觉得 cloud 的协议应该放在云端,没必要开源
and then: 「开决策卡」.
That reframes this card. Its three-way fork (spec moves to snake_case / the wire moves to camelCase / a
transform is documented) exists only because the declaration lives in the open-source contract package
while the implementation lives in the cloud repo. If the control-plane schemas move to the cloud repo,
the casing question stops being a cross-repo contract decision and becomes one repo's internal coding
style — decided there, without a maintainer ruling and without a three-repo coordination window.⇒ #16325 asks that upstream question. This card is blocked on it so exactly ONE question sits in the
maintainer's inbox, not two.Blocked-by: #16325added to the body in the same write.Two readings taken on this card that change what was written above
(a) The first answer to the maintainer's question is: no customer breaks today. The wire and all three
consumers (CLI, objectui console, SDK) agree on snake_case, and the 15 client methods returnany—
measured, not inferred:packages/client/src/index.ts:2262 unwrapResponse<{ environments: any[]; total: number }> packages/client/src/index.ts:2389 unwrapResponse<{ environment: any }> packages/client/src/index.ts:2415 unwrapResponse<{ environment: any; sessionUpdated: boolean }>⚠️ This corrects the pull recorded in the ② facet of comment5555…(2026-09-06T02:28Z). That analysis
read the pull as real and pointed at bindability. Measured, the pull is #11925 wanting to bind types —
internal code quality, not a customer-visible defect. Nobody is bleeding. This card is not P0.(b) The
anyis deliberate and already carries a maintainer ruling.packages/client/src/index.ts:2228-2248
is a 20-line docblock stating the erasure is intentional, naming #11925/#12036. At:2340-2372a prior
ruling is recorded verbatim — maintainer, 2026-08-29: 「同意」— option 甲 — choosing the inline wire shape
and ⛔ NOT binding@objectstack/spec/cloud'sProvisionEnvironmentResponseSchema.⇒ The direction #16325 proposes has already been ruled once, per-method. #16325 asks whether it becomes the
rule for the whole subpath.What happens to this card
- [Decision] Should the cloud control-plane contracts leave
@objectstack/specand live in the cloud repo? (upstream of #12036) #16325 answered A or B (control-plane schemas move to the cloud repo) ⇒ this card closes
not_planned, its fork dissolved rather than decided; the two independent defects below move with the
work. - [Decision] Should the cloud control-plane contracts leave
@objectstack/specand live in the cloud repo? (upstream of #12036) #16325 answered C (no move) ⇒ this card returns toneeds-user-decisionwith its original three-way
fork intact and the 2026-09-06T02:28Z analysis still standing, minus the ② correction above.
The two fork-independent defects, restated so they do not vanish with the fork
GETandPOST /cloud/environmentsemit a key both callenvironmentin two different casings
(environment-crud.ts:943-956vsenvironment-lifecycle.ts:1021-1026).clone_from_environment_idis sent bypackages/cli/src/commands/environments/create.ts:96and read by
no code in the cloud repo (git grep -niE "clone_from|cloneFrom"zero hits; control termdisplay_name
on the same tree hits 8 times ⇒ the zero is a reading). The CLI sends a key that is silently dropped.
⛔ Still not filed separately, for the reason the previous comment gave: whichever way #16325 goes, these
are fixed by the work it produces, and filing now would duplicate that card. If #16325 is answered C and
this card's own fork is then answered, they go with it; if both stall, this seat files them into the
repo:cloudqueue.- [Decision] Should the cloud control-plane contracts leave
Closed as superseded by the #16325 ruling (director seat, decision batch #62, 2026-09-07)
The maintainer adopted option B on #16325: the cloud control-plane schemas (including
EnvironmentSchema,TenantSchema, the environment-package rows) leave@objectstack/specand become the cloud repo's own declarations (#16450), matched to the wire's casing there. The three-way fork this card asked (spec moves to snake_case / wire moves to camelCase / a documented transform) therefore dissolves: once declaration and producer live in the same repo, the casing is that repo's internal style, and the 15 SDK methods keep the inline wire shape already ruled on 2026-08-29. The measurements here remain the reference for #16450's implementer.
Generated by Claude Code
- added a commit that references this issue
on Sep 28, 2026 - added a commit that references this issue
on Oct 7, 2026
Measured at
c804f0ca5while implementing #11925, which expected the cloud families to be bindable("
InstalledPackage… 20 of the 38 plausibly land on a type that exists today") and found they are not.Filed rather than fixed: choosing a casing is a contract decision for
domain:spec/ the cloud controlplane, and
packages/specis read-only from adomain:cliseat.The measurement
packages/spec/src/cloud/environment.zod.tsdeclaresEnvironmentSchemaentirely in camelCase:displayName,organizationId,isDefault,isSystem,databaseUrl,databaseDriver,createdBy.Zero snake_case keys across the three cloud row schemas, against 63 key lines in the same file — so the
zero is a real absence, not a broken pattern.
The in-repo consumers of those same routes read and write snake_case, on both request and response:
packages/cli/src/commands/environments/show.ts:52-64p.display_name,p.organization_id,p.is_default,p.is_system,res.database.database_url,res.membership.rolepackages/cli/src/commands/environments/list.ts:53-72p.display_name,p.is_default,p.is_systempackages/cli/src/commands/environments/create.ts:84-91organization_id,display_name,clone_from_environment_idThese call
client.projects.get/list/create/activate/update, i.e./api/v1/cloud/environments/*. They werewritten against the live control plane, so the wire is snake_case and
Environmentis not its type.Consequence
Binding the 15 unannotated cloud client methods —
projects.list/get/create/update/activate/ rotateCredential/updateHostname/updateVisibility/retryProvisioning(9) andprojects.packages.list/install/ get/enable/disable/upgrade(6) — toEnvironment/EnvironmentCredential/EnvironmentPackageInstallationtypechecks and is false, and would break the CLI call sites above atcompile time while telling them they are wrong when they are right. This is the same near-miss class #8140
recorded for
SearchResult, at family scale.It also makes one spec docblock's claim untrue as written:
ListEnvironmentPackagesResponseSchema(
environment-package.zod.ts:185) namesGET /cloud/environments/:environmentId/packagesas its route anddeclares camelCase
EnvironmentPackageInstallationrows.The fork
then all 15 methods bind and the CLI's reads move with it.
case the transform needs to be stated somewhere a consumer can read.
Nothing here is guessable from this repo; the control-plane implementation is not in it (
grep -rn "sessionUpdated" packages/ apps/finds only the client's own declaration).Blocked-by: #16325
Related: #11925, #11924.
Generated by Claude Code