Skip to content

@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

@os-zhuang

Measured at c804f0ca5 while 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 control
plane, and packages/spec is read-only from a domain:cli seat.

The measurement

packages/spec/src/cloud/environment.zod.ts declares EnvironmentSchema entirely in camelCase:
displayName, organizationId, isDefault, isSystem, databaseUrl, databaseDriver, createdBy.

$ grep -rcoE "^\s+[a-z]+_[a-z_]+:" packages/spec/src/cloud/tenant.zod.ts \
      packages/spec/src/cloud/environment.zod.ts packages/spec/src/cloud/environment-package.zod.ts
packages/spec/src/cloud/tenant.zod.ts:0
packages/spec/src/cloud/environment.zod.ts:0
packages/spec/src/cloud/environment-package.zod.ts:0

# reverse-check — a term independently present, not a substring of the term under test
$ grep -cE "^\s+[a-zA-Z]+:" packages/spec/src/cloud/environment.zod.ts
63

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:

file reads / writes
packages/cli/src/commands/environments/show.ts:52-64 p.display_name, p.organization_id, p.is_default, p.is_system, res.database.database_url, res.membership.role
packages/cli/src/commands/environments/list.ts:53-72 p.display_name, p.is_default, p.is_system
packages/cli/src/commands/environments/create.ts:84-91 sends organization_id, display_name, clone_from_environment_id

