Skip to content

feat(client): environments.create() declares the three response keys the control plane really sends (#12883) - #13379

Merged
os-trump merged 1 commit into
mainfrom
claude/issue-12883-environments-create-response-keys
Aug 30, 2026
Merged

feat(client): environments.create() declares the three response keys the control plane really sends (#12883)#13379
os-trump merged 1 commit into
mainfrom
claude/issue-12883-environments-create-response-keys

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes #12883

client.environments.create() declared its unwrap shape as the single key
environment, while POST /api/v1/cloud/environments answers 201 with three
more. warnings in particular is the channel a partially-degraded provision
uses to report what it could not do, and no SDK caller could reach it without
an as any.

What changed

packages/client/src/index.ts — the environments.create unwrap now declares:

key declared as why
environment unchanged already declared; its erased any is deliberate (#11925 / #12036)
warnings string[], PRESENT the partial-degradation channel — the business pull of this card
durationMs number, PRESENT
hostnameAssignment OPTIONAL, an object of requestedHostname / assignedHostname the producer forwards it only when it renamed a colliding hostname, "so absence stays absence"

Per the maintainer ruling of 2026-08-29
(verbatim: 「同意」, option 甲) the three keys are typed as the inline wire
shape
and are deliberately not bound to @objectstack/spec/cloud's
ProvisionEnvironmentResponseSchema. Those are camelCase row contracts, and the
/api/v1/cloud/* control plane this namespace calls speaks snake_case — the
constraint already recorded on the namespace docblock. Binding them would
typecheck and be false.

The docblock at the declaration site used to say these keys were "deliberately
not declared here ... a separate decision, not part of this rename". That
decision has since been made, so the comment now records the ruling instead of
going on asserting a stance the repo no longer holds.

The request side is untouched — #3739 is a separate card and is not addressed
here. Related consumer-side card: objectstack-ai/objectui#6707.

Which readings are measured here, and which are inherited

⚠️ The producer shape is an INHERITED reading, not one this PR measured.
objectstack-ai/cloud is not in this session's repository scope, so the handler
body this declaration is written against — packages/service-cloud/src/routes/environment-lifecycle.ts,
POST /cloud/environments, spreading environment / warnings / durationMs
and a conditional hostnameAssignment — is the card author's 2026-08-28
measurement, relayed. Neither this PR nor any gate in this repo has read it, and
no gate here ever can. That is stated in the code comment too, so the next
reader does not have to rediscover it.

Measured in-repo and load-bearing for this change: the declaration site and its
docblock, the request-side snake_case parameters, the spec schema, and every
consumer of environments.create() in this repo (swept below).

⚠️ One tension this PR implements rather than resolves

packages/spec/src/cloud/environment.zod.ts:346 marks warnings .optional()
inside ProvisionEnvironmentResponseSchema, while the ruling declares it
PRESENT. The inherited handler quote spreads it unconditionally
(warnings: result.warnings), which is consistent with "present" only if
result.warnings is always an array — something only the cloud repo can settle.
This PR implements the ruling: it is the authority, and the counter-evidence
is a defensive .optional() in a schema this change is explicitly not binding.
The divergence is recorded here rather than silently reconciled in either
direction. The same schema carries a second, heavier divergence (a REQUIRED
credential the handler quote does not send); it is unjudged, out of scope for
this card, and reported separately to the dispatching seat rather than touched
here.

Verification

All of the below ran on the final commit, git rev-parse --short HEAD = 5c8be5a65b.

Tests

packages/client/src/client.environments-namespace.test.ts gains a compile-time
pin (createDeclaresTheWireResponseKeys) and three runtime relays. The type pin
carries the teeth: presence is asserted by assigning into non-optional locals,
optionality by a @ts-expect-error that only holds while undefined is in the
type, and credential is pinned as UNDECLARED so binding the spec schema goes
red rather than landing quietly.

pnpm --filter @objectstack/client test        -> Test Files 30 passed (30) · Tests 405 passed (405)
pnpm --filter @objectstack/client typecheck   -> check:test-typecheck: OK — @objectstack/client's test layer
                                                 compiles under packages/client/tsconfig.test.json;
                                                 0 file(s) / 0 error(s) held in test-typecheck-debt.json
pnpm --filter @objectstack/client exec vitest run --maxWorkers=2 src/client.environments-namespace.test.ts
                                              -> Test Files 1 passed (1) · Tests 10 passed (10)

NOT-MEASURED guard for that green: the package's BUILD tsconfig.json excludes
**/*.test.ts, so tsc --noEmit alone says nothing about the new pins.
tsc --listFiles -p tsconfig.test.json was used to confirm the program really
reads both edited files — 1 hit for packages/client/src/index.ts, 1 hit for
client.environments-namespace.test.ts, positive control 33 hits under
packages/client/src/.

Consumer sweep

The widening is additive, and this proves it rather than asserting it.

sweep result positive control
environments.create repo-wide (ts/tsx/mts/cts/js/mjs/cjs/md/mdx/json, node_modules and dist excluded) 1 production consumer — packages/cli/src/commands/environments/create.ts:91 — plus 4 call sites in the client's own test file and 2 changeset mentions environments.activate in the same population: 4 hits
destructured / computed reaches (} = client.environments, environments['create'], = client.environments) 0 hits client.environments in the same population: 25 hits, so the pattern family is reachable
sibling repo objectui 0 hits objectstack/client there: 97 hits, so that corpus is reachable

Every downstream consumer of @objectstack/client typechecks:

pnpm --workspace-concurrency=2 --filter '...@objectstack/client' run typecheck
  -> Scope: 7 of 79 workspace projects — @objectstack/client, @objectstack/cli,
     @objectstack/client-react, @objectstack/dogfood, @objectstack/example-crm,
     @objectstack/example-showcase, @objectstack/example-todo — exit 0
pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/commands/environments/environments.test.ts
  -> Test Files 1 passed (1) · Tests 74 passed (74)

The filter direction is the PREFIX form (...@objectstack/client) = dependents.
Direction control: the suffix form (@objectstack/client...) resolves to a
different, 34-package upstream set, so the 7 above are consumers and not
dependencies. Each of the 7 echoed its own typecheck script in the run, so
this is not a zero-match filter passing silently.

Reverse verification

Direction predicted before running: RED, and red specifically in
check:test-typecheck rather than in tsc --noEmit (the build config cannot see
the pins), as 8 errors on the reads of the three keys — 4 in the type pin,
4 in the runtime tests — with the @ts-expect-error lines staying suppressed and
vitest staying green, because the pin's teeth are in the type layer only.

Ablation: revert only the widened unwrap generic back to the single environment
key, leaving the pins in place. No build or dist is involved — the pin imports
the mutated module relatively (from './index'), source to source, so tsc reads
the edited bytes and not a stale declaration file.

HEAD blob (git rev-parse HEAD:packages/client/src/index.ts)  6a0ff280d5a4f511ff0b7235bee863f57c73d15e
pre-mutation  git hash-object                                6a0ff280d5a4f511ff0b7235bee863f57c73d15e   (equal -> tree was at HEAD)
post-mutation git hash-object                                18d2025a84ddc821ddb26a6f330193485e1e8309   (differs -> mutation landed on disk)
removed-text grep count                                      0   (the widened generic is gone)
post-restore  git hash-object                                6a0ff280d5a4f511ff0b7235bee863f57c73d15e   (equal to the HEAD blob)
git diff HEAD                                                empty
git status --porcelain                                       empty

Observed, matching the prediction exactly:

ABLATION_TYPECHECK_EXIT=1
check:test-typecheck: 1 problem(s)
  - src/client.environments-namespace.test.ts: 8 type error(s) in a file the ledger does not cover.

Honest limit on that reading: the gate reports a COUNT and a file, not TypeScript
error codes, so "all 8 are TS2339" is inferred from which sites can fail, not
quoted from the gate. The count and the file match the prediction exactly.
The restore used git checkout HEAD -- ABSOLUTE_PATH (never the bare form, which
reads from a possibly-poisoned index), the mutation script carried an
EXIT INT TERM trap restoring by absolute path, and both legs are proved by blob
hash rather than by exit code.

Gates

Union derived from the real change set by
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (3 paths
vs merge base 3ec8646f1; the script's own stale-tree note said nothing this
answer derives from had changed). All named families were run. Each line below is
the gate's own verdict text, not an exit code read through a pipe:

check:nul-bytes                    ✓ check-nul-bytes --self-test: 75 assertions over a temp git repo (real scan() path)
check:changeset-gate-self-tests    ✓ check-adr-0087-registration --self-test: 235 assertions … ✓ check-changeset-no-major --self-test: 116 assertions
check-adr-0087-registration        ✓ this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).
check-changeset-no-major           ✓ This diff introduces no `major` bump.
check-empty-changeset              ✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).
check:objectui-changeset           ✓ objectui-range --self-test: all checks passed
check:pm-half-states               ✓ check-half-states self-test: 1551 cases pass.
check:published-files              ✓ 69 publishable package(s) … declare a `files` whitelist …
check:slot-lookup                  ✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new
check:logger-receiver-detach       OK  every log channel keeps its receiver: 2305 non-test TS file(s) walked, 0 detach(es)
check:objectql-double-limit        baseline key set verified against 3ec8646: no files added.
check:page-declaration-shape       check-page-declaration-shape: OK — 34 page entries across 2316 sources …
check:where-matcher                ✓ where-matcher conformance holds: 316 matcher(s) discovered, 316 answer … correctly or refuse it loudly
check:cross-package-test-inputs    OK: 24 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
check-cross-package-test-inputs    OK: 24 package(s) read outside themselves, all declared …
check-ci-filter-parity             OK: all 123 declared cross-package glob(s) (88 unique) are covered by `core` or `crosspkg` …
check-comment-mask-adoption        OK  check:comment-mask-adoption — 14 private comment-stripper(s) …, all 14 recorded …
check:test-source-alias            check-test-source-alias OK — 72 packages with tests scanned …
check:type-source-resolution       check-type-source-resolution OK — 94 tsc program(s) across 77 packages scanned …
check:query-options-erasure        ✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new
check:engine-double-contract       check-engine-double-contract: OK — 709 pinned, 134 in the DEBT ledger, 3 exempt.
check:dispatcher-error-vocabulary  check-dispatcher-error-vocabulary: OK — 22 unregistered code-stamping site(s), all classified …
check:type-check-coverage          check-type-check-coverage: OK — 66/78 workspace packages type-checked (plus the root) …
check:type-check-debt              check-type-check-coverage --re-measure: OK — 30 ledger entr(ies) re-measured in 249.5s, 1558 raw tsc error(s) total, none above its recorded number.
check:dual-build-cjs-loads         ✓ 102 published require entry point(s) across 66 package(s) load; 610 emitted CommonJS file(s) parse
check:skill-examples (spec)        ✅ 260 prose examples type-check across 3 surface(s) — every marked block parsed
check-keyed-text-bounds            ✓ 112 *.object.ts files … 148 keyed text-family columns judged, 148 bounded.
check-plugin-teardown-shape        ✓ 64 Plugin implementation(s) across 5028 source(s) … baseline fully burned down
check-shard-attestation            ✓ 2 aggregate gate(s) count 3 declared leg(s) across 3 attesting job(s).
check-undeclared-dep-imports       ✓ 78 workspace packages … 7 ledger row(s), all evidence intact.
check-affected-docs                ✓ affected-docs self-test: 487 cases pass.
check-drift-comment                ✓ check-drift-comment: 56 cases pass across 5 fixture diff(s).
release-rehearsal-clone            ✓ self-test passed

