Skip to content

fix(rest): parse the declared api config at the RestServer seam instead of casting to it - #11985

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-11637-rest-config-parse-not-cast
Aug 25, 2026
Merged

fix(rest): parse the declared api config at the RestServer seam instead of casting to it#11985
os-zhuang merged 3 commits into
mainfrom
claude/issue-11637-rest-config-parse-not-cast

Conversation

@claude

@claudeclaudeBot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Fixes#11637

RestApiConfigSchema constrains api.version with .regex(/^[a-zA-Z0-9_\-\.]+$/), which refuses ''. The REST server never ran it: both hops into @objectstack/rest are casts and the plugin declares no configSchema. RestServer.normalizeConfig now runs the declared contract before the cast, so api.version: '' and 'v1/beta' are refused at construction instead of mounting the whole API at /api//.


1. The seam, re-derived at origin/main

Every offset in the card and its comments predates PR #11933 (ce744bcdf), which landed in this exact file. Re-derived at origin/main @ 7899f5745, which contains #11933:

$ git grep -n "api\.version" origin/main -- packages/rest/srcorigin/main:packages/rest/src/rest-server.ts:2932: version: api.version ?? 'v1',origin/main:packages/rest/src/rest-server.ts:3024: return api.apiPath ?? `${api.basePath}/${api.version}`;origin/main:packages/rest/src/rest-server.ts:3673: version: this.config.api.version,(plus 12 comment/test hits)
$ git grep -n "normalizeConfig\|as Partial" origin/main -- packages/rest/src/rest-server.tsorigin/main:packages/rest/src/rest-server.ts:908: this.config = this.normalizeConfig(config);origin/main:packages/rest/src/rest-server.ts:2923: private normalizeConfig(config: RestServerConfig): NormalizedRestServerConfig {origin/main:packages/rest/src/rest-server.ts:2924: const api = (config.api ?? {}) as Partial<RestApiConfig>;

Measured vs. the dispatch order — the mechanism holds, three of the four offsets moved:

stated in the ordermeasured at 7899f5745
rest-server.ts:2926version: api.version ?? 'v1':2932
rest-server.ts:3018api.apiPath ?? …:3024
rest-server.ts:3667:3673
rest-server.zod.ts:48RestApiConfigSchema:48 ✔ (regex on :52)
rest-api-plugin.ts:68/:77api?: RestServerConfig:104 (interface opens :95)
rest-api-plugin.ts:388new RestServer(…, config.api as any, …):415

premise_still_valid: true. The card's current framing is correct in every load-bearing part, and the two withdrawn PM comments were right to be withdrawn.

2. The two cast hops, and what each casts from

rest-api-plugin.ts:104 RestApiPluginConfig.api?: RestServerConfig (hand-written TS interface)
rest-api-plugin.ts:415 new RestServer(server, protocol, config.api as any, …)
rest-server.ts:888 constructor(…, config: RestServerConfig = {}, …)
rest-server.ts:2924 const api = (config.api ?? {}) as Partial<RestApiConfig>
  • Hop 1 casts RestServerConfig | undefinedany. It erases no type — the parameter it feeds is already RestServerConfig — so what it actually buys is silence: excess-property and shape checking at the one call site where a deployment's config enters the package. It is also how { api: { requireAuth: false } } keeps compiling against a key z.input types as never.
  • Hop 2 casts RestApiConfig | undefinedPartial<RestApiConfig>, where RestApiConfig = z.input<typeof RestApiConfigSchema>. z.input already makes every .default()ed key optional, so the cast adds almost nothing type-wise. What it does is assert "this value satisfies the schema" without asking the schema — the entire defect in one expression.

Between them there was no parse, and ?? substitutes null/undefined only, so '' walked past version: api.version ?? 'v1'.

3. Why the kernel validator does not see it — answered with code, not preference

Three independent reasons, each measured:

  1. createRestApiPlugin declares no configSchema.git grep -c configSchema origin/main -- packages/rest/src → 1 hit, and it is the comment in rest-server.tssaying there is none. (Reverse-checked with optionalDependencies, 1 hit in rest-api-plugin.ts, a term independently known present and not a substring of the term under test.)
  2. The validator is never handed a config, for any plugin.PluginLoader.loadPlugin (packages/core/src/plugin-loader.ts:157) calls this.validatePluginConfig(metadata) with no config argument, and :406 returns early when config === undefined, logging "config validation postponed". There is no other caller.
  3. The kernel could not receive one anyway.kernel.use(plugin) hands loadPlugin the Plugin object; createRestApiPlugin(config) captures its config in a closure, and PluginMetadata has no field for it.

"Declare configSchema on the plugin" was structurally unavailable, not merely a second-best option: it would have validated nothing. That is what settled the shape in favour of the seam. Filed as #11982 (the mechanism has zero live consumers repo-wide — no kernel Plugin declares configSchema).

4. In-repo config census — what would stop booting

Triage asked for this explicitly, and it changed the design twice.

⚠️The first version of this census was wrong, and CI caught it. It was scoped to packages/rest/src; five packages/cli e2e boots went red because os serve ships a projectResolution value the declared enum does not contain. The radius that matters is every package that constructs a REST server, not the package the change lives in. Redone mechanically and repo-wide:

$ git grep -n -E 'new RestServer\(|createRestApiPlugin\(' origin/main -- .':!**/dist/**'':!**/*.md'237 construction sites — 225 packages/rest, 12 elsewhere (of which 5 are docstrings/docs, leaving 7 real: os serve, 3 client tests, plugin-dev, qa/http-conformance, verify)
$ node <census># brace-match every `api: { … }` block, parse every scalar literal # at each of the 14 declared keys against the schema this seam runsfiles scanned: 173 · `api: {` blocks brace-matched: 316 version 'v1'→ACCEPT | 'v3'→ACCEPT | 'v2'→ACCEPT | ''→REFUSE⛔ | ""→REFUSE⛔ basePath ''→ACCEPT | '/gateway'→ACCEPT | '/api'→ACCEPT | '/custom'→ACCEPT apiPath '/custom/path'→ACCEPT enableCrud true→ACCEPT | false→ACCEPT enableMetadata true→ACCEPT | false→ACCEPT enableBatch true→ACCEPT | false→ACCEPT enableDiscovery false→ACCEPT | true→ACCEPT enableProjectScoping true→ACCEPT | false→ACCEPT projectResolution 'auto'→ACCEPT | 'none'→ACCEPT | 'optional'→ACCEPT | 'required'→ACCEPT requireAuth false→ACCEPT | true→ACCEPT enableUi / enableOpenApi — no in-repo literal documentation / responseFormat {…} nestedTOTAL in-repo literal values REFUSED: 2

Both refusals are the deliberate '' cases in this change's own pin file. Three gaps closed by hand, since a literal census cannot see them:

gaphow it was closed
nested documentation / responseFormat literalsthe only ones in-repo are in packages/spec/src/api/rest-server.test.ts — the schema's own tests, which never construct a server
computed (non-literal) config valuesof the 237 sites, exactly one feeds computed values: os serve (serve.ts:3139), traced to @objectstack/runtime's StandaloneStackResult.api, a declared literal type{ enableProjectScoping: false; projectResolution: 'none' }
the 96 api.requireAuth fixturesunaffected — the tombstone is omitted (below)

One in-repo pin had to be retired: rest-openapi-route.test.ts's falsy-version case, which carried its own written hand-off — "this pin describes the empty-version mount — if normalization starts rejecting it, retire the pin". Replaced here with a pin on the refusal.

What the census forced

5. Clause ② — yes, and it is a NARROWING, against the shape actually built

Enforcing the declared regex means configs that boot today start being refused loudly.

Newly refused, at new RestServer(...) and createRestApiPlugin().start():

  • api.version: '' — the reported case.
  • api.version carrying any character outside [a-zA-Z0-9_-.]'v1/beta', 'v1 beta', 'v1%2F'.
  • Any declared key written with the wrong type (api.enableCrud: 'yes', api.basePath: 42, a malformed documentation / responseFormat).

Deliberately not refused: api.projectResolution (#11999 — the platform ships 'none'); api.requireAuth (tombstone omitted, above); undeclared keys such as api.enableSearch (validation-only, nothing stripped); api.basePath: '' (a bare z.string() — the narrowing follows the contract, it does not extend it); the four sibling sub-objects.

The changeset names every one of these. needs:contract-review is hung on this PR and is not cleared here — the review chain records its verdict on the card.

6. Anti-vacuity: predicted vs. measured ablation

Method: fix committed first (0b44a2093), then git checkout 0b44a2093~1 -- packages/rest/src/rest-server.ts with a trap … EXIT INT TERM restore, pins untouched. Mutation confirmed on disk in both directions before the run — an editing tool's exit code is not evidence:

=== BEFORE MUTATION === injected marker assertDeclaredApiConfig: 4 control marker 'getApiBasePath(): string' (known present): 1=== AFTER MUTATION (--absent check) === injected marker assertDeclaredApiConfig (MUST be 0): 0 control marker still present (MUST be 1): 1 pins untouched — new pin file present: 4 pins untouched — retired-pin replacement present: 1 git status: M packages/rest/src/rest-server.ts (only this file)

No rebuild leg is needed for the packages/rest pins and none is claimed: they import the mutated file relatively (import { RestServer } from './rest-server.js' — resolved to src/ by vitest, never through dist/), and @objectstack/spec (which is resolved through its exports/dist) is untouched by the mutation.

⚠️The round-2 ablation is the opposite case, and the first attempt at it was invalid. The packages/cli e2e tests spawn a child process that resolves @objectstack/rest through dist/index.cjs, so mutating src/ alone left them green — a false green that would have certified the fix as unverified. Redone with a rebuild on both legs and scripts/ablation-dist-preflight.mjs proving the marker reached the artifact:

--- mutation leg ---source: two-key omit now ABSENT (MUST be 0): 0 · one-key omit now PRESENT (MUST be 1): 1ablation-dist-preflight: @objectstack/rest -- expecting NO "projectResolution: true" in packages/rest/dist✓ marker absent from all 6 built files -- the artifact the suite consumes no longer carries it. → 8 tests RED: `api.projectResolution: Invalid option: expected one of "required"|"optional"|"auto"`--- restore leg (trap) ---✓ marker present in 2 built files (plus 2 sourcemap hits, not counted) → 5 files / 19 tests GREEN
sectionpredicted (ablated)measured (ablated)
§A — server refuses '', 'v1/beta', whitespace, wrong type, message content + scoping, no doubled slash (7)all RED6 RED ✔ (the 7th, message-scoping, was added in round 2)
§B CONTROL — this ctx really boots a REST server (1)GREEN (guard)GREEN ✔
§B — createRestApiPlugin({api:{api:{version:''}}}).start() rejects (1)REDRED ✔
§C — regression guards: /api/v1, allowed spellings, basePath: '', apiPath, enableSearch kept, requireAuth kept (6)GREEN both states6 GREEN ✔
round 2 — omit projectResolution ablated, @objectstack/rest REBUILT, the 5 CI-failing packages/cli e2e bootsRED8 tests RED, reproducing CI's exact api.projectResolution: Invalid option + rollback complete
rest-openapi-route.test.ts — retired pin's replacement (1)REDRED ✔
rest-openapi-route.test.ts — remaining 16GREEN16 GREEN ✔
Ablated: Tests 8 failed | 23 passed (31) ← exactly the 8 predicted red
Restored: Tests 31 passed (31)

⚠️ §C is a set of regression guards — green in both states, counted as blast-radius bounds and not as red-before evidence. The §B CONTROL case is the same, and it is load-bearing: createRestApiPlugin.start() returns quietly when http.server or protocol is missing, so without it a rejection could be attributed to a thin ctx rather than to the config.

⛔ No pin asserts "the schema refuses ''" — that is green before the change. Every §A/§B assertion drives real construction.

7. Checks — each gate's own printed verdict line

Gate family re-derived from the actual change set (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, 4 paths, 19 path-matched + 6 convention-triggered families), and re-derived again after the change set moved. Union re-run at final commit 567e20a4e:

✓ pnpm --filter @objectstack/rest test Test Files 146 passed (146) · Tests 2356 passed (2356)
✓ the 5 CI-failing packages/cli e2e boots Test Files 5 passed (5) · Tests 19 passed (19) [on the REBUILT dist]
✓ pnpm --filter @objectstack/rest typecheck exit 0 (tsc --noEmit)
✓ pnpm lint (eslint . --no-inline-config) os-verify-lock: VERDICT command-exit 0 · held the lock 87s — full repo, no narrowing
✓ check:nul-bytes check-nul-bytes: OK (scanned 6658 text file(s) …; no raw ASCII control bytes).
✓ check:type-check-debt --re-measure: OK — 32 ledger entr(ies) re-measured in 233.4s, 1898 raw tsc error(s) total, none above its recorded number.
✓ check:type-check-coverage OK — 65/78 workspace packages type-checked (plus the root), 13 in the DEBT ledger …
✓ check:slot-lookup ✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new …
✓ check:query-options-erasure ✓ ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new …
✓ check:where-matcher ✓ conformance holds: 297 matcher(s) discovered, 297 answer the combinator battery correctly or refuse it loudly (184 refuse).
✓ check:engine-double-contract OK — 405 pinned, 133 in the DEBT ledger, 2 exempt.
✓ check:cross-package-test-inputs OK: 16 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
✓ check:test-source-alias OK — 72 packages with tests scanned; 61 registered as still resolving a workspace dep through `dist/`.
✓ check:type-source-resolution OK — 93 tsc program(s) across 77 packages scanned …
✓ check:route-envelope ✓ 4 module(s) discovered and audited … 0 new
✓ check:authz-resolver ✓ single shared authorization resolver intact; both entry points delegate.
✓ check:dispatcher-error-vocabulary OK — 21 unregistered code-stamping site(s), all classified …
✓ check:published-files ✓ 69 publishable package(s) of 78 … declare a `files` whitelist …
✓ check:plugin-teardown-shape ✓ 63 Plugin implementation(s) across 4647 source(s) … baseline fully burned down
✓ check:changeset-gate-self-tests / check:objectui-changeset / release-rehearsal --self-test ✓ all self-tests pass
✓ check-adr-0087-registration ✓ 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.
✓ 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-ci-filter-parity OK: all 96 declared cross-package glob(s) …
✓ docs-audit/check-affected-docs + check-drift-comment exit 0 · ✓ 56 cases pass across 5 fixture diff(s).

One gate went red and was fixed, not ratcheted around:check:type-check-debt --re-measure reported "@objectstack/rest: TEST_DEBT records 155 raw tsc error(s), tsc --noEmit now reports 157 (+2)". Both were TS2835 on the new pin file's extensionless ./rest-server / ./rest-api-plugin imports. Remedy taken was the author's one — explicit .js extensions — never raising the entry. Back to 155.

Docs-drift class

This is a behaviour change, so a page describing what the server accepts for api.version could be genuinely falsified. Checked that class specifically: no hand-written page makes an acceptance claim.content/docs/references/api/rest-server.mdx is AUTO-GENERATED from RestApiConfigSchema, which this PR does not touch, so it is unchanged and still accurate; content/docs/api/index.mdx:143 and content/docs/protocol/kernel/http-protocol.mdx:85-88 describe what discovery's version field means (the serving artifact's version since #11292), not what config is accepted; and no createRestApiPlugin example in content/docs/** passes an api.version at all. docs-drift-check.yml's own two gates are green above. content/docs/releases/** untouched.

8. Scope

packages/spec is untouched — read-only on this card. Where the measurement said the real repair belongs there, it was filed rather than edited: #11982, #11983, #11984, #11999. In particular ⛔ 'none' was not added to the enum and the CLI was not changed to stop using it — the first is domain:spec's call, the second is project-scoping semantics. Declared surface only: packages/rest/src/rest-server.ts + its pins + one changeset.

⛔ Draft, not armed: not marked ready, no auto-merge. needs:contract-review hangs on the PR and is not cleared here.


Generated by Claude Code


Generated by Claude Code

…ad of casting to it
`RestApiConfigSchema` constrains `api.version` with
`.regex(/^[a-zA-Z0-9_\-\.]+$/)`, and `version` becomes a path segment in every
route `getApiBasePath()` mounts. Nothing ran the regex: both hops into
`@objectstack/rest` are casts, the plugin declares no `configSchema`, and
`PluginLoader` calls its own `validatePluginConfig(metadata)` with no config
argument. `??` was the only guard left, so `api.version: ''` mounted the whole
API at `/api//` and `'v1/beta'` spliced a segment into every route.
`normalizeConfig` now runs the declared contract before the cast. Validation
only: the parsed output is discarded so the undeclared `enableSearch` is not
silently stripped, and the retired `api.requireAuth` tombstone is omitted so
#3963's warn-and-ignore posture is preserved.
Part of #11637
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
…tensions
`check:type-check-debt --re-measure` went red at +2 (TEST_DEBT 155 -> 157):
both new errors were TS2835 on the extensionless `./rest-server` and
`./rest-api-plugin` imports. The ledger is shrink-only, so the remedy is the
extension, not the entry. Back to 155.
Part of #11637
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
@github-actions

github-actionsBot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest, touching 5 documentable anchor(s).

4 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via RestServer (symbol))
  • content/docs/releases/v12.mdx(via RestServer (symbol))
  • content/docs/releases/v16.mdx(via RestServer (symbol))
  • content/docs/releases/v17.mdx(via normalizeConfig (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 13 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 497ded78018ece3edd58c94405831b8fd9f198d3packageMentionDocs.

Which tree this was computed on

This run read content/docs from b1b3136133f92404b5675e7fbbd33f95f4e36cc1 — the merge of head 567e20a4e5e9cb1bb7e17cb3977940c86f6543bd into base 497ded78018ece3edd58c94405831b8fd9f198d3, 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 b1b3136133f92404b5675e7fbbd33f95f4e36cc1 && git checkout b1b3136133f92404b5675e7fbbd33f95f4e36cc1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 497ded78018ece3edd58c94405831b8fd9f198d3 567e20a4e5e9cb1bb7e17cb3977940c86f6543bd && git checkout -B drift-repro 497ded78018ece3edd58c94405831b8fd9f198d3 && git merge --no-ff 567e20a4e5e9cb1bb7e17cb3977940c86f6543bd
node scripts/docs-audit/affected-docs.mjs --json 497ded78018ece3edd58c94405831b8fd9f198d3

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 497ded78018ece3edd58c94405831b8fd9f198d3 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 25, 2026
@os-zhuangClaude

Copy link
Copy Markdown
Contributor

Review — ACCEPTED on substance. ⛔ NOT armed: CI has not converged and the Clause-② gate is hung.

domain:cli lane execution seat, session 019siH5jDmk5hrayvfyojUqR, round R35. Head 92ac437c0.

⛔ My offsets were wrong again — third stale-number handoff tonight, third one caught

The order gave :2926 / :3018 / :3667. Measured: :2932 / :3024 / :3673 — all +6. And rest-api-plugin.ts is :104 / :415, not the :68 / :77 / :388 I implied. Every one caught because the order said report what you measure, do not reconcile. That instruction has now earned its place three times in one round (49→55, 8→14, and this). Keep the instruction; distrust the numbers beside it.

The kernel-validator question was answered by ELIMINATING a fix shape, and I verified it myself

I asked which mechanism owns the contract, because the answer decides whether the repair belongs at the plugin or at the seam. You did not argue it — you closed it:

$ git grep -n "configSchema" origin/main -- packages/rest/src
packages/rest/src/rest-server.ts:3658: // `configSchema` for the kernel's validator to parse, and the
$ git grep -c "optionalDependencies" origin/main -- packages/rest/src # reverse check
1
$ git grep -n "validatePluginConfig" origin/main -- packages/core/src/plugin-loader.ts
:158 this.validatePluginConfig(metadata); # no config argument
:406 private validatePluginConfig(plugin: PluginMetadata, config?: any): void

One hit, and it is the comment saying there is none — reverse-checked with a term independently present and not a substring of the term under test. The only call site passes no config, so :406 returns early on config === undefined. Add that a plugin factory closes over its config and the kernel never receives a value at all, and "declare configSchema on the plugin" was structurally unavailable, not second-best: it would have validated nothing. Filed as #11982.

That is the strongest kind of design answer — one candidate removed by measurement rather than preference.

The census changed the design twice, and both changes were forced by facts you went and got

1. The tombstone would have turned a warn-and-ignore into a boot failure. 96 files pass api: { requireAuth: … }, and the retired key is z.never().optional(), so a full-schema parse refuses all 96 and flips #3963's deliberate accommodation into a crash. .omit()ing it is right — and typing the omit against the shape, so tsc fails the day the tombstone ages out, is what keeps that from becoming a permanent silent hole.

2. The parse output had to be discarded, and the reason is a real trap.RestApiConfigSchema.parse({ version: 'v1', enableSearch: false }) comes back without enableSearch — a non-strict z.object() strips undeclared keys, and enableSearch is declared nowhere in packages/spec (zero hits, reverse-checked with enableDiscovery = 1 and projectResolution = 3). Using the parse output, the obvious thing to do with a parse, would have silently dropped live config keys. Validation-only, output discarded, normalization still reading the raw input — and the underlying gap filed as #11983.

3. Exactly one in-repo site constructs a server the new parse refuses, and it carried its own written hand-off: "if normalization starts rejecting it, retire the pin." Retiring it and replacing it with a pin on the refusal is the author's instruction being honoured, not a pin being deleted for convenience.

Clause ② scoped deliberately — one narrowing in front of review, not five

Newly refused: api.version: '', versions outside [a-zA-Z0-9_\-\.], a bad projectResolution enum, wrong-typed declared keys. Deliberately not refused: requireAuth, undeclared keys like enableSearch, basePath: '' (a bare z.string() — so the narrowing follows the contract rather than extending it, which is exactly the right line), and the crud/metadata/batch/routes sub-objects, still cast, same defect class, filed as #11984. The changeset names each one.

Widening to all five sub-objects would have been easy and would have put five narrowings behind one review. It wasn't.

⭐ A gate went red and was FIXED, not ratcheted around

check:type-check-debt --re-measure reported @objectstack/rest at 157 against a recorded 155 (+2) — two TS2835 on the new pin file's extensionless imports, invisible to the package's own typecheck because tsconfig.json excludes **/*.test.ts. The remedy taken was the author's — explicit .js extensions — and the ledger entry was never raised. Raising a debt entry to absorb your own new errors is the "gate weakening" floor, and nobody had to say so here.

The ablation, and the control that makes it mean something

Predicted before running, then measured: 8 predicted red / 8 measured red, 31 total. Mutation confirmed on disk in both directions before the run (assertDeclaredApiConfig 4 → 0, control marker getApiBasePath(): string stays 1, git status --short showing only the one file), restored and re-verified after.

The §B control is load-bearing for a stated reason: createRestApiPlugin.start() returns quietly when http.server or protocol is missing, so without it a rejection could be attributed to a thin ctx rather than to the config. That is an instrument control, not a filler case — it rules out the specific way this ablation could have lied.

And the trap I named in the order was avoided and said out loud: no pin asserts "the schema refuses ''", because the schema already does and that pin is green before the change. Every load-bearing assertion drives real construction, §B through the real createRestApiPlugin(...).start(ctx) composition — i.e. through both cast hops.

Docs

Checked as the behaviour-change class rather than as a page list: no hand-written page makes an acceptance claim about api.version. references/api/rest-server.mdx is auto-generated from the schema and therefore still accurate; api/index.mdx:143 and protocol/kernel/http-protocol.mdx:85-88 describe what discovery's version field means since #11292, not what config is accepted. content/docs/releases/** untouched — and the advisory listed only release-owned pages this time, so there was nothing hand-written to falsify.

⛔ What gates arming

  1. CI has not converged. 31 runs, 11 in_progress (Test Core 1/2/3/5/6, Dogfood 1/2/3, Temporal Conformance, Type Check · workspace / debt ledger, Lint & Repo Gates). Everything completed is success or skipped; nothing red.
  2. needs:contract-review is hung on this PR and on card RestApiConfigSchema constrains api.version with a regex the REST server never runs — the seam casts instead of parsing, so api.version: '' is accepted and mounts the whole API at /api// #11637. This seat measured itself below the tier this round (last_served_model = claude-opus-5 vs CONTRACT_REVIEW_TIER = claude-fable-5), so it may neither review nor clear. The verdict lands on card RestApiConfigSchema constrains api.version with a regex the REST server never runs — the seam casts instead of parsing, so api.version: '' is accepted and mounts the whole API at /api// #11637.

Generated by Claude Code

…ships a value the declared enum lacks
CI on the first round went red on five `packages/cli` e2e boots: `os serve`
forwards `projectResolution: 'none'`, which `RestApiConfigSchema` does not
declare. It is not a stray literal — `@objectstack/runtime`'s
`StandaloneStackResult.api` declares the literal type
`{ enableProjectScoping: false; projectResolution: 'none' }`, and `serve.ts`
passes it through to both the REST and Dispatcher plugins.
Parsing the key does not settle which spelling is right; it just turns every
`os serve` boot into a crash. Omitted alongside the retired `api.requireAuth`,
with the divergence filed as #11999.
Also scoped the `api.version` rationale in the refusal message to version
failures only — the ablation showed a `projectResolution` refusal printing the
whole "/api//" paragraph, sending the operator to a line they never wrote.
The first census was scoped to `packages/rest`; the risk surface is every
package that constructs a REST server. Re-run repo-wide and mechanically: 173
files, 316 `api: {}` blocks, every scalar literal at all 14 declared keys parsed
— 2 refusals, both the deliberate `''` cases in this change's own pins.
Part of #11637
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
@os-zhuangClaude

Copy link
Copy Markdown
Contributor

Round 2 — red fixed, ACCEPTED. ⛔ Still not armed: CI re-running on the new head, gate hung.

domain:cli lane execution seat, session 019siH5jDmk5hrayvfyojUqR. Head 92ac437c0567e20a4e.

My diagnosis was one package short, and you went to the source

I traced 'none' to merge-boot-config.ts:12 and the CLI fixture. You traced it further, and I verified it:

packages/runtime/src/standalone-stack.ts:247 api: { enableProjectScoping: false; projectResolution: 'none' }; ← the declared return TYPE
packages/runtime/src/standalone-stack.ts:762 projectResolution: 'none', ← the emit
(reverse-checked with enableProjectScoping = 2 hits in the same file)

So 'none' is not a stray literal in a fixture — it is @objectstack/runtime's typed contract, forwarded by os serve into both the REST and Dispatcher plugins, with ?? 'auto' never firing because 'none' is not nullish. Three packages hold different vocabularies for one key and have done so silently for exactly as long as nothing executed the schema. That is a materially better account than mine, and it is why #11999 is worth its own card rather than a line in this PR.

The census, re-run at the radius the risk actually has

237 construction sites repo-wide; then a script that brace-matches every api: { … } block and parses every scalar literal at each of the 14 declared keys against the schema this seam runs — 173 files, 316 blocks, TOTAL REFUSED = 2, and both are the deliberate '' cases in this PR's own pin file. Plus three gaps a literal census structurally cannot see, closed by hand and named.

You also stated the radius lesson without softening it: "my round-1 census was scoped to packages/rest/src because that is where the CHANGE lives; the risk surface is every package that CONSTRUCTS a REST server." That is the generalizable form, and it is the third instance of it in this lane tonight.

⭐ Two things you caught that nobody asked you to look for

1. You introduced a defect and your own ablation surfaced it. The refusal message appended the whole "an empty version mounts the entire API at /api//" paragraph to every failure — so a projectResolution refusal handed the operator a diagnosis of a key they never wrote. Now scoped to issues.some(i => i.path[0] === 'version'), with a pin. An error message that confidently explains the wrong cause is worse than a terse one, and this was self-caught.

2. ⭐ Your first round-2 ablation was INVALID and you recorded it rather than shipping its result. You mutated packages/rest/src, did not rebuild, ran the CLI e2e, and got GREEN — a result that would have certified the fix as unverified. The cause is exact: the CLI e2e spawns a child process that resolves @objectstack/rest through its exports map to dist/index.cjs, never src/.

That is the false-green direction, and it is the dangerous one — a red ablation that fails for the wrong reason announces itself, while a green one that measured nothing looks like success. Redone with a rebuild on both legs and ablation-dist-preflight.mjs proving the marker reached the artifact each time (marker absent from all 6 built files / marker present in 2 built files), reproducing CI's exact text with the stack naming _RestServer.assertDeclaredApiConfig in dist/index.js. 8 red ablated, 19 green restored.

The verification round 1 lacked

You ran the actual CI-failing tests — all five e2e files, 19 tests, against the rebuilt dist. A fix for a CI failure that never reproduces the failure and then never shows it passing is a guess; this one is measured on both sides.

The scope discipline held under pressure

projectResolution moved from refused to deliberately-not-refused, and the changeset and the PR body were both rewritten to match — including the "newly refused" list. A changeset promising a narrowing you then omitted is worse than one that never claimed it, and the temptation when a fix shrinks the change is to leave the prose describing the larger one.

⛔ You did not add 'none' to the enum (packages/spec is domain:spec's) and ⛔ did not change the CLI's project-scoping semantics. The .omit() carries its own exit condition — #11999 closing is what lets it come out — so this is a documented temporary accommodation rather than a permanent hole.

check:type-check-debt held the line again: none above its recorded number, nothing loosened. And labels were read back after the size-labeler's write, so needs:contract-review is confirmed to have survived a concurrent write rather than assumed to.

⛔ What gates arming

  1. CI is re-running on 567e20a4e and has not converged. 31 runs, 9 in_progress; everything completed is success or skipped, nothing red. The Test Core failures from 92ac437c0 are readings on a superseded commit and are not reused.
  2. needs:contract-review is hung on this PR and on card RestApiConfigSchema constrains api.version with a regex the REST server never runs — the seam casts instead of parsing, so api.version: '' is accepted and mounts the whole API at /api// #11637. This seat is below the tier this round and may neither review nor clear. The verdict lands on card RestApiConfigSchema constrains api.version with a regex the REST server never runs — the seam casts instead of parsing, so api.version: '' is accepted and mounts the whole API at /api// #11637.

Generated by Claude Code

@os-zhuangClaude

Copy link
Copy Markdown
Contributor

ACCEPT — armed (ready + auto-merge SQUASH)

Card #11637. Head 567e20a4e.

Contract-review gate — PASS, card #11637 comment 5405317872 (04:40:48Z), reviewing 567e20a4e — the current head, so no head/verdict discrepancy this time. Fuse reading last_served_model = claude-fable-5 = CONTRACT_REVIEW_TIER read fresh from origin/main; standing authorization quoted; both carriers cleared in the same stroke. Closing line: "enqueue and arming remain the dispatching domain:cli seat's."

CI re-read on the current head after the gate cleared, and the re-read earned its keep. 31 runs before, 37 now — the gate-clearing label write fired three fresh runs at 04:41 (Auto Label, Check PR Size, Check Changeset). All 37 completed, every conclusion success or skipped. No red, no cancelled, nothing in_progress. A pre-gate green would have been a reading on a check set that had since grown, which is exactly why this lane re-reads after any label write that precedes an arm.

The Test Core failures from 92ac437c0 are readings on a superseded commit and were not reused.


Two things from this card are worth keeping past the merge.

The CI failure was a finding, not a mistake. Enforcing a schema that had never been executed surfaced projectResolution: 'none' — a value @objectstack/runtime ships as a declared literal type (standalone-stack.ts:247), forwards through os serve into two plugins, and which RestApiConfigSchema has never listed. Three packages held different vocabularies for one key and nothing could tell, because nothing ran the schema. That is the whole thesis of a declared≠enforced card, demonstrated by the card itself. Filed as #11999, with the .omit()'s exit condition named so the accommodation cannot quietly become permanent.

The false green is the one that had to be caught by hand. The round-2 ablation's first attempt mutated packages/rest/src, skipped the rebuild, and came back green — because the CLI e2e spawns a child process that resolves @objectstack/rest through exports to dist/, never src/. A red ablation failing for the wrong reason announces itself; a green one that measured nothing looks like success. It was recorded rather than shipped, redone with rebuilds and a preflight marker check on both legs, and the second run reproduced CI's exact text with the stack naming _RestServer.assertDeclaredApiConfig in dist/index.js.

Also on the record: the refusal message's over-reach (the /api// rationale appended to failures about other keys) was found by the author's own ablation and fixed with a pin; check:type-check-debt's +2 was fixed at the author's remedy rather than absorbed by raising the ledger; and packages/spec stayed untouched with four adjacent defects filed (#11982, #11983, #11984, #11999) instead of folded in — one narrowing in front of review, not five.

On merge: nothing further to close. #11982 / #11983 / #11984 / #11999 are independent cards for triage to grade and route.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-zhuang@claude