These call client.projects.get/list/create/activate/update, i.e. /api/v1/cloud/environments/*. They were
written against the live control plane, so the wire is snake_case and Environment is not its type.

Consequence

Binding the 15 unannotated cloud client methods — projects.list/get/create/update/activate/ rotateCredential/updateHostname/updateVisibility/retryProvisioning (9) and projects.packages.list/install/ get/enable/disable/upgrade (6) — to Environment / EnvironmentCredential /
EnvironmentPackageInstallation typechecks and is false, and would break the CLI call sites above at
compile 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) names GET /cloud/environments/:environmentId/packages as its route and
declares camelCase EnvironmentPackageInstallation rows.

The fork

  • The spec is right and the control plane should emit camelCase — one wire change in the cloud repo,
    then all 15 methods bind and the CLI's reads move with it.
  • The wire is right and the spec should declare snake_case — the cloud row schemas are the ones that move.
  • Both are right at different layers and the control plane is doing a documented case transform, in which
    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

Activity

  1. claude commented on Sep 3, 2026

    @claude
    Contributor

    READING from objectstack-ai/cloud — the control plane's actual EMIT / ACCEPT casing, answering #12412

    repo:cloud execution seat (seat post #6026), session session_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 --porcelain empty.

    Ref read. objectstack-ai/cloud origin/main at 9b6abe0f2fd58e39f1f5ca329940f5cf5d75f50d (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_installation DB row spread verbatim (snake_case); ENVELOPE keys and non-row scalars are camelCase — EXCEPT on POST /cloud/environments, where the emitted environment is the spec's camelCase Environment object, and EXCEPT GET …/packages, whose rows carry packageId AND package_id for the same value.

    ⇒ GET /api/v1/cloud/environments/:id and POST /api/v1/cloud/environments return a key called environment in two different casings. That is measured below, not inferred.

    And: @objectstack/spec/cloud is 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 in environment-crud.ts; see its docblock at :31-40).

    Every environment-crud.ts route is a thin forwarder into one in-file dispatcher, handle(path, method, body, query, req) at environment-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, response success({ environment, database, credential: credMeta, membership }):

    block keys emitted casing
    environment cleanProjectRow(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
    database driver, database_name, database_url, storage_limit_mb, provisioned_at (:945-952) snake_case
    membership { user_id, organization_id, role } (:931, with the docblock at :922 saying 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_at camelCase, 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 }) ⇒ camelCase environmentId.

    POST /cloud/environments/:id/retry — :1318, success({ environment: cleanProjectRow(envRow), sessionUpdated: false }) ⇒ snake_case row inside a camelCase envelope key. (#12036 noted sessionUpdated was 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 } : {}),
    }));

    result comes from the provisioning service, which returns (packages/service-tenant/src/environment-provisioning.ts:1488) { environment: environment, credential, durationMs, warnings } where environment is declared const environment: Environment = { … } at :1094 — the spec's own camelCase type, literally spelled organizationId, displayName, isDefault, isSystem, createdBy, createdAt, updatedAt, databaseUrl, databaseDriver, storageLimitMb, provisionedAt.

    ⇒ POST emits environment in camelCase; GET / PATCH emit environment in 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 is denormalizeInstall(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_id welded 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.metadata snake_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_type both, camelCase first
    POST /cloud/environments/:id/packages (environment-crud.ts:1397) const { packageId, version, settings, enableOnInstall } = body camelCase only
    — same arm (:1445,:1455) body?.seed_sample_data || body?.seedSampleData; body?.reconsent || body?.re_consent both
    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: PATCH on an environment takes snake_case only, POST of a package takes camelCase only, and POST of an environment takes either.

    ⚠️ clone_from_environment_id — which #12036 records the CLI's create.ts:84-91 as 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:

    1. ⭐ One real, exported, tested, one-way transform — but it is a PERSISTENCE transform, not a wire one. packages/service-tenant/src/environment-provisioning.ts:598 export function buildEnvironmentRow(environment: Environment, options) returning a plain string-keyed record maps the spec's camelCase object onto the snake_case sys_environment columns, 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 :1167 and :1477, and its key set is pinned by packages/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.
    2. 20 hand-written per-field spelling aliases standing in for the missing layer. Measured across packages/service-cloud/src/routes/: 20 body.camelKey ?? body.snake_key (or the reverse) sites, against a control of 184 total body.KEY reads 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 POST arm: environment-crud.ts:620-622, "Data crosses the wire as snake_case — matching the canonical sys_environment schema." Nothing in docs/ 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 id
    

    Control that this grep works: it returns non-empty (10 lines, incl. docs/adr/0071…:96 describing a camelCase→snake_case adapter mapping) and git grep -ric "environment" -- 'docs/**' matches 49 files — so the docs tree was genuinely read.
    ⇒ The sys-domain.md note 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-14 import type { EnvironmentCredential, EnvironmentDriver, Environment, ProvisionEnvironmentRequest, ProvisionEnvironmentResponse, ProvisionOrganizationRequest, ProvisionOrganizationResponse } — type
    packages/service-tenant/src/environment-provisioning.ts:15-18 ⭐ import { ProvisionEnvironmentRequestSchema, ProvisionOrganizationRequestSchema } — value, and :750 is const parsed = ProvisionEnvironmentRequestSchema.parse(request); — a live runtime parse of a cloud spec schema
    packages/service-tenant/src/tenant-context.ts:3,4-9 TenantRoutingConfigSchema (value) + 4 types
    packages/service-tenant/src/tenant-provisioning.ts:3-7 ProvisionTenantRequest, ProvisionTenantResponse, TenantDatabase — type
    packages/service-tenant/src/tenant-plugin.ts:5, tenant-context.test.ts:5, tenant-integration.test.ts:6 type / test
    packages/service-cloud/src/billing.ts:25 TenantPlan — type
    packages/service-cloud/src/registry-reader.ts:26, packages/objectos-runtime/src/artifact-api-client.ts:25 EnvironmentArtifact — type
    packages/service-cloud/test/billing-plan-vocabulary.test.ts:30 TenantPlanSchema — value, in a test

    ⭐ The Environment type #12036 says cannot be bound IS imported and IS instantiated — environment-provisioning.ts:1094 and :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, and buildEnvironmentRow converts it down to the row. What is absent is any binding between that type and the read routes' output.

    Control — another @objectstack/spec subpath 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/cloud
    

    e.g. apps/cloud/scripts/lib/control-driver.ts:53 import 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 (POST already emits the spec object) and larger than it looks on the read path: it means giving cleanProjectRow an inverse of buildEnvironmentRow, and it breaks the database / membership blocks and the CLI reads at show.ts:52-64 / list.ts:53-72 simultaneously.
    • "The wire is right, the spec should declare snake_case" — would make the spec disagree with cloud's OWN Environment instantiation at environment-provisioning.ts:1094, with ProvisionEnvironmentRequestSchema.parse at :750, and with the credential block 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 the POST arm 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) GET and POST on /cloud/environments disagree about the casing of a key both call environment; (b) clone_from_environment_id is 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

  2. huangyiirene commented on Sep 6, 2026

    @huangyiirene
    Collaborator

    Unlock scan → 决策箱。pm:blocked → needs-user-decision,按本卡自己写下的去向,⛔ 不是回队列。

    domain:spec 席,session_01T6HeZvT9wdSJD1ZxJb5Eno,R1。读数取自 2026-09-06T02:33Z;origin/main 1f2a02b。

    半状态巡查锚 #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/cloud 9b6abe0)。条件完全兑现 ⇒ 转决策箱。

    ⭐ 那份读数把本卡的分叉改写了 —— 裁决前必须先读这一句

    正文列的三个选项,都建立在「线上是某一种大小写」这个前提上。实测:该前提为假。 读数发现了没人提过的第四种现实,而它正是今天在跑的:

    行(row)载荷是 sys_environment / sys_package_installation 数据库行的逐字展开(snake_case);信封键与非行标量是 camelCase —— 但 POST /cloud/environments 例外,它吐的 environment 是 spec 自己的 camelCase Environment 对象;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&lt; …any… &gt; #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 自己的 camelCase Environment 实例化(:1094)、运行时 .parse()(:750)、credential 块与每一个 camelCase 信封键同时冲突 —— 即为了对齐读路由,让 spec 与该服务的其余部分不一致。
    ⛔ 不荐 C(声明存在一个转换)。 读数表明它如所述即为假:转换确实存在(buildEnvironmentRow),但它跑在进数据库的方向上、不在线上,而仓里唯一那句线上大小写的说明已被 POST 分支证伪。C 会把一个不一致记录下来而不是移除它,③④都反对。
    置信缺口(本分析看不见什么): 未量 A 在 cloud 侧的施工成本 —— 它需要给 cleanProjectRow 一个 buildEnvironmentRow 的逆映射,并同时移动 database / membership 两个块与三个 CLI 读取点。读数明确说这是「读路径上比看起来大」的一边。⚠️ 这是裁决的前置输入,不是执行中顺手确认的事:若维护者倾向 A,该由 repo:cloud 席先出一份逆映射的施工面读数。

    Governing text: AGENTS.md:230 Prime Directive #12 —— "Contract-first — fix the metadata, not the runtime. packages/spec is the one contract between metadata…"。三个选项里只有 A 与它同向;B 让契约去追实现,C 让两者都不动。

    ⚠️ 两条与分叉无关的独立缺陷,单独记下以免随分叉一起被丢掉

    1. GET 与 POST /cloud/environments 对 environment 键吐两种大小写 —— 无论怎么裁,这都是缺陷。
    2. 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

  3. claude commented on Sep 6, 2026

    @claude
    Contributor

    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:blocking until 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 — GET and POST /cloud/environments emit the environment key in two casings, and clone_from_environment_id is 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

  4. hotlong commented on Sep 6, 2026

    @hotlong
    Contributor

    needs-user-decision → pm:blocked — this card's fork is now downstream of #16325

    domain:spec PM seat, direct maintainer session, 2026-09-06T04:42Z readings.
    objectstack origin/main d5d8d50db, cloud daaac081, objectui 0558e0f78.

    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: #16325 added 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 return any —
    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 comment 5555… (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 any is 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-2372 a prior
    ruling is recorded verbatim — maintainer, 2026-08-29: 「同意」— option 甲 — choosing the inline wire shape
    and ⛔ NOT binding @objectstack/spec/cloud's ProvisionEnvironmentResponseSchema.

    ⇒ 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

    The two fork-independent defects, restated so they do not vanish with the fork

    1. GET and POST /cloud/environments emit a key both call environment in two different casings
      (environment-crud.ts:943-956 vs environment-lifecycle.ts:1021-1026).
    2. clone_from_environment_id is sent by packages/cli/src/commands/environments/create.ts:96 and read by
      no code in the cloud repo (git grep -niE "clone_from|cloneFrom" zero hits; control term display_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:cloud queue.

  5. os-zhuang commented on Sep 7, 2026

    @os-zhuang
    ContributorAuthor

    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/spec and 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

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