Beyond the derived union, pnpm --filter @objectstack/client check:exported-any-returns
was run because this change edits an exported callable's return type:
✅ no NEW exported callable of @objectstack/client resolves to 'any': 317 callables reached (52 caller-supplied generics, not counted as erasure), 42 ledgered site(s) still open.

The build-dependent gates above were run after a full
turbo run build --filter=./packages/* --filter=./packages/*/* (70/70 successful),
so none of them read a stale dist.

Repo-wide ESLint was run in full rather than narrowed: eslint . --no-inline-config,
5474 files judged by eslint's own config, 0 errors and 0 warnings, exit 0. (One
honest difference from pnpm lint: that script prefixes node --stack-size=4000;
this invocation did not, and completed with zero fatal-parse messages.)

NOT MEASURED — recorded as neither green nor red, and not folded into the list above:

  • node scripts/check-test-completeness.mjs — refused: PREREQUISITE NOT MET — this gate grades a saved 'turbo run test' log, and no log was named (exit 3). Its own
    text says the local reading is NOT MEASURED; CI tees the log and passes the path.
  • node scripts/pm/check-half-states.mjs — refused: PREREQUISITE NOT MET — the token in the environment is not a valid GitHub credential (exit 3). Nothing was swept.
  • The producer handler body, as described above: inherited, not measured, unreachable
    from this session.

Changeset

.changeset/environments-create-declares-wire-response-keys.md — additive
minor on @objectstack/client, as ruled. Nothing else in this diff publishes,
so no other package is bumped.


Generated by Claude Code

… really receives (#12883)

`client.environments.create()` declared its unwrap shape as the single key
`environment`, while `POST /api/v1/cloud/environments` answers 201 with
`warnings`, `durationMs` and a conditional `hostnameAssignment` as well.
`warnings` is the partial-degradation channel, so no SDK caller could learn
what a provision failed to do without an `as any`.

Per the 2026-08-29 maintainer ruling (verbatim 「同意」, option 甲) the three
keys are declared as the INLINE WIRE SHAPE and are NOT bound to
`@objectstack/spec/cloud`'s `ProvisionEnvironmentResponseSchema`, honouring
the namespace docblock's recorded snake_case constraint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 3f54efdf52c7c80a3ec242fa9a9e779a5873f852packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8ba81c5eba2fdb4d9e5e9f114dfbfc6018aae164 — the merge of head 5c8be5a65bddf47daa6ecdf370e9dfd65803cf90 into base 3f54efdf52c7c80a3ec242fa9a9e779a5873f852, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8ba81c5eba2fdb4d9e5e9f114dfbfc6018aae164 && git checkout 8ba81c5eba2fdb4d9e5e9f114dfbfc6018aae164
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3f54efdf52c7c80a3ec242fa9a9e779a5873f852 5c8be5a65bddf47daa6ecdf370e9dfd65803cf90 && git checkout -B drift-repro 3f54efdf52c7c80a3ec242fa9a9e779a5873f852 && git merge --no-ff 5c8be5a65bddf47daa6ecdf370e9dfd65803cf90

node scripts/docs-audit/affected-docs.mjs --json 3f54efdf52c7c80a3ec242fa9a9e779a5873f852

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Copy link
Copy Markdown
Collaborator Author

PM 复核:ACCEPT-on-green。已 undraft,绿了就 arm。

domain:cli 执行 PM 席位(#6024) · 会话 session_01TvqBFLRzXdSPcbusDoED9k。⛔ 下面每条都从 diff 自己读,不是转述报告。

裁决被逐字执行

2026-08-29 裁决(verbatim 「同意」,option 甲)四项逐一对上:warnings: string[]durationMs: number presenthostnameAssignment optional、三者写成内联 wire 形状、⛔ ProvisionEnvironmentResponseSchema 未 import 未绑定。change set 恰好 3 个文件;请求侧未动(#3739 仍是独立卡)。

credential 陷阱不只是被绕开了 —— 它被钉住

派发令只要求 ⛔ 不要顺手绑 schema。PR 做了更强的一步:

// @ts-expect-error `POST /cloud/environments` is not declared to answer a `credential`
void created.credential;

⇒ 这条 @ts-expect-error 只在 credential 未被声明时成立。谁日后把它补进 SDK,指令变成未使用而变红。⭐ 一条防止本卡缺陷被反向再犯的活钉子,比一句注释强得多。

⚠️ 同样的手法用在 optionality 上:hostnameAssignment 的可选性由一条 @ts-expect-error 断言(只在 undefined 还在类型里时成立),present 的两个键则靠赋值给非可选局部变量来钉。⇒ 两个方向都会红,⛔ 没有一个方向是靠注释守着的。

⭐ 并且 'hostnameAssignment' in res 用的是 in不是真值判断 —— 生产者的契约是键不存在,一个把 undefined 物化出来的 relay 会通过真值断言却破坏该契约。这个区分是对的。

三条我特别看的诚实性

  1. 继承读数申报了。 cloud 仓不在会话范围内 ⇒ handler 形状是卡作者 2026-08-28 的读数转述,PR 正文和代码注释里都写明「no gate in this repo can check it」。⭐ 写进代码注释是对的:下一个读者不必重新推导。
  2. warnings 的张力实现了但没被悄悄和解。 spec :346.optional()、裁决说 present ⇒ PR 按裁决落,并把分歧一段话写清楚。⛔ 没有向任一方向静默偏离 —— 这正是派发令要的。
  3. 消融的读数限度自己说了。 门只打印计数和文件名、不打印 TS 码,所以「8 个都是 TS2339」被标为推断而非引用。⭐ 一个把自己读数边界说出来的消融,比一个假装读到码的消融可信。

NOT MEASURED 三条记法正确

check:test-completeness(无 turbo log)、check-half-states(token 非有效 GitHub 凭证)、以及producer handler 本体(跨仓不可达)。⛔ 三条都没被折进绿名单,也 ⛔ 没被记成红。

附加动作

check:exported-any-returns派生并集之外主动加跑的 —— 理由正当:本 diff 改的是一个导出可调用体的返回类型。⭐ 派生并集是下限不是上限,按改动性质补一道门是对的。


两条卡外发现的处置

credential 分歧 ⇒ 已立卡 #13381,路由到 domain:spec(⛔ 不留在本车道 —— packages/spec/** 是那个车道的)。带上了 dev 新加的读数:ProvisionOrganizationResponseSchema:406整体内嵌该 schema ⇒ 两个响应契约携带此分歧,⛔ 不是一个。卡上也写明修复会触发条款②路径肢 ⇒ 低于 CONTRACT_REVIEW_TIER 的席位落不了地。

retryProvisioning() / delete() 的邻居不对称 —— dev 明确标为线索,不是测量「this is a lead, not a measurement, and it must not be recorded as one」)。⭐ 本席照此对待:⛔ 不立卡、⛔ 不记成读数。它需要的正是 #13381 那次 cloud 读取;等那次读取有了结果,再决定要不要扫这一族。⛔ 在此之前把它写成 finding 就是把一个未测量的猜测装扮成事实。


处置:ACCEPT-on-green。 已 undraft。CI 完整跑绿后 arm(⛔ 不在未读完的绿上 arm —— 今天 #13352 就有一个失败 run 藏在后来的 skipped 之后)。


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

environments.create() declares one response key; the control plane sends four (warnings / durationMs / conditional hostnameAssignment undeclared)

2 participants