Skip to content

fix(runtime): the package install door parses its whole body through PackageInstallBodySchema - #20218

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-19328-install-door-body-parse
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-19328-install-door-body-parse

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Refs #19328. This PR closes rows 1b, 3 and 4. It closes row 2 in two of its three positions. The row left open is row 2's third position: an unknown key at the TOP LEVEL of the wrapped form ({ manifest, bogus }). It still answers 201, with bogus dropped, because the declaration puts the wrapped branch in strip mode and its docblock forbids closing it. That makes it a spec seam, not a door defect (see Open question 1). Per triage 5780789216, this round lands as Refs.

Clause-②: no (narrowing)

What changed

POST /api/v1/packages (packages/runtime/src/domains/packages.ts, handlePackagesRequest, the parts.length === 0 && m === 'POST' branch) now parses the whole body once, at the top of the install path. It uses the union @objectstack/spec already declares for this door, PackageInstallBodySchema.safeParse(body). The door's own prose had named this call twice as the one call that closes the residual classes.

  • What the door now enforces: the WHOLE declaration, not only the card's four rows. Before this change, the door parsed only the id and version legs of the manifest. Each of the following therefore answered 201 and now answers 400: name (the manifest's other required key); the namespace grammar; the closed value sets of scope, runtime and packaging; a declared key's type; the retired-key tombstones (configuration, capabilities, extensions, loading); and unknown keys inside the nested blocks the declaration closes. Those blocks are contributes and its kinds[], data[] seeds (SeedSchema), navigationContributions[] and their items, engine, engines, and the structured form of permissions. The list was measured by walking ManifestSchema at this head. §8 of the body-contract file pins nine of these at the door, and the changeset's BREAKING paragraph names all of them, with a FROM → TO bullet for name.
  • Verdict placement. The verdict is answered after the id and version legs, which keep their published sentences. It comes before the duplicate-id 409, so a request-shape refusal never depends on server state.
  • Envelope. The envelope is the one the id and version legs already use, deps.error(msg, 400): status 400, code VALIDATION_ERROR, taken from the standard catalog's 400 member. No error code was minted.
  • Reads go through the parsed value. overwrite, settings and enableOnInstall are read off the parsed wrapped request, never the raw body. That makes the string-vs-boolean inversion in row 3 structurally impossible rather than patched.
  • Telling the forms apart. The two forms are distinguished by 'manifest' in parsed.data. This is the declaration's own disjointness: ManifestSchema is a strict close with no manifest key, and the wrapped branch requires one.
  • The manifest is stored as sent. The door hands the install writer the manifest the caller sent, not the parsed copy. Parsing through ManifestSchema adds defaults (scope: 'project', defaultDatasource: 'default'). The existing pins (packages-install-manifest-version.test.ts §2) and withWritableVerdict's header both record that this door stores a key-by-key copy with no defaults. The parse is a gate here, not a normaliser.
  • The refusal sentence. installBodyRefusal re-parses the branch of the form the caller wrote, to locate the failure. The verdict is still the union's. It surfaces each issue's own message verbatim, as the id leg surfaces manifestIdRefusal. When install options are spelled on the bare form, it appends one prescription, the declaration's own remedy: send the wrapped form, or ?overwrite=true for overwrite. The option key set is read off PackageInstallRequestSchema.shape and is never listed by hand. The shared union-branch ranking (zodIssuesToFields) is not used for this. Measured, it picks the bare branch for { manifest: null, … } and reports both branches for a bare body with no type.
  • Untouched regions. The @objectstack/spec/kernel import line and the PATCH /packages/:id version check are not touched (the PR feat(spec)!: the canon for a package version is SemVer 2.0.0 — nine carriers, one grammar #19637 fence). The new import is on its own line.

Before and after, measured against the real door

Method: the real HttpDispatcher over a real SchemaRegistry, with OS_HOME redirected, driven by a one-shot probe that is not committed. "Before" is origin/main 1c8b320a8. "After" is this branch at df0108189.

row request before: status · installed · enabled after: status · installed · enabled
1b wrapped, manifest with no type 201 · yes · true 400 VALIDATION_ERROR · no · –
1b bare, no type 201 · yes · true 400 VALIDATION_ERROR · no · –
2 wrapped, unknown key at the TOP LEVEL 201 · yes · true 201 · yes · true (open: declared strip, the seam)
2 wrapped, unknown key INSIDE the manifest 201 · yes · true, key stored 400 VALIDATION_ERROR · no · –
2 bare, unknown key 201 · yes · true, key stored 400 VALIDATION_ERROR · no · –
3 wrapped, enableOnInstall: 'false', fresh id 201 · yes · true (inverted) 400 VALIDATION_ERROR · no · –
3 wrapped, enableOnInstall: 'true', fresh id 201 · yes · true 400 VALIDATION_ERROR · no · –
3 wrapped, overwrite: 'true', id already installed 409 RESOURCE_CONFLICT (read as absent) 400 VALIDATION_ERROR · existing row untouched
4 bare, enableOnInstall: false, fresh id 201 · yes · true (ignored, stored as a manifest key) 400 VALIDATION_ERROR · no · –
4 bare, overwrite: true, id already installed 201 · honoured, stored as a manifest key 400 VALIDATION_ERROR · existing row untouched
4 bare, settings: {a:1} 201 · honoured as install settings, stored as a manifest key 400 VALIDATION_ERROR · no · –
ctl wrapped, well-formed 201 · yes · true 201 · yes · true
ctl bare, well-formed 201 · yes · true 201 · yes · true
ctl SDK wire shape, enableOnInstall: false 201 · yes · false 201 · yes · false
ctl SDK wire shape, overwrite: true, id already installed 201 201
ctl bare, well-formed, ?overwrite=true, id already installed 201 201

Row 4 had moved from what the card says. The card says install options on the bare form are "ignored". Measured, they were handled key by key: enableOnInstall was ignored, while overwrite: true and settings were honoured. All three were stored inside the manifest. The door read body?.overwrite and body.settings without checking the form. Rows 1b, 2 and 3 read as the card relayed them.

Row 4's answer, decided by the declared schema. ManifestSchema's strict close refuses settings, enableOnInstall and overwrite on a bare body by name. The docblock states the remedy: «a caller that needs an option sends the wrapped form». So the declared answer is a refusal, and the refusal names the wrapped form. Neither branch is relaxed. A bare body keeps ?overwrite=true, which the docblock names as its one route.

The SDK control (its request shape is the one production caller of enableOnInstall)

  • Permanent pin, door side. packages-install-body-contract.test.ts §7 builds the body exactly the way client.packages.install(m, options) builds it, and round-trips it through JSON. It checks three calls. install(m, { enableOnInstall: false }) answers 201 and is disabled in all three records: the returned row, the registry, and the durable file. install(m) answers 201 and is enabled. install(m, { overwrite: true }) over an installed id answers 201. The SDK side of the same shape is already pinned in packages/client/src/client.test.ts.
  • One-shot probe, not committed. The real ObjectStackClient ran through the real HttpDispatcher from the rebuilt @objectstack/runtime dist, built from 0e3d3fdf5. Results:
    • install(m, { enableOnInstall: false }) → enabled: false, and the registry row is false too.
    • install(m) → enabled.
    • install(m, { overwrite: true, enableOnInstall: true }) → 201, and the manifest was replaced.
    • install(m, { settings: { a: 1 } }) → the settings landed.
    • A manifest with no type → the client threw, with code VALIDATION_ERROR and status 400.
    • In the same run, packages-write-envelope.test.ts, client.test.ts and readme-package-install-example.test.ts stayed green: 4 files, 222 tests.

The objectui caller, read by the seat at .objectui-sha f8a9d0fb (REWORK 5855224201): «The create path sends POST /api/v1/packages with { manifest: manifestBody }, where manifestBody = { ...draft, id, name, version, type: draft.type ?? 'app' }.» The file is packages/app-shell/src/views/metadata-admin/PackageFormDialog.tsx. Studio's create-package dialog therefore always sends a declared type, and row 1b's refusal does not break it.

Fixture triage (off-spec door fixtures, by disposition)

  • Declaration added (the fixture was never spec-legal; its subject is something else). Each fixture gains type: 'app':
    • package-door-namespace-conflict-code.test.ts (the manifest helper);
    • domain-handler-registry.test.ts (the duplicate-id drive);
    • packages-capability-gate.test.ts (the install write row);
    • http-dispatcher.test.ts (the 409 and ?overwrite=true cases).
  • Spelling corrected. In http-dispatcher.test.ts, the two POST /packages install cases changed type: 'application' to 'app'. 'application' is not a member of the declared enum.
  • Replaced whole (the pin asserted the very branch this PR removes):
    • packages-install-enable-on-install.test.ts. The case "the BARE body form does NOT honour the key" asserted 201 plus ENABLED. That was a standing pin on row 4, which the card believed had none. It now asserts 400 VALIDATION_ERROR and that nothing installed.
    • packages-install-preserves-lifecycle.test.ts. The case "the BARE body form still sets nothing" now asserts 400 VALIDATION_ERROR. It still asserts that the operator's disable stands in the registry and on disk.

Pins (new file packages/runtime/src/domains/packages-install-body-contract.test.ts)

Every refusal asserts status 400, error.code VALIDATION_ERROR and success: false. It also asserts that nothing was written: the id is absent from the registry, or the existing row is unchanged.

  • §0 Every row body is refused by the declaration itself.

  • §1 Row 1b, both forms.

  • §2 Row 2, the in-manifest and bare positions.

  • §3 Row 3: 'false' is never installed-enabled, 'true' is refused, and overwrite: 'true' is not read as absent.

  • §4 Row 4, enableOnInstall / overwrite / settings. The message names the misplaced key, "manifest", and ?overwrite=true.

  • §5 Parity for the top-level wrapped unknown key: the door answers what the declaration answers. The pin stays green whichever way the seam is decided.

  • §6 Ordering. The version leg's sentence survives when a body also lacks type. An off-spec body answers 400, not 409, on an installed id.

  • §7 Controls: both forms install, the manifest is stored as sent, wrapped settings land, and the SDK shapes behave as above.

  • §8 The rest of the declaration, added in patch round 2. Nine cases each answer 400 VALIDATION_ERROR, install nothing, and name the path:

    • no name, in the wrapped and the bare form;
    • a namespace outside the grammar;
    • a scope outside its set;
    • a non-string description;
    • the retired capabilities;
    • an unknown key inside contributes, inside a data[] seed, and inside engines.

    Each case is first asserted off-declaration by the declaration itself, beside a control that parses green and differs by the defect alone.

Ablation (commit first, then mutate, then restore)

  • Mutation. At e2c4c7d90, node scripts/ablation-replace.mjs replaced the anchor const declaredBody = PackageInstallBodySchema.safeParse(body); with a verdict that always passes and hands back the raw body. The anchor count went 1 to 0, the replacement count 0 to 1, and the blob 4f6b4be7 to 4a881701.
  • No dist involved. The subject is reached through a relative import (../http-dispatcher.js to ./domains/packages.ts), so no dist/ sits on the path. ablation-dist-preflight --absent confirms the marker is absent from runtime dist/ and that the tree is clean.
  • Red leg. The three files went 13 failed, 42 passed. Every row pin in §1–§4 failed, as did §6's 409-ordering case and both replaced bare-form pins. §0, §5, the version-leg ordering case and every §7 control stayed green. The direction was predicted before the run: red.
  • Restore. The tool restored with git checkout HEAD, then showed the blob after restore equals HEAD (4f6b4be7) and git diff HEAD is empty. Re-verified by hand, the tree is clean.
  • Green leg. The same three files then passed 55 of 55.

Tests and gates (head df0108189 unless stated; the patch rounds are recorded at the end of this section)

  • Runtime door tests. 31 door-touching test files, plus the uncommitted probe: 32 files, 779 tests passed.
  • Full runtime suite. pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2: 280 files, 3927 passed, 1 skipped, at 0e3d3fdf5, before any merge of origin/main. The branch has merged origin/main three times:
    • The first merge (df0108189, base e7f69dbba) brought no change to packages/runtime, the install schemas or the lockfile.
    • The second merge (f2e2c99bd, base 4df101c38) brought the [finding] three sibling list doors declare limit/cursor and never read them, one reporting hasMore: false as a literal — REBUILD of #19365, which stopped resolving on 2026-09-21 #19543 flow-list retirement: domains/automation.ts, dispatcher-plugin.ts, route-ledger.ts, packages/client/src/index.ts, seven runtime test files (dispatcher-plugin.anonymous-gate.integration, domain-handler-registry, anonymous-gate-actions-automation, automation-run-read-permission-gate, automation-write-capability-gate, http-dispatcher.tenancy-posture-outage, http-dispatcher) and test-typecheck-debt.json. It does not touch the install door, PackageInstallBodySchema, ManifestSchema or the install writers. 10 door and runtime test files re-ran on the merged tree at 481ad07f: 515 passed.
    • The third merge (dd49317f1, base 805af4f29) brought no packages/runtime change and no lockfile change.
  • Typecheck. pnpm --filter @objectstack/runtime typecheck exited 0. Its test layer (tsconfig.test.json) compiles the new file; --listFilesOnly counts it. The debt ledger held at 27 files, 191 errors and 69 signatures.
  • Derived gates. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 60 families: 59 exited 0. The one other is pnpm check:dual-build-cjs-loads: NOT MEASURED (exit 3, PREREQUISITE NOT MET: 31 packages have no dist/ without a full build, which is CI's). As a scoped substitute, require('packages/runtime/dist/index.cjs') loads (HttpDispatcher is a function). --ran reconciliation: 60 derived, 59 run, 1 NOT MEASURED, 0 UNRUN.
  • Lint. pnpm lint, the full eslint . --no-inline-config, not narrowed: exit 0.
  • Issue citations. node scripts/check-issue-citations.mjs --base origin/main: exit 0 (12 citations: 11 resolve, 1 cross-repo).
  • Patch round 1 (head 481ad07f, changeset only).
    • check-adr-0087-registration --base origin/main → 0, reading [BREAKING+clause-②-narrowing].
    • check-changeset-no-major --base origin/main → 0.
    • 10 door and runtime test files → 515 passed.
    • Derived gates: 60 derived, 59 exit 0, check:dual-build-cjs-loads NOT MEASURED (exit 3). pnpm lint → 0; the real issue-citation check → 0.
  • Patch round 2 (head 1358147bc: the changeset states the whole declaration, plus the §8 pins).
    • check-adr-0087-registration --base origin/main → 0, reading [BREAKING+clause-②-narrowing] with the one marker.
    • check-changeset-no-major --base origin/main → 0.
    • The same 10 files → 524 passed (515 plus the 9 §8 cases).
    • pnpm --filter @objectstack/runtime typecheck → 0.
    • Ablation, with the same anchor as before (the body parse replaced by a verdict that always passes): the body-contract file went 20 failed / 10 passed, and every §8 case answered 201 where it expects 400, i.e. installed before the fix. The restore was byte-identical to HEAD.
    • Derived gates: 60 derived, 59 exit 0, check:dual-build-cjs-loads NOT MEASURED (exit 3; 36 packages have no dist/). pnpm lint → 0; the real issue-citation check → 0 (8 citations, all resolve).

Changeset and bump

.changeset/19328-install-door-body-parse.md, @objectstack/runtime: minor, carrying Clause-②: no (narrowing), a BREAKING for callers of the install door paragraph with a FROM → TO remedy for each refused shape, and exactly one ADR-0087 not-required (no-migration-prescription) disposition. It was rewritten in patch round 1, after contract review 5855204061 answered FAIL on the bump and the seat's REWORK 5855224201 upheld it. The claim's own line stays Clause-②: no, because a claim line carries only yes | no.

Why minor and (narrowing): the diff shrinks the door's observed wire accept set. Bodies that answered 201 now answer 400, and bare-form overwrite: true and settings were HONOURED before and are refused now. scripts/pm/clause2-line.mjs defines no (narrowing) as «NOT a widening, but breaking». AGENTS.md Post-Task Checklist step 3 makes (narrowing) BREAKING: the changeset must carry its migration, and must state its ADR-0087 disposition in writing. The same door's two earlier pull-backs to the same declaration carry exactly this form, and this PR narrows more than either:

Each carries minor, Clause-②: no (narrowing), a BREAKING paragraph and adr-0087: not-required (no-migration-prescription). node scripts/check-adr-0087-registration.mjs --base origin/main now reads this changeset as [BREAKING+clause-②-narrowing] and accepts it with its marker (exit 0). For each refused shape, the changeset names what a client should send instead.

✅ Settled in patch round 1. The seat answered Open question 2 with A (REWORK 5855224201). The text below is kept as the record of why it was raised. The two earlier PRs on this same door, for #19120 (the version leg) and #19417 (the id leg), each pulled the door back to the same declaration. Both declared Clause-②: no (narrowing), bumped minor and wrote an ADR-0087 not-required disposition. This PR narrows more than either did: row 4's bare overwrite and settings were honoured before and are refused now.

Acceptance notes (observations, not filed)

  • A JSON null body answers 500. Through the dispatcher, a null or undefined body answers 500 INTERNAL_ERROR with the TypeError text ("Cannot read properties of null (reading 'manifest')"). The cause is the unchanged body.manifest || body line ahead of the id gate. The Hono adapter maps an unparseable body to {}, but a literal JSON null would reach the door as null. That path is inferred from the adapter code, not measured through a public door, so it is not filed. Owner: none.
  • ?overwrite is compared by hand. The query parameter is still read with a hand-written === 'true' || === true, not with parseBooleanParam, which is the file's own declared query-boolean coercion. That code is outside this card's rows and unchanged here. Owner: none.
  • Nested strip-mode shapes are stored as sent. Some nested shapes are still in strip mode, and those are stored as the caller sent them. A walk of ManifestSchema at this head found them only in the object form of a navigation item's visible and its meta. The gate refuses unknown keys in the manifest and in every nested block the declaration closes: contributes and its kinds[], data[] (SeedSchema is a strictObject), navigationContributions[] and their items, engine, engines, and the structured permissions. §8 pins three of these at the door. The earlier sentence here named data[] as strip mode and said the gate closed top-level keys only; both were wrong, and patch round 2 corrected them. Owner: none.

Spec-side follow-up: filed as #20219 (the #19327 pattern; it lands with or right after this PR)

@objectstack/spec's PackageInstallBodySchema docblock goes stale when this lands. Four places are affected:

  • The "measured residual" list still records classes 1b, 2, 3 and 4 as answering 201.
  • The paragraph on the two runtime door drives says they post bodies with no type; they now carry type.
  • The enableOnInstall docblock says the door "reads the raw body".
  • package-api.test.ts has a block, "the measured bare-form senders are the RESIDUAL", that makes the same claims.

That docblock ships in dist/api/index.d.ts, per the #19327 changeset. packages/spec/** is read-only for this card, so this is the #19327 pattern: a spec-lane docs follow-up, not a rider here.

Open questions

  1. The seam, row 2 at the top level of the wrapped form. Should PackageInstallRequestSchema be closed (.strict())? The door now asks the declaration, so it follows with no edit, and §5 stays green either way.
    • For closing, measured: no first-party producer sends an undeclared top-level key. The SDK sends manifest, settings, enableOnInstall and overwrite. objectui's PackageFormDialog sends { manifest }.
    • The cost of leaving it: a misspelt option such as enabledOnInstall: false is silently dropped, and the package installs ENABLED. That is the same harm as row 3, reached by a typo.
    • Why this PR does not do it: the docblock ties the strip mode to maintainer ruling A (batch 🔗 Broken links detected in documentation #148 item 4), and this card may not move a spec shape. So it is a spec-lane and maintainer decision.
  2. The changeset's Clause-② arm and bump. ✅ Answered A by the seat (REWORK 5855224201) and applied in patch round 1. The claim declares Clause-②: no, and this PR copies it. The same-door precedents ([finding] POST /api/v1/packages installs a manifest with NO version and answers 201, while its published declaration requires one — the door parses nothing #19120, [finding] the HTTP install door reads manifest.id positionally and never parses the body through ManifestSchema — POST /packages answers 201 to ids that MANIFEST_ID_PATTERN (spec, defineStack, os build, the publish face) refuses #19417) declared no (narrowing) with minor. Should the seat re-declare no (narrowing) with minor and an ADR-0087 not-required disposition? Recommended, because refusing previously honoured bare-form overwrite and settings is a narrowing of observed wire behaviour.

Generated by Claude Code

…kageInstallBodySchema

The install door parsed the manifest's id and version legs and read every
other key positionally off the raw body, so four classes the declared
union refuses still answered 201: a manifest with no `type`, an unknown
key inside the manifest or on a bare body (stored with the package), a
string-typed `enableOnInstall` / `overwrite` (`'false'` installed a fresh
id ENABLED), and install options spelled on the bare form (honoured or
ignored key by key, all stored as manifest keys).

The door now parses the body once through `PackageInstallBodySchema`,
answers a failure with the envelope its id/version legs already use
(400 VALIDATION_ERROR), ordered after those legs and ahead of the 409, and
reads `overwrite`, `settings` and `enableOnInstall` off the parsed wrapped
request. The manifest handed to the install writer is still the one sent
(a gate, not a normaliser). Off-spec door fixtures gain the declared
`type`; the two pins that asserted bare-form options were ignored are
replaced by refusal pins.

Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 27, 2026
@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/environment-routing.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line))
  • content/docs/deployment/cli.mdx (via unrecognized_keys (literal, a string literal in installBodyRefusal))
  • content/docs/getting-started/examples.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line))
  • content/docs/kernel/contracts/metadata-service.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line))
  • content/docs/kernel/services-checklist.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line))
  • content/docs/permissions/permission-sets.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line))
  • content/docs/permissions/system-context.mdx (via handlePackagesRequest (symbol, a top-level function))
  • content/docs/protocol/kernel/error-handling.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line))
  • content/docs/protocol/kernel/http-protocol.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line))
  • content/docs/protocol/objectql/types.mdx (via unrecognized_keys (literal, a string literal in installBodyRefusal))
  • content/docs/ui/apps.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line))

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

  • content/docs/releases/v17/17-0.mdx (via unrecognized_keys (literal, a string literal in installBodyRefusal), /api/v1/packages (route, a path literal in a comment on a changed line))
  • content/docs/releases/v17/17-4.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line))

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 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 26 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 805af4f290565955d6e6b56ee46fed45f721d955 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 407408eaa527c3a4c07a3b06aeb3153f42e672a0 — the merge of head 1358147bcdf8c8f31509db38223a59332d59daed into base 805af4f290565955d6e6b56ee46fed45f721d955, 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 407408eaa527c3a4c07a3b06aeb3153f42e672a0 && git checkout 407408eaa527c3a4c07a3b06aeb3153f42e672a0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 805af4f290565955d6e6b56ee46fed45f721d955 1358147bcdf8c8f31509db38223a59332d59daed && git checkout -B drift-repro 805af4f290565955d6e6b56ee46fed45f721d955 && git merge --no-ff 1358147bcdf8c8f31509db38223a59332d59daed

node scripts/docs-audit/affected-docs.mjs --json 805af4f290565955d6e6b56ee46fed45f721d955

⚠️ 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 805af4f290565955d6e6b56ee46fed45f721d955 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: df01081896b718b687036a4b5d5f801d82c8a7e3

① Derived judgments

  • Whole-body parse, once, at the top of the install branch — packages/runtime/src/domains/packages.ts handlePackagesRequest, parts.length === 0 && m === 'POST': const declaredBody = PackageInstallBodySchema.safeParse(body) runs right after requireManageMetadata; the verdict is answered after the id leg (ManifestSchema.shape.id.safeParse(rawId)) and the version leg, and before registry.getPackage(pkgId) (the 409). overwrite, settings, enableOnInstall are read off request = 'manifest' in declaredBody.data ? declaredBody.data : undefined. The only raw reads left are body.manifest || body (the legs and the stored manifest). Correct.
  • Door answers what the declaration answers — probed by reading against packages/spec/src/api/package-api.zod.ts (PackageInstallBodySchema = z.union([PackageInstallRequestSchema, ManifestSchema]), ManifestSchema a strictObject, type: z.enum([...]) required, enableOnInstall/overwrite z.boolean().optional()): wrapped well-formed → 201; bare well-formed → 201; body that is both ({ manifest, id, … }) → wrapped branch in strip mode, body.manifest || body picks the same manifest → 201, consistent; { manifest: null, id… } → both branches fail → 400, installBodyRefusal reads it as wrapped ('manifest' in body) → consistent with the union; string/number/array bodies → manifest?.id undefined → the id leg's 400; ?overwrite=true still read raw (query?.overwrite), body overwrite only from the parsed wrapped request; settings only wrapped; enableOnInstall string → 400, boolean → honoured. Correct, with one exception: a JSON null body → safeParse(null) fails, then body.manifest throws → 500 INTERNAL_ERROR, reachable through packages/adapters/hono/src/index.ts (c.req.json().catch(() => ({})) catches parse errors, not a literal null). Pre-existing, unchanged, admitted in the PR's Acceptance notes; the declaration answers 400 there. Not one of the card's rows.
  • Manifest stored as SENT — protocolSvc.installPackage({ manifest, settings }) / registry.installPackage(manifest, settings) receive body.manifest || body, not declaredBody.data. Sound: ManifestSchema carries no .transform/preprocess/aliases (only .default() on scope, defaultDatasource), and the top-level strict close means every stored top-level key is a declared one; the stored copy differs from the parsed copy only by absent defaults (which withWritableVerdict's header depends on — a Studio-created base is scope-less) and by nested strip-mode residue (pre-existing). Pinned §7 (toEqual(m)). Correct.
  • Refusal envelope and ordering — deps.error(installBodyRefusal(...), 400) → HttpDispatcher.error → apiErrorResponse({ httpStatus: 400 }), the same path the id/version legs use; §6 pins the version-leg sentence surviving a type-less body and 400-not-409 on an installed id. Package id is required, manifestIdRefusal, the version sentence and Package '<id>' already exists are untouched (diff touches only comments there). No published message or code changed; one new message minted (Invalid package install body (read as …)), no new code. Correct.
  • First-party SDK — packages/client/src/index.ts install(manifest, options) sends { manifest, settings, enableOnInstall, ...(overwrite) } with undefined dropped by JSON.stringify: wrapped form, JSON booleans → passes the wrapped branch. README (packages/client/README.md:254) and content/docs/api/client-sdk.mdx:336 examples carry type. Pinned door-side in §7 (three SDK shapes). Correct.
  • objectui package form — NOT VERIFIABLE from here: packages/console/dist is untracked at origin/main, the objectui repo answers 403 to the API and add_repo is fenced. The PR asserts PackageFormDialog sends { manifest } but never says that manifest carries type; row 1b's closure makes a type-less Studio create-package body a 400. Dogfood Regression Gate 3/3 and Dogfood Verify CLI are green on the head, coverage of that dialog unknown. Unverified — the seat must confirm at .objectui-sha f8a9d0fb before landing.
  • The two REPLACED pins — packages-install-enable-on-install.test.ts («the BARE body form does NOT honour the key») asserted 201 + ENABLED with the key ignored and stored; packages-install-preserves-lifecycle.test.ts («the BARE body form still sets nothing») asserted 201 + enabled: false under ?overwrite=true. Both protected «the door must not ACT on a bare-form key» — that reading is kept (nothing installs / the operator's disable stands in registry and on disk) and the 201 they also asserted is exactly the row-4 branch the declaration refuses. Replacing is right; the card's «nothing pins row 4» was wrong and the PR says so. Correct.
  • Fixture edits (type: 'app' in four files, 'application' → 'app' in http-dispatcher.test.ts) — none was spec-legal ('application' is not an enum member; type has always been required). They surface, not hide, the intended change: a type-less or mis-typed manifest now answers 400 (row 1b). No first-party producer sends type: 'application' (git grep at origin/main: only packages/spec/scripts/generate-sbom.ts, unrelated). Correct, but the same edit makes the spec docblock's «both door drives post no type … the door answers both 201» false (see ③).
  • Row-4 «moved» — the PR measured bare overwrite: true and settings as HONOURED before (the door read body?.overwrite / body.settings form-blind). Consistent with the origin/main code (body?.overwrite === true, body.settings). Correct, and it is the fact that decides ②.

② Semver level

Wrong as declared. Must be Clause-②: no (narrowing), '@objectstack/runtime': minor, a BREAKING banner, migration text per refused shape, and an ADR-0087 disposition marker.

  • The diff shrinks the observed wire accept set: bodies that answered 201 (and, for bare overwrite: true / settings, were honoured) now answer 400. scripts/pm/clause2-line.mjs: «A diff that NARROWS a published accept set answers it no truthfully — and a narrowing is a breaking change … no (narrowing) — NOT a widening, but breaking. This is the whole point of the arm.» scripts/check-changeset-no-major.mjs: an accept-set narrowing is breaking; in the launch window it ships as minor, with the **BREAKING** banner and the ADR-0087 disposition as the mandatory carriers. AGENTS.md Post-Task Checklist 3: «(narrowing) is BREAKING … Breaking changesets must carry their migration … must also state its ADR-0087 disposition, in writing».
  • Same-door precedents on origin/main for the identical «pull back to the declared contract» reasoning: .changeset/19120-install-door-parses-manifest-version.md and .changeset/19417-install-door-parses-manifest-id.md — both minor, Clause-②: no (narrowing), «BREAKING for callers of the install door», per-shape remedy, <!-- adr-0087: not-required (no-migration-prescription) … -->. This PR narrows MORE than either (previously honoured options refused).
  • As written (patch, bare Clause-②: no, no banner, no marker) check-adr-0087-registration.mjs's breakingDeclaration() reads no signal and the gate stays green — the exact feat(platform-objects): sys_job.timezone and sys_report_schedule.timezone are validated against the IANA domain #16296 shape the arm was created to stop. The dev flagged this as open question 2 and copied the claim's line verbatim; the claim's line is what is wrong.
  • Exact carry: front-matter '@objectstack/runtime': minor; line Clause-②: no (narrowing); a **BREAKING for callers of the install door** paragraph naming the five refused shapes (type-less manifest either form; unknown key inside the manifest / on a bare body; string-typed enableOnInstall / overwrite; install options on the bare form — enableOnInstall, overwrite, settings) each with FROM → TO («send instead» text already present is adequate: add type; spell the declared key; JSON booleans; the wrapped form or ?overwrite=true); and exactly one marker <!-- adr-0087: not-required (no-migration-prescription) … --> (nothing authorable is removed or renamed; the refusal carries the remedy), as the two precedents carry.

③ Boundary flags

  • Refs, not Fixes — correct per triage 5780789216 («a round that closes some rows lands as Refs #19328, never Fixes, with the remaining rows named»); the PR names the open position (row 2, wrapped top level).
  • Row-2 top-level position is a spec seam gated by ruling A — correct. PackageInstallRequestSchema is a plain z.object (strip); its docblock: «⛔ Do NOT close the wrapped branch with .strict() … the one direction this binding may never move (ruling A)». Refusing it door-side without the declaration would make the published contract admit a body the door refuses; triage's fence («if a row cannot be closed without moving a spec shape, that is a seam, stop and report») applies. §5 is a parity pin, so the door follows whichever way the maintainer rules.
  • packages/spec docblocks made newly FALSE (ship in @objectstack/spec dist/api/index.d.ts per .changeset/19327-install-door-residual-split.md) — yes, four places in package-api.zod.ts: (1) the «measured residual» list still records 1b OPEN and 2/3/4 answering 201; (2) «the runtime's own door drives … post a manifest with no wrapper … refused here on type alone, and the door answers both 201» — the drives now carry type: 'app' and the door answers a type-less body 400; (3) enableOnInstall's «implemented at the door … which reads the raw body»; (4) package-api.test.ts «the door answers 201 to all of them anyway» (a transcription, stays green, now false prose). packages/spec/** is read-only for this card; the [finding] the package install door's residual docblock records type and version as one 201 class — the version half is closed now #19327 pattern (spec-lane docs-only patch sub-issue of [finding] four residual classes the package install door still answers 201 to, measured unchanged by #19326 (triage: likely four cards) #19328, landing with or right after this PR) is the correct route and must be filed before landing, not left as «owner: none».
  • PR body claims — parse-once, parsed reads, envelope, ordering, strip-mode seam, stored-as-sent, SDK control, row-4 moved: all borne out by the diff and the new file packages/runtime/src/domains/packages-install-body-contract.test.ts (§0–§7, real HttpDispatcher over real SchemaRegistry). The objectui claim is unverifiable here (above). The «patch is what Clause-②: no alone takes» reasoning is wrong (②). The null-body 500 and the hand-written ?overwrite comparison are correctly reported as pre-existing and out of the rows; the null case is a declaration/door disagreement worth its own card.
  • CI on the head (check-runs API, read at review time): 29 success, 3 skipped, 1 in progress (Type Check · workspace), 0 failed. Build Core, all six Test Core shards, Dogfood Regression Gate 1–3, Dogfood Verify CLI, Temporal Conformance, Lint & Repo Gates, Type Check · debt ledger / consumer gates green. check-adr-0087-registration green only because the declaration hides the narrowing.

Implemented-by: claude/issue-19328-install-door-body-parse
Reviewed-by: session_01UYBdGBzWSrAMzpW8ah3GbP

Independence: INDEPENDENT AGENT (fed the card, the rulings and the PR only; not the dispatch order or the seat's conclusions)

VERDICT: FAIL

…rrowing

The body parse refuses shapes the door answered 201 to, and two of them
(bare-form `overwrite: true` and `settings`) were honoured before. The
changeset now carries what the same door's two earlier pull-backs to the
declaration carry: a minor bump, `Clause-②: no (narrowing)`, a BREAKING
paragraph with a FROM -> TO remedy per refused shape, and one ADR-0087
not-required disposition.

Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 481ad07f33a22aea78b3e36c04da2dca62968a34

Delta of: 5855204061 (FAIL on df010818)

① Derived judgments

  • Required change 1, form — .changeset/19328-install-door-body-parse.md at head: front matter '@objectstack/runtime': minor; line-initial Clause-②: no (narrowing) (line 7, blank lines either side, UTF-8 ②); a **BREAKING for callers of the install door.** paragraph; exactly one adr-0087: not-required (no-migration-prescription) … HTML-comment marker (grep count 1, line 79). Same form as .changeset/19120-… and .changeset/19417-… on origin/main. scripts/check-adr-0087-registration.mjs breakingDeclaration() (:629-642) reads BREAKING + clause-②-narrowing; readDisposition() (:1923-1947) accepts one marker; no-migration-prescription is in CATEGORIES (:494-500). Correct.
  • Remedies (a)–(d) checked against the door at head (packages/runtime/src/domains/packages.ts, byte-identical between df010818 and 481ad07f): (a) the eleven type members = 'plugin' + CORE_PLUGIN_TYPES (plugin.zod.ts:90-98, seven) + 'module', 'gateway', 'adapter' (manifest.zod.ts:443-449) — correct. (b) ManifestSchema is strictObject (manifest.zod.ts:326); installBodyRefusal (packages.ts:717-748) surfaces the unrecognized-key message verbatim; pre-PR manifest = body.manifest || body stored the key — correct. (c) enableOnInstall/overwrite are z.boolean().optional() (package-api.zod.ts:300, :319); pre-PR body?.overwrite === true read 'true' as absent → 409, and 'false' matched neither === true nor === false → installed ENABLED (4df101c38:packages.ts:1034-1035, :1095-1099) — correct. (d) pre-PR body?.overwrite and body.settings were read form-blind (:1034, :1045, :1048) → honoured; requestedEnabled = wrapped ? body?.enableOnInstall : undefined (:1095) → ignored — correct. ?overwrite=true with a bare body at head: packages.ts:1168-1169 still reads query?.overwrite === 'true' || === true raw, a bare body carrying no option keys passes ManifestSchema, pinned §7 (packages-install-body-contract.test.ts:388-395) — correct.
  • A refused shape is MISSING. ManifestSchema.name is z.string() with no .optional() (manifest.zod.ts:472). Pre-PR the door parsed only the id and version legs; the registry limb (packages/objectql/src/registry.ts:4188+) refuses only namespace/object-name conflicts; the protocol limb (packages/metadata-protocol/src/protocol.ts:22752+) parses the id leg alone («ManifestSchema are still not parsed whole here») and derives an absent namespace. So { id, version, type } with no name answered 201 on main and answers 400 at head. Same for an explicit namespace failing ^[a-z][a-z0-9_]{1,19}$ (:402-404), a scope outside cloud|system|project (:463), a wrong-typed declared key, and an unknown key inside a nested strict shape (contributes :590, engine :856, engines :107, data[] = SeedSchema, seed.zod.ts:34, navigationContributions, app.zod.ts:886). The changeset says «Four shapes of body are now refused» and «ManifestSchema requires type and closes the manifest against unknown keys» — read as exhaustive, both are false: the whole declaration is enforced, and a second required key is newly enforced. The SDK README at head (packages/client/README.md:251-252) itself says «id, name, version and type are required». A caller refused on name greps CHANGELOG.md for it and finds nothing. Wrong (incomplete). Every other sentence in the changeset checked true, the marker's reason included.
  • Required change 2 (objectui), verified independently — raw.githubusercontent.com/objectstack-ai/objectui/f8a9d0fb… answers 200 (a plain GET; the API answers 403). PackageFormDialog.tsx:193 seeds { version: '0.1.0', type: 'app' }; :256-262 builds manifestBody = { ...draft, id, name, version, type: draft.type ?? 'app' }; :266 posts { manifest: manifestBody }. package-schema.ts:28,60 derives the form from ManifestSchema via z.toJSONSchema (a curated subset, no UI-only keys reach the value); packages-io.ts:252 NAMESPACE_RE = /^[a-z][a-z0-9_]{1,19}$/ is byte-identical to the schema's (manifest.zod.ts:403) and nsOk requires it on create (:246). .objectui-sha at head is f8a9d0fb0596…. The seat's reading is correct, and holds for the «only declared keys» half it did not state. Note canSubmit (:247) does not gate on the inline ManifestSchema.safeParse issues (:233), so an off-spec draft that used to install 201 now gets the door's 400 rendered through readEnvelopeFailureText — the intended direction, not a happy-path break.
  • Required change 3 — [finding] once PR #20218 lands, PackageInstallBodySchema's published docblock still says the install door answers 201 to residual classes 1b, 2, 3 and 4, which it then refuses 400 #20219 exists (open, bare, filed 2026-09-27T10:43Z, parent [finding] four residual classes the package install door still answers 201 to, measured unchanged by #19326 (triage: likely four cards) #19328) and names the four sites: the residual section of package-api.zod.ts (:383-410), the door-drives paragraph there, the enableOnInstall docblock (:245), and package-api.test.ts «the measured bare-form senders are the RESIDUAL» (~:969). Confirmed.

② Semver level

'@objectstack/runtime': minor + Clause-②: no (narrowing) + BREAKING banner + not-required (no-migration-prescription) is the correct level and carrier set for a wire accept-set narrowing in the launch window (check-changeset-no-major.mjs:47-71: breaking ships minor; the banner and the ADR-0087 disposition are the mandatory carriers). The level is right; the BREAKING carrier is materially incomplete (①): it names four refused shapes where the door now refuses everything ManifestSchema refuses, name included.

③ Boundary flags

  • Merge of origin/main (f2e2c99bd, new base 4df101c38) — 93 files came in; the PR-own delta vs merge-base is the same 9 paths (+750/−51); the eight code/test files are byte-identical between the two heads except domain-handler-registry.test.ts and http-dispatcher.test.ts, which main also touched — the PR's hunks survived verbatim (offsets +15). Merge-brought packages/runtime changes are the [finding] three sibling list doors declare limit/cursor and never read them, one reporting hasMore: false as a literal — REBUILD of #19365, which stopped resolving on 2026-09-21 #19543 flow-list retirement: domains/automation.ts (the GET / listFlows branch removed), dispatcher-plugin.ts (GET /automation unmounted), route-ledger.ts (row removed), packages/client/src/index.ts (automation.list removed) plus four runtime tests. None touches the install door, PackageInstallBodySchema, ManifestSchema, registry.installPackage, protocol.installPackage, or SDK packages.install (client/src/index.ts:2635-2644 unchanged). No behaviour the pins or claims depend on moved. Since the base, origin/main (805af4f2) added 5 commits; .changeset/19328-* is not on main; no conflict.
  • PR body — Clause-②: no (narrowing) is line 3, line-initial, blank line above and below, no CRLF; consistent with the changeset; Check Changeset re-ran on the edited event (11:42) → success. The quoted clause2-line.mjs phrase exists verbatim (:95). Refs only, no closing keyword; no model names in body or changeset; draft, same-repo head, not governed. False sentences: (1) Acceptance note «Nested shapes that ManifestSchema leaves in strip mode (for example a data[] seed entry)» and «The gate closes the top-level manifest keys only» — SeedSchema is strictObject (seed.zod.ts:34), as are contributes, engine, engines, navigationContributions; the gate refuses unknown keys inside them. (2) «Tests and gates (final head df0108189 …)» and «The merge brought no change to packages/runtime, the install schemas or the lockfile» — true of the first merge (e7f69dbba) only; the second merge brought packages/runtime changes (above); the dev's round-1 report records a 10-file re-run (515 passed) at 481ad07f, the body does not. The before/after table («After is … df0108189») stays true because the door code is byte-identical.
  • CI on 481ad07f (check-runs API): 41 runs, 36 success, 5 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke by path filter; Auto Label and Check PR Size on the edited re-run), 0 failure, 0 in progress. All seven required contexts success: Lint & Repo Gates, TypeScript Type Check, Test Core (+6 shards), Dogfood Regression Gate (+3), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Both changeset gates run inside green jobs.

Implemented-by: claude/issue-19328-install-door-body-parse
Reviewed-by: session_01UYBdGBzWSrAMzpW8ah3GbP

Independence: INDEPENDENT AGENT (fed the card, the prior review, and the PR only; not the dispatch order or the seat's conclusions)

VERDICT: FAIL

  • Complete the changeset's BREAKING paragraph so it states what the door enforces at head — the WHOLE ManifestSchema, not four shapes: add a FROM → TO bullet for the second required key (name: FROM { id, version, type } TO the same manifest with "name": "…"; it used to install), and one bullet for every other declared constraint now enforced (the namespace grammar, the scope enum, a declared key's type, unknown keys inside the nested strict shapes contributes / engine / engines / data[] / navigationContributions) with the remedy «send the value the declaration states; the refusal names the path»; reword «Four shapes of body are now refused» and «requires type and closes the manifest against unknown keys» so neither reads as exhaustive. Mirror the same statement in the PR body's «What changed».
  • Correct the two false PR-body sentences: the acceptance note's strip-mode example (data[] is SeedSchema, a strictObject; the gate also closes the nested strict shapes, not «top-level keys only»), and the «final head df0108189» / «The merge brought no change to packages/runtime» sentences, which the second merge (f2e2c99bd, domains/automation.ts et al.) made false — record the 10-file re-run at 481ad07f there instead.

…ole declaration it enforces

The door parses the whole body, so it enforces everything ManifestSchema
declares, not four shapes: `name` (the other required key), the
`namespace` grammar, the closed value sets, the retired-key tombstones,
and unknown keys inside the nested blocks the declaration closes. The
changeset gains a FROM -> TO bullet for `name` and one bullet for the
rest, and no longer reads as exhaustive. The body-contract suite gains a
§8 table pinning each of those at the door.

Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1358147bcdf8c8f31509db38223a59332d59daed

Delta of: 5855593517 (FAIL on 481ad07f)

① Derived judgments

  • Required change 1, walked against ManifestSchema (packages/spec/src/kernel/manifest.zod.ts:326-927, byte-identical on origin/main and head). Required keys: id (:363, MANIFEST_ID_PATTERN), version (:428), type (:443-449: plugin + CORE_PLUGIN_TYPES seven, plugin.zod.ts:90-98, + module/gateway/adapter = the eleven the changeset lists), name (:472, z.string() with no .optional()); every other key is .optional() or defaulted (scope :463, defaultDatasource :419). Grammars: namespace ^[a-z][a-z0-9_]{1,19}$ (:403) = the changeset's «2–20 characters…»; engine.objectstack regex (:864). Closed sets: scope (:463), runtime (PluginRuntimeSchema :149, node|sandbox|worker), packaging (:165). Top-level tombstones configuration :555, capabilities :766, extensions :786, loading :823 — retiredKey() is z.never({ error }).optional() (shared/retired-key.ts), so any JSON value is refused with the prescription. Nested closed shapes: contributes (:590, strictObject) and kinds[] (:607), data[] = SeedSchema (data/seed.zod.ts, strictObject), navigationContributions[] (ui/app.zod.ts:886, strictObject) whose items are a discriminated union of strictObject/.strict() members only (:826-848), engine (:856), engines = PluginEnginesSchema (:107), structured permissions = PluginPermissionsSchema (:51) inside the union (:94-97); strictObject = z.object(shape,{error}).strict() (shared/strict-object.ts:109-110). Every shape the changeset names as closed is closed — nothing listed is strip mode. Correct.
  • Nothing listed was already enforced on main. The merge-base door (805af4f29:packages/runtime/src/domains/packages.ts) parses only ManifestSchema.shape.id (:963) and .shape.version (:1017); the POST branch (:886-1050) reads no name/type/namespace/scope. Registry limb installPackage (objectql/src/registry.ts:4188+) reads manifest.name only in a log line (+136) and gates namespace conflicts (+37-50), never the grammar; protocol limb (metadata-protocol/src/protocol.ts:22752+) parses the id leg alone and derives an absent namespace. The bullet «Before, only the id and version legs were» is exactly right. Correct.
  • name FROM → TO bullet (changeset :22-27): name required at :472; neither the pre-fix door nor either limb required it, so { id, version, type } installed on main and answers 400 at head; §8 rows 1–2 pin both forms. True.
  • Exhaustiveness. Opener (:9-15) says «whatever the reason» and «ends with one bullet for everything else»; the catch-all uses «The constraints include:». Neither reads as exhaustive. One imprecision: «the retired manifest keys configuration, capabilities, extensions and loading» (:58-59) reads as the whole tombstone set, while contributes carries ten more (events…commands, :630-731) and kinds[].globs (:616); those are refused with their own prescriptions and fall under the «unknown keys inside … contributes» bullet, so no caller is left without a remedy. Wording only, not a required change.
  • §8 pins, non-vacuity (packages-install-body-contract.test.ts:445-488, nine cases). Each case keeps the fixture's valid id/version (manifest() :36-42: com.acme.rest.of.declaration, 1.0.0), so the pre-fix door reached the install limbs, and neither limb reads name, namespace grammar, scope, description, capabilities, contributes, data[] or engines (above) → 201 before. The dev's ablation (5856093982) read 20 failed / 10 passed of 30 (it( count 21 + 9 loop cases = 30) with every §8 case «expected 201 to be 400». Controls differ by the defect alone: without(m,'name')↔m; namespace:'Acme-CRM'↔rest_of_declaration; scope:'tenant'↔'project'; description:5↔'x'; capabilities:{}↔absent; contributes:{bogus:1}↔{}; data[0] with/without bogus; engines:{bogus:1}↔{}. Each asserts declaration refusal, control green, expectRefused (:146-152), registry.getPackage(m.id) undefined, and the path in the message — installBodyRefusal (packages.ts:717-748) joins issue.path with ., so manifest.name, manifest.data.0 are named. Controls are asserted at the declaration, not driven at the door; §7 carries the door-level well-formed control. Non-vacuous, correct.
  • Required change 2, PR body (edited 13:03:14Z; Check Changeset re-ran 13:04:18Z → success; Q1–Q6 present). Q1 «What changed» bullet names the same set as the changeset ✓. Q3 strip-mode note: visible = EvaluatedExpressionInputSchema (shared/expression.zod.ts:263) whose object arm is ExpressionSchema.safeExtend (:93, :166, z.object) with meta: ExpressionMetaSchema (:73, :104, z.object) — the only strip-mode objects under ManifestSchema; everything else is strict or an open z.record (SeedSchema.records, dependencies, integrity, InlineLocaleMapSchema, nav params/filters) ✓; data[] is SeedSchema strictObject ✓; «§8 pins three of these» ✓. Q4 heading ✓. Q5 merges: df0108189 (parents e2c4c7d90, e7f69dbba; 1c8b320a8..e7f69dbba touches no packages/runtime, lockfile or install schema ✓); f2e2c99bd (parents df0108189, 4df101c38; the one runtime-touching main commit is 3875ae677 = [finding] three sibling list doors declare limit/cursor and never read them, one reporting hasMore: false as a literal — REBUILD of #19365, which stopped resolving on 2026-09-21 #19543, domains/automation.ts, dispatcher-plugin.ts, route-ledger.ts, client/src/index.ts ✓ — but seven runtime test files (dispatcher-plugin.anonymous-gate.integration, domain-handler-registry, anonymous-gate-actions-automation, automation-run-read-permission-gate, automation-write-capability-gate, http-dispatcher.tenancy-posture-outage, http-dispatcher) plus test-typecheck-debt.json, not «four» ✗); dd49317f1 (parents 481ad07f3, 805af4f29; 4df101c38..805af4f29 brings no packages/runtime, no lockfile ✓). Q6: 524 = 515 + 9 ✓; 20/10 of 30 ✓. Clause-②: no (narrowing) at body line 3 ✓. One wrong count, inherited from the prior review's own «plus four runtime tests»; the load-bearing clause (the merge touches neither the door, PackageInstallBodySchema, ManifestSchema nor the install writers) is true.

② Semver level

Unchanged and still correct: '@objectstack/runtime': minor (:2), line-initial Clause-②: no (narrowing) (:7), one **BREAKING …** paragraph, exactly one adr-0087: not-required (no-migration-prescription) marker (:108, grep count 1). The delta touches no runtime code — packages.ts blob 4f6b4be7 on both heads; the PR-own delta is the changeset (+45/−8) and test-only pins (+49) — so the narrowing set is identical and the added prose only describes it completely. The marker's reason (nothing authorable removed, renamed or reshaped; the refusal carries the remedy) still holds. Check Changeset success twice on head.

③ Boundary flags

  • Delta composition: 481ad07f..1358147b = merge dd49317f1 (five main commits, none in packages/runtime or the lockfile) + 1358147bc (changeset + test file). Nine PR paths, +828/−51, matching the dev's report and the files API. Not governed; draft; same-repo head; base 805af4f29 = merge-base; mergeable_state: clean against origin/main now at 615c46874.
  • Body-only correction before landing: «and four runtime tests» → seven (list above). No new head needed.
  • Optional wording: the tombstone sub-bullet could say the nested contributes tombstones are refused too.
  • No worktree run: the shared checkout has no node_modules or dist, so §8 was judged structurally against the merge-base door and both limbs plus the dev's ablation; the green leg is CI's Test Core.
  • CI on 1358147b (check-runs API): 41 runs, 36 success, 5 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke by path filter; Auto Label and Check PR Size on the 13:03 edited re-run), 0 failure, 0 in progress. All seven required contexts success: Lint & Repo Gates, TypeScript Type Check, Test Core (+6 shards), Dogfood Regression Gate (+3), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard.
  • No model identifier in the changeset or body; body footer is the session-URL form.

Implemented-by: claude/issue-19328-install-door-body-parse
Reviewed-by: session_01UYBdGBzWSrAMzpW8ah3GbP

Independence: INDEPENDENT AGENT (fed the card, the prior review, and the PR only; not the dispatch order or the seat's conclusions)

VERDICT: PASS

veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…s group, refusing the rest (objectstack-ai#20497) (objectstack-ai#20517)

Fixes objectstack-ai#20497
Clause-②: no

## What changes

`parseNumberCell` in `packages/rest/src/import-coerce.ts` removed every
comma before parsing, as if every comma grouped thousands. So `POST
/api/v1/data/:object/import` stored a decimal-comma cell as a different
number and reported success.

A comma is now accepted only in a well-formed thousands group: 1 to 3
leading digits, then groups of exactly three, and only before any `.`
(`1,000`, `12,345.67`). One anchored pattern,
`THOUSANDS_GROUPED_INTEGER`, is tested before the strip, and the strip
now runs only for that form. Every other comma makes the cell
unparseable, so the row gets the importer's existing `invalid_number`
error. That is the same code the plain write doors already answer for
these cells. No locale is guessed, and there is no new error code and no
decimal-separator option, as triage directed (`5877481993`). The rule is
stated in the `parseNumberCell` docblock, where the reader's tolerances
are listed.

Measured through the real route (JSON rows, `writeMode: 'insert'`) on
`InMemoryDriver` and on `SqlDriver` (better-sqlite3). Both drivers
answered the same on every cell, at base `9449512a31` and at head
`c76a3c95f2`:

| cell | base: stored · import answer | head | plain `POST` (engine
insert), both trees |
|:--|:--|:--|:--|
| `'3,14'` | `314` · ok 1, errors 0 | row refused, `invalid_number`,
nothing stored | `VALIDATION_FAILED` / `invalid_number` |
| `'1,5'` | `15` · ok 1, errors 0 | row refused, `invalid_number`,
nothing stored | same |
| `'1.000,5'` | `1.0005` · ok 1, errors 0 | row refused,
`invalid_number`, nothing stored | same |
| `'1,2,3'` | `123` · ok 1, errors 0 | row refused, `invalid_number`,
nothing stored | same |
| `'1,000'` | `1000` | `1000` (unchanged) | refused (the import-only
tolerance stays) |
| `'12,345.67'` | `12345.67` | `12345.67` (unchanged) | refused |
| `'(1,234)'` | `-1234` | `-1234` (unchanged) | refused |

## PM hypotheses, measured

- **H0 holds.** On current `main` (`9449512a31`), the four cells
imported as `314`, `15`, `1.0005` and `123` with `ok 1, errors 0`, on
`InMemoryDriver` and on SQLite. The table above has the readings.
- **H1 holds.** The one line `s.replace(/,/g, '')` is the whole cause.
Before/after census through `coerceRow`: before is rest's built `dist`
at the base, after is the head's `src`. It covers 79 rows: the spec
grammar's 41 `NUMERIC_STRING_GRAMMAR_CASES` rows, 18 documented or
control forms, and 20 comma probes.
- **Grammar rows.** 1 of 41 changed: `'1.000,5'` went from `1.0005` to
refused. The other 7 grammar-refused rows the reader admits keep their
reading: `' 12 '`, `'12\n'`, `'\t-3'`, `'1,000'`, `'+5'`, `'.5'` and
`'007'`.
- **Documented and control forms.** 0 of 18 changed: `1,234`, `$1,000`,
`¥2,500.75`, `€1,000`, `£1,000`, `¥1,000`, `25%`, `(1,234)`, `(100)`,
`1,234.5`, `12,345.67`, `1,000`, `-1,234`, `+1,234`, `1,234,567.89`, `$
1,000`, `1,234%` and `1,000e3`.
- **Comma probes.** 18 of 20 changed, each from a stripped number to a
refusal: `3,14`, `1,5`, `1.000,5`, `1,2,3`, `0,5`, `1,23`, `1234,567`,
`1,0000`, `,123`, `-,123`, `.5,000`, `1,000,`, `12,345.6,7`,
`12,34,567`, `1,00,000`, `(3,14)`, `$1,5` and `1,5%`. The other 2
(`1,000.` and `1 ,000`) were already refused.
- **Overall.** 19 rows changed, every one from a stored number to a
refusal. No row moved the other way, and no admitted value changed.
- **H2 holds.** A refused cell's row carries `code: 'invalid_number'`,
the code the importer already uses for `abc`, with the importer's
existing sentence (`Amount: "3,14" is not a number`, from the catalog's
`import_invalid_number` key). The plain create door answers `400
VALIDATION_FAILED` with field code `invalid_number` for the same cells,
and that is pinned beside the import pins. No new code.
- **H3 holds, in the direction expected.** The ablation removed the
grouping guard, which restores the unconditional strip. It ran via
`scripts/ablation-replace.mjs` in wrap mode, at head `c76a3c95f2`. The
anchor went 1 to 0 and the blob went `afaf602da192` to `a06963b8c44c`,
so the mutation landed. Result: `Tests 24 failed | 46 passed (70)`.
- **Red: every refused-cell assertion and nothing else.** That is 18
`parseNumberCell` refusal cases, the 4 per-cell import pins, the CSV leg
and the dry-run leg.
- **Green: every admitted control.** That is 3 import pins, 8
`parseNumberCell` admitted cases, and the plain-door parity pin.
- **Restore proven.** Blob after restore equals HEAD (`afaf602da192`),
`git diff HEAD` is empty, and the marker count is 0. No build was
needed: the pins reach `import-coerce.ts` through relative imports
(`./rest-server`, `./import-coerce`), never through a `dist/`.

## Tests

- `packages/rest/src/import-number-thousands-group.test.ts` (new) goes
through the real `/import` route over `SqlDriver` (better-sqlite3
`:memory:`). It has 10 cases:
- each of the four cells is refused as its own row's `invalid_number`,
with a sibling row still written;
- each admitted control (`1,000`, `12,345.67`, `(1,234)`) is stored as
its number;
  - the same verdicts hold for quoted CSV cells;
  - the dry run predicts the refusals and persists nothing;
  - the plain create door answers the same code.
- `packages/rest/src/import-coerce.test.ts` gets the `parseNumberCell`
case table: 8 admitted groupings and 18 refused comma forms.
- At `c76a3c95f2`, run as `pnpm --filter @objectstack/rest test
--maxWorkers=2`: `Test Files 220 passed (220)` and `Tests 4211 passed |
40 skipped (4251)`. `test:repo` passed 1 file with 8 tests.
- `pnpm --filter @objectstack/rest typecheck` exits 0: `tsc --noEmit`,
then `check:test-typecheck: OK`, with 0 errors. Both test files are in
the `tsconfig.test.json` program (`--listFilesOnly`).

## Gates

- **Build.** `turbo run build` ran first for `@objectstack/rest...`,
then for all of `./packages/*` and `./packages/*/*`, because two gates
read the whole built tree. Result: 71/71 tasks.
- **Derived gates.** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derived 61 commands at
`c76a3c95f2`. All 61 ran with exit 0. Reconciled with `--ran`: `61
derived, 61 run, 0 NOT-MEASURED, 0 UNRUN`.
- **Other gates, all exit 0.** `pnpm lint` (the whole repository, 29 s,
no narrowing) and `node scripts/check-issue-citations.mjs --base
origin/main` (`origin/main` is still `9449512a31`, the branch point, so
there was nothing to merge).
- **Changeset gates.** `check-adr-0087-registration` names this PR's
changeset as `[BREAKING+clause-②-narrowing] not-required
(no-migration-prescription)`. `check-changeset-no-major` reports no
`major`.

**Declared narrowing — verification ran UNLOCKED.**
`scripts/pm/os-verify-lock.sh`
could not take the shared verify lock on this host: no usable `flock`.
The shared
verify lock is declared Linux-only (`flock` is util-linux, and a stock
macOS does
not ship it), so the command below was run directly, without the lock —
a declared narrowing, not a silent one. No serialization guarantee held
for this
run, nor for any sibling agent in this container while it ran.

pnpm turbo run build --filter='@objectstack/rest...' --concurrency=2
--output-logs=errors-only
pnpm exec turbo run build --filter='./packages/*'
--filter='./packages/*/*' --concurrency=2 --output-logs=errors-only
    pnpm --filter @objectstack/rest test --maxWorkers=2
    pnpm --filter @objectstack/rest test:repo --maxWorkers=2
    pnpm --filter @objectstack/rest typecheck
pnpm --filter @objectstack/rest exec vitest run --project local
--maxWorkers=2 (the targeted pin files, and the ablation's wrapped run)

(The entry point printed this wording once for each command above. It is
pasted once here, with every command it covered.)

## Changeset

`.changeset/20497-import-number-thousands-group.md`: `@objectstack/rest`
`minor`, with a line-initial `Clause-②: no (narrowing)`, a BREAKING
paragraph giving each refused cell shape FROM → TO, and the ADR-0087
disposition `not-required (no-migration-prescription)`. The shape
follows PR objectstack-ai#20218 and PR objectstack-ai#20231.

## Acceptance notes

- **The `InMemoryDriver` leg is measured, not pinned. This departs from
triage's pin list.** Triage asked for `/import` pins on memory and
SQLite. `@objectstack/driver-memory`'s test consumers are a ruled,
ledgered set (`scripts/driver-memory-census.ledger.json`, gated by `pnpm
check:driver-memory-census`), and the gate says a new consumer is a
maintainer ruling. A first cut added the driver as a `packages/rest`
devDependency with a source alias. The census gate refused it by name:
`x LEDGERED: packages/rest/src/import-number-thousands-group.test.ts:31
binds @objectstack/driver-memory (import) and the ledger does not cover
it`. That cut was withdrawn, so the diff is back to the claim's file
surface. The cell is judged by the importer's reader before any driver
is reached, so one verdict holds on every driver. The memory readings at
base and head are recorded in the table above and in the pin's header.
If a permanent memory arm is wanted, it goes through the census ruling
first.
- **Grouping by twos is now refused (`12,34,567`, `1,00,000`).** These
used to import as the number they denote. The changeset names this as
the one case where the narrowing refuses a cell that was read correctly
before, because a two-digit group cannot be told apart from a decimal
comma.
- **The import template card objectstack-ai#18386 should follow this wording.** Its
value-domain row for `number` lists `1,234` among the tolerated forms.
It should say that a comma is read only as a thousands group (1 to 3
leading digits, then groups of exactly three, only before any `.`), and
that a decimal comma such as `3,14` is refused, never guessed. That card
is assigned elsewhere and is not edited here.
- **A spec comment now overstates the reader.** The module header of
`packages/spec/src/data/filter-number-comparand-declared-type.ts` says,
in the parenthetical under its refused digit-separator forms, that the
CSV import route's cell reader strips such punctuation before it parses.
For `1.000,5` that is no longer true. It was already untrue for `1_000`
and `1 000`, which the reader refused before this change too. This is a
comment in the spec seat's surface. There is no carrier, so it is noted
here only.
- **objectui's Import Wizard preview disagrees with the server on
grouped numbers.** The preview judges numeric cells with a bare
`Number()` (`packages/plugin-grid/src/ImportWizard.tsx` in objectui, the
number/currency/percent case). So it flags `1,000` as invalid while the
server admits it. That disagreement predates this PR and is unchanged by
it. For the four cells here, preview and server now agree: both refuse.
I read this in objectui's source and did not measure it through the UI.
There is no carrier, so it is noted here only.

---
_Generated by [Claude
Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_

---------

Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…r residual as closed, and enableOnInstall as read off the parsed request (objectstack-ai#20506)

Fixes objectstack-ai#20219

Clause-②: no

## What this changes

Text only, in `@objectstack/spec`: the `PackageInstallBodySchema`
docblock, one sentence of the
`PackageInstallRequestSchema.enableOnInstall` docblock, the matching
block of `package-api.test.ts`, and a `patch` changeset. ⛔ No schema
shape, accept set, export or runtime file moves.

Every sentence was re-derived against the landed door on `origin/main`
`fc0db22b` (`packages/runtime/src/domains/packages.ts`), not against the
card's quotes.

## Premise check (dispatch assumption 1)

The card's line "Only the wrapped form's top-level unknown key is still
stripped" is false on `main`: since `28ad7e4b` the wrapped branch is a
`strictObject`, and the door refuses such a body `400`. Measured on the
built schema: `{ manifest, bogus: 1 }` fails
`PackageInstallBodySchema.safeParse`. What the declaration still strips
is only an unknown key NESTED in a strip-mode block. A walk of the built
union finds three such positions: `artifactRef`, and the expression
envelope of a manifest navigation item's `visible` plus its `meta`. Both
were measured parsing green with the key dropped. So the rewritten
section describes that strip, and no wrapped-form top-level strip.

## Sentences changed, each with the line that makes the new one true

| # | Where | Old sentence (gist) | New sentence (gist) | Evidence on
`fc0db22b` |
|--:|:--|:--|:--|:--|
| 1 | `enableOnInstall` docblock | the door "reads the raw body" | the
door reads the key off the PARSED wrapped request, after the body passes
`PackageInstallBodySchema` | `packages.ts:1019` `const declaredBody =
PackageInstallBodySchema.safeParse(body)`, `:1164` `const request =
'manifest' in declaredBody.data ? …`, `:1247` `const requestedEnabled =
request?.enableOnInstall` |
| 2 | body docblock, bare-form paragraph | the two runtime door drives
post no `type`, are refused here, and are answered `201` | both drives
carry `type: 'app'` since PR objectstack-ai#20218, parse green through the bare
branch, and are answered `201` |
`package-door-namespace-conflict-code.test.ts:88` and
`domain-handler-registry.test.ts:605`, both with `type: 'app'` |
| 3 | residual heading and lead | a SUBSET description: the door
"additionally answers `201` to five classes" | the residual is empty on
the answer: the door refuses each class as the union does, `400` /
`VALIDATION_ERROR`, ahead of the `409` | `packages.ts:1155` answers
`!declaredBody.success` with `400`, ahead of the `409` at `:1176` |
| 4 | residual item 1 | 1b "still OPEN, answered `201`" | 1a and 1b both
landed; 1b with the whole-body parse | same union verdict,
`packages.ts:1155` |
| 5 | residual item 2 | unknown keys "`201` either way" | refused on
both forms, and at the wrapped top level since ruling record
`5856869656` | `packages.ts:1155`, plus `PackageInstallRequestSchema` is
`strictObject` (`package-api.zod.ts:228`) |
| 6 | residual item 3 | `'false'` installs ENABLED, `'true'` overwrite
read as ABSENT | both keys are `z.boolean()`, so the parse refuses
either string | `package-api.zod.ts` `enableOnInstall:
z.boolean().optional()`, `overwrite: z.boolean().optional()`;
`packages.ts:1155` |
| 7 | residual item 4 | bare-form options "ignored, never honoured" |
refused by `ManifestSchema`'s strict close; the door's refusal names the
wrapped form | `packages.ts:1155` → `installBodyRefusal`
(`:717`-`:750`), whose bare-form arm prescribes the wrapped form |
| 8 | residual item 5 | the door answers `400` to a whitespace-only `id`
"this declaration admits" | both faces refuse it, since
`ManifestSchema.id` carries `MANIFEST_ID_PATTERN` |
`packages.ts:1021`-`:1023` (trim, then `Package id is required`);
`manifest.zod.ts:272`; the spec test's own whitespace pin already said
so. **This sentence was already false before PR objectstack-ai#20218.** |
| 9 | new paragraph | (none) | what the parsed value still does not
describe is what the door STORES: the manifest as SENT, so parse-time
defaults (`scope`, `defaultDatasource`) are not stored, and an unknown
key nested in a strip-mode manifest block is stored as sent |
`packages.ts:1020` (`manifest = body.manifest || body`, the raw body) →
`:1193` / `:1196` `installPackage({ manifest, settings })`; door pin
`packages-install-body-contract.test.ts` §7 ("stored as SENT"); defaults
measured on the built schema (`defaultDatasource`, `scope` are added by
the parse) |
| 10 | the ⛔ paragraph after the list | "the residual is RECORDED here …
closing it is its own decision with its own card" | none of this
licenses relaxing either branch, or making the door answer a body
differently from the declaration | follows from rows 3-8 |

## Sentences kept, because they are true on `main` (assumption 3)

- «The door reads `const manifest = body.manifest || body`, so a bare
manifest IS a body form it accepts» (`packages.ts:1020`).
- «A contract naming only the wrapped form would refuse bodies this door
answers `201` to»: the bare form is answered `201`
(`packages-install-body-contract.test.ts` §7, "BARE → 201").
- The whole "two branches are disjoint — and BOTH are closed" section,
including its ruling paragraph («Since `c02fa1276` … the door's answer
IS this declaration's answer»).
- «⚠️ The bare form carries NO install options … A bare-form caller
reaches `overwrite` through the query string alone»
(`packages.ts:1171`-`:1172`).
- The rest of the `enableOnInstall` docblock, including «It calls
`installPackage({ manifest, settings })` and performs the enable/disable
flip itself» (`packages.ts:1193`, `:1247`-`:1254`).

## The test block (assumption 4): what `DOOR_201_RESIDUALS` guards now

Measured, not assumed: the list's assertion (the declaration refuses
every body in it) still guards a door behaviour. The door parses through
this declaration, so if any of those bodies started parsing, the door
would start answering it `201` again. The assertion is therefore **kept,
not weakened**. It is renamed `CLOSED_RESIDUALS` and re-titled to what
it now says: the declaration refuses every one, and so does the door
since PR objectstack-ai#20218.

- The two drive transcriptions now carry `type: 'app'`, because the
drives do, and are pinned GREEN.
- The bodies they used to post (the same manifest with no `type`) are
derived by an `untyped` helper and stay pinned REFUSED, both in the "the
missing `type` is what decided it" case and in `CLOSED_RESIDUALS`. No
assertion was dropped. The `pkg-a` refusal is unchanged.
- The clause-1a case keeps its assertion. It is re-titled to what it
guards now: every body in the list is refused for its own class, never
on the `version` leg the door answers first.
- The whitespace-id case keeps its assertions; only its trailing comment
("the class remains") is rewritten.
- In-place fix, outside the named block: the comment on "parses a
COMPLETE manifest posted BARE" said the bare-form callers "post
INCOMPLETE ones". This is the same stale fact, so it is corrected in one
comment edit. It passes all four bounded-fix conditions: same defect
class, mechanical, no other claim on the file, and the same gate family.

Test count is unchanged (84 → 84).

## Verification — against `e627005b` (`git rev-parse --short HEAD`)

- `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2
src/api/package-api.test.ts`: `Tests 84 passed (84)` (the baseline on
`fc0db22b` was also 84).
- Spec suite, `vitest run --project local --maxWorkers=2` in
`packages/spec`: `Test Files 572 passed (572)`, `Tests 16789 passed | 1
todo (16790)`.
- `pnpm --filter @objectstack/spec run typecheck` → exit 0,
`check:test-typecheck: OK — … 53 file(s) / 251 error(s) / 138 pinned
signature(s) held`.
- `pnpm --filter @objectstack/spec check:generated` → "All 15 generated
artifacts are up to date". The build left the tree clean:
`authorable-surface.base.json` was not touched.
- `pnpm check:doc-authoring` → exit 0 ("16735 customer-facing string(s)
across 1167 spec sources clean").
- Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 83 commands over this diff. All were
run, with each exit code written to disk before any pipe. `--ran`
reconciliation: `✓ dispatch-gates --ran: 83 derived famil(ies) accounted
for — 80 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3)`.
- `check:doc-formula-expressions` first exited 3 (its `formula` / `lint`
dist was absent). After building that closure it was re-run: exit 0.
- NOT MEASURED: `check:dual-build-cjs-loads`, `check:lean-entry-closure`
and `check:type-check-debt`. Reason: each needs a whole-workspace (or
`objectql`-closure) build this worktree does not have, and each refused
with `PREREQUISITE NOT MET`. The diff changes no emitted code:
`check:api-surface` is green, and so is the `dts` sweep. These three are
CI's to read.
- Published-surface check (changeset rather than `skip-changeset`):
after `pnpm --filter @objectstack/spec build`, the new heading `CLOSED
on the answer` appears once in `dist/api/index.d.ts` and once in
`index.d.mts`. The old `the door additionally answers` appears 0 times
in both. The `enableOnInstall` docblock ships through `files[]`'
`src/**/*.zod.ts`.

### Ablation: NOT MEASURED as a vitest run, with a lock-free stand-in

The planned ablation replaced the `untyped` helper with an identity,
through `scripts/ablation-replace.mjs` under the verify lock. It never
acquired the lock: 3 queue-timeouts (`VERDICT queue-timeout (exit 99)`),
about 27 minutes in all. The file stayed byte-identical to `HEAD` (blob
`a0277d57` on both).

The stand-in ran without the lock and without vitest. It evaluated the
refusal assertions' inputs against the built schema under both helpers:

```text
committed: 'type decided it' refusals hold = true,true ; CLOSED_RESIDUALS refusals hold = true,true,true,true,true,true
ablated:   'type decided it' refusals hold = false,false ; CLOSED_RESIDUALS refusals hold = false,false,true,true,true,true
```

So the identity helper would turn both refusal cases red. The committed
suite already holds the same pair in one run: the two drives GREEN and
their untyped bodies REFUSED.

## Acceptance notes (not filed)

- `platformVersion` and `artifactRef` are declared install options on
`PackageInstallRequestSchema`, parsed at the door and read by no line of
it. A repo-wide `git grep`, outside `packages/spec` and tests, finds no
reader and no producer. The first-party SDK sends only `manifest`,
`settings`, `enableOnInstall` and `overwrite`. With zero pull and no
public-door reading, this is noted, not filed. The docblock was
deliberately NOT extended to say so: that would widen this card.
- `packages/spec/scripts/lib/default-changes.ts:167` carries "the door
reads the raw body". It is the rationale of an earlier protocol major's
default-change record (`spec-changes.json` / the upgrade guide), and it
was true at that major. It is history, not a description of today's
door, and it was left untouched.
- `.changeset/19327-install-door-residual-split.md` (unreleased) still
says the `type` half is an open residual. Its successor entries,
`19328-install-door-body-parse.md` and this one, record the rest. A
release compiling all three reads as a sequence, and that changeset was
not edited here.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…decision in words instead of a tracker number (stage 24) (objectstack-ai#21961)

Part of objectstack-ai#20749
Clause-②: no

Stage 24 of this card: the next area of class (e), the test strings
shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513.
This stage takes the first name-ordered `api/` group: the 27 id-bearing
test files directly under `packages/spec/src/api/` from
`ai-agents-envelope.test.ts` to `package-lifecycle.test.ts`. Those files
carried 100 messages and 106 tracker ids, citing 65 records. All 106 now
either state what their record decided, in words (form D), or are
dropped where the title already says it. No needle sits in this group.
Text only: no assertion, identifier, test count or code comment changes,
and no file is renamed.

## Census at the base (`a3bd157730`)

Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`),
`census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`), `census-wide.cjs`
(md5 `c98410a19529c439adb0afbfb00026a2`) and `dirtable.cjs` (md5
`dda605c54745b4a60cc14c9a686e4eff`), byte-identical to the copies stages
10 to 23 used. A literal counts as a test title when its folded message
is argument 0 of a `describe` / `it` / `test` call, `.each` / `.skip` /
`.only` chains included. Everything else is an "other" string.

The worktree was cut from `origin/main` at `a3bd157730`, the claim's
base and stage 23's landing. Both instruments read **471 messages / 498
ids in 111 files**, the seat's reading and stage 23's head reading.

| directory | files | messages / ids | titles | other |
|:--|--:|--:|--:|--:|
| `api/` (this PR: 27 of the 40 files) | 40 | 189 / 201 | 181 / 193 | 8
/ 8 |
| `system/` | 34 | 154 / 167 | 128 / 138 | 26 / 29 |
| (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 |
| `ui/` | 5 | 7 / 7 | 0 | 7 / 7 |
| `ai/` | 1 | 2 / 2 | 0 | 2 / 2 |
| `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 |
| **total** | **111** | **471 / 498** | **426 / 450** | **45 / 48** |

The group reads **100 messages / 106 ids in 27 files**, the seat's
figures file for file:

| file (under `api/`) | messages / ids | titles | other |
|:--|--:|--:|--:|
| `ai-agents-envelope.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `analytics.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `api-entry-graph.pin.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `api-error-code-type.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `apis-publish-gates.test.ts` | 12 / 12 | 12 / 12 | 0 |
| `auth-endpoints.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `auth.test.ts` | 2 / 2 | 1 / 1 | 1 / 1 |
| `automation-api.zod.test.ts` | 4 / 5 | 4 / 5 | 0 |
| `batch.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `contract.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `dataset-selection.test.ts` | 5 / 5 | 5 / 5 | 0 |
| `discovery-auth-families.pin.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `discovery-environment-subset.pin.test.ts` | 2 / 2 | 1 / 1 | 1 / 1 |
| `discovery.test.ts` | 10 / 11 | 10 / 11 | 0 |
| `dispatcher.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `endpoint.test.ts` | 4 / 4 | 4 / 4 | 0 |
| `envelope-violations.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `error-code-ledger.test.ts` | 7 / 11 | 7 / 11 | 0 |
| `errors.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `export-job-family-retirement.test.ts` | 6 / 6 | 3 / 3 | 3 / 3 |
| `export.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `meta-item-response-shapes.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `metadata.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `odata-orderby-dual-declaration.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `package-api.test.ts` | 10 / 10 | 10 / 10 | 0 |
| `package-install-one-authority.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `package-lifecycle.test.ts` | 9 / 9 | 9 / 9 | 0 |
| **27 files** | **100 / 106** | **95 / 101** | **5 / 5** |

Five more test files sit in the same name range and carry no id
(`documentation.test.ts`, `error-catalog-docs.test.ts`,
`events.test.ts`, `http-cache.test.ts`, `odata.test.ts`). The five
"other" strings are expect messages, rewritten and declared to the
text-only tool: `auth.test.ts:155`,
`discovery-environment-subset.pin.test.ts:65` (one leaf of a `+` chain)
and `export-job-family-retirement.test.ts:112` (a template literal),
`:158` and `:349`.

- **Controls.** Lit: `ui/notification.test.ts` (1 id) and
`api/protocol.test.ts` (50 ids), outside the group, read the same at the
base and at the head. Dark: `package-api.test.ts` reads 0 at the head
while 30 of its comment lines still carry a number. Planted in a scratch
tree: an id put into a `package-lifecycle.test.ts` title reads 1 / 1
(`title:describe`), and an id put into a `batch.test.ts` comment reads
0.
- **A wider pattern** (any `#` plus digits) reads the same as the gate
pattern in all 27 files at the base, and 0 in all 27 at the head.
- **At the head:** 371 messages / 392 ids in 84 files. The 27 files read
0 / 0, `api/` reads 89 / 95 in 13 files, and no other file moved.

## How the area was chosen

`api/` has no subdirectory, so it is taken in name-ordered file groups
near the ~100-id bound, the rule stages 20 to 23 used. Stage 23's cut
named this group at 106 ids, and this census reads 106, so no re-cut was
needed.

**Named for the next stages** (cut from the head census, 371 / 392):
- **The second `api/` group:**
`plugin-rest-api.handler-status-retirement.test.ts` through
`zod-issues-to-fields.test.ts`, 13 files, 89 messages / 95 ids (86 / 92
titles, 3 / 3 other), `protocol.test.ts` alone 46 / 50 and
`rest-server.test.ts` 19 / 19. That finishes `api/`.
- `system/` 167, two stages. The files directly in `src/`, 120, one.
- The needles: the three docblock needles, the kept
`ui/component-props-unknown-members.pin.test.ts:322` and stage 22's two.
One stage, with an at-tier review. The four colour literals stay, as
stage 21 decided.

## What each id became

- **18 literals (22 ids)** now state a decision in words.
- **10 literals (10 ids)** get their subject back in words, where the
number stood for a thing.
- **72 literals (74 ids)** drop a number the title already explains.

Every cited record was fetched with all its comments through REST (357
comments, objectstack-ai#4052's included), and its decision was read from its ruling,
ACCEPT and landing comments. 65 records are cited: 59 answer 200 and 6
answer 404. Two of the 200s are PRs (objectstack-ai#4049 and objectstack-ai#20218), read from their
bodies. One citation is objectui's and was read from objectui:
`objectui#6593`. The six that answer 404 were read from what landed,
through the commits endpoint (this checkout is shallow), each named by
the commit the stage-2 re-anchoring of `api/` comments gave it:
- **objectstack-ai#6287**, from `84c86fb454` (objectstack-ai#6610): `preview` and `trial` fold to
`sandbox` by declaration, and the fold table is typed total over
`EnvironmentType`;
- **objectstack-ai#6704**, from `c3f4916266` (objectstack-ai#7015): `ImportRequest.runAutomations`
declares the default the import route applies;
- **objectstack-ai#10330**, from `b9e9227e36` (objectstack-ai#11316): `mappingName` declared on
`ImportRequestSchema`, with the mutual-exclusion refine;
- **objectstack-ai#10338**, from `d2619fd0cd` (objectstack-ai#11290): `ApiEndpoint.target` is
optional, and the publish gate holds the flow requirement;
- **objectstack-ai#11504**, from `f90e820249` (objectstack-ai#12611): `FLOW_INPUT_SCHEMA_INVALID`
registered, the never-dispatched code;
- **objectstack-ai#16649**, from `613bfbd3db` (objectstack-ai#16879): the fourteen remaining `door:
'none'` codes registered.

One citation names a different record. `batch.test.ts:78` read "(objectstack-ai#3963
follow-up)"; objectstack-ai#3963 is the `api.requireAuth` retirement. The
`validateOnly` tombstone is objectstack-ai#4052's decision, read too: never
implemented, so tombstoned rather than half-built. The title already
says that ("rejects the retired `validateOnly` key with its
prescription"), so the number is dropped.

Where a record's decision was refined later, the title follows the
refined one:
- **objectstack-ai#4936 and objectstack-ai#5111:** objectstack-ai#4936's ruling refused every non-empty `apis:`;
objectstack-ai#5111 narrowed that to per-endpoint gates. The `:152` title says what
held through both: an empty or absent `apis:` was never refused.
- **objectstack-ai#4910 Q2:** that ruling left endpoint-level `rateLimit` unwired and
tracked under objectstack-ai#4936; objectstack-ai#4936's ruling then kept it in the vocabulary for
the endpoint executor to wire. The title names that destination.
- **objectstack-ai#17518:** its 2026-09-13 ruling was re-presented and briefly
replaced (batch objectstack-ai#149, withdrawn as unexecutable), then confirmed (batch
objectstack-ai#159, letter A) and given its mechanism (batch objectstack-ai#192, letter A′), which
adds the record-stage body. The title "the row's manifest is the RECORD
stage" is that body, so only the number goes.
- **objectstack-ai#18605:** ruling letter 1 made the request contract the one
authority, and objectstack-ai#18877's later ruling made that key optional so the door
sees an absence; the title says only "has ONE authority", which both
keep, so only the number goes.

**Stated in words:**

| record | literal (under `api/`) | now reads | the decision |
|:--|:--|:--|:--|
| objectstack-ai#18576 | `api-entry-graph.pin.test.ts:77` | "… stays off the assembled
package body (ruled: split the entry rather than watch its weight)" |
Ruling B (batch objectstack-ai#145 item 1, maintainer 2026-09-17): the cost is
removed, not watched; `./api` is split and the assembled-package
declarations move to `@objectstack/spec/api-assembled`. |
| objectstack-ai#4936 | `apis-publish-gates.test.ts:152` | "still accepts an EMPTY and
an ABSENT `apis:` — never refused, even while a non-empty one was" |
Maintainer ruling 2026-08-04: v17 loudly refuses a non-empty `apis:` and
keeps the vocabulary; an empty or absent one stays publishable, then and
after objectstack-ai#5111's narrowing. |
| objectstack-ai#4910 | `apis-publish-gates.test.ts:568` | "keeps endpoint-level
`rateLimit` in the vocabulary (ruled: left to the endpoint executor, not
the server-level seam)" | Q2 = B (2026-08-03): that card wires the
server level only; the endpoint-level keys stay, and objectstack-ai#4936's ruling has
the endpoint executor wire them. |
| objectstack-ai#5189 | `apis-publish-gates.test.ts:597` | "still refuses D6 — the
gate with no runtime counterpart, so the per-item publish path runs it
too" | Triage disposition (E7b, 2026-08-04): `publishPackage` reuses the
same gate function, because D6 alone has no runtime counterpart. |
| objectstack-ai#7481 | `auth-endpoints.test.ts:112` | "AuthFeaturesConfig retired
flags (ruled: stop advertising them)" | Maintainer ruling 2026-08-11:
`passkeys` / `magicLink` leave the `/api/v1/auth/config` payload. |
| objectstack-ai#14788 | `auth.test.ts:88` | "SessionUser.language retirement
(ADR-0049 — ruled: gone, with no replacement field)" | Maintainer ruling
D (2026-09-03): retired under ADR-0049, no producer and no consumer; no
replacement field until a real producer exists. |
| objectstack-ai#9378, objectstack-ai#9510 | `automation-api.zod.test.ts:327` | "… status, runId and
the screen (a pause is the third state, not a failure)" | objectstack-ai#9510's ruling
(2026-08-18): a pause is not a failure, and callers learn the third
state deliberately; `status: 'paused'` + `runId` + `screen` is the
trigger contract's third state. |
| objectstack-ai#4828 | `discovery.test.ts:1167` | "scoping (ruled: declare what REST
actually emits)" | Maintainer ruling 2026-08-05, item 3: `scoping` is
declared on `DiscoverySchema` as an optional key. |
| objectstack-ai#4828 | `discovery.test.ts:1207` | "resolveDiscoveryEnvironment
(ruled: an enum, not a passthrough)" | Item 4: the schema is
authoritative, so every producer's `environment` is mapped into the
declared enum. |
| objectstack-ai#8211 | `error-code-ledger.test.ts:68` | "standard-synonym detection
(ruled: refused unless waived)" | Option C (triage adjudication,
2026-08-12): the admission gate refuses a semantic synonym of a standard
member unless a recorded waiver admits it; the four existing ones are
waived. |
| objectstack-ai#10025, objectstack-ai#11504 | `error-code-ledger.test.ts:220` | "accepts the
definition-level input-schema refusal code (ruled non-retryable: a
never-dispatched exit)" | Maintainer ruling B (2026-08-20): the refusal
is non-retryable and becomes a never-dispatched exit with its own
ADR-0112 code. |
| objectstack-ai#16449, objectstack-ai#16404 | `error-code-ledger.test.ts:234` | "accepts the
nine-code batch — every code that ships in dist, door or no door (ruled:
the ledger is the published face)" | objectstack-ai#16404 option D (batch objectstack-ai#62,
2026-09-07): the ledger is the published face, so every code in `dist`
is registered; objectstack-ai#16449 registered the nine. |
| objectstack-ai#16649, objectstack-ai#16404 | `error-code-ledger.test.ts:308` | "accepts the
fourteen remaining door:none codes, each under its stamping package
(ruled: the ledger is the published face)" | The same ruling;
`613bfbd3db` registered the fourteen. |
| objectstack-ai#17158 | `export-job-family-retirement.test.ts:158`, `:349` (expect
messages) | "… the retirement is being undone — nothing served, bound or
consumed the family" | Ruling A (batch objectstack-ai#122 item 3, 2026-09-12; landing
route A, batch objectstack-ai#221 item 2): ADR-0049 retires a declared API that
nothing serves, binds or consumes. |
| objectstack-ai#12038 | `package-api.test.ts:603` | "package-rollback-response
retirement (ruled: it described the wrong operation on the live path)" |
Ruling 3A (2026-08-27): the published version-rollback schema, bound to
the live commit-rollback path, is retired first. |
| objectstack-ai#12038 | `package-lifecycle.test.ts:25` | "the ruled re-export of
PackagePublishResultSchema into the `/api` namespace" | Ruling 5A:
re-export the existing schema into the namespace the ledger resolver
searches, never a second copy. |
| objectstack-ai#12038 | `package-lifecycle.test.ts:140` |
"RollbackToPackageCommitResponseSchema declares the COMMIT-rollback body
(ruled: authored once the wrong-operation schema was retired)" | Ruling
3A's binding sequence: retire the false declaration, then author the
true commit-rollback schema. |

**Subject back in words** (10 literals): "the pre-objectstack-ai#4053 bare body"
becomes "the bare body from before the envelope relocation" (objectstack-ai#4053's end
state: both producers relocated the payload under `data`); "(objectstack-ai#3891 shim
dialect)" becomes "(the degraded shim dialect)"; "the duplicate-payload
drift objectstack-ai#4049 removed" becomes "the duplicate-payload drift the
/share-links domain stopped emitting", the PR's own title; "zero holders
after objectstack-ai#17158" becomes "after the export-job family retirement"; "the
objectstack-ai#10330 TS2353 repro" becomes "the original TS2353 repro"; the three
"since PR objectstack-ai#20218" titles become "since the door parses its whole body"
(twice) and "so does the door, which parses the whole body", the PR's
own title; the `objectstack-ai#17534` title now names "the reverse-domain id rule",
that card's ruling A; "the objectui#6593 confusion" becomes "the
envelope-vs-payload `success` confusion", the defect objectui#6593
measured.

**Dropped where already stated** (72 literals, 74 ids). A number goes
only where the title already says its decision. Examples: the eight
`[objectstack-ai#5111]` describes ("the flip — a well-formed `apis:` publishes", "gate
(a)" to "gate (e)", …), `[objectstack-ai#5310]`, `[objectstack-ai#19920]`, the two `[objectstack-ai#21046]`,
`[objectstack-ai#5676]`, `[objectstack-ai#5672]`, `[objectstack-ai#5679]` and `[objectstack-ai#6287]` prefixes; the four
`objectstack-ai#17551` / `objectstack-ai#17550` section prefixes in `dataset-selection.test.ts`,
which keep the file's own `§1` to `§5`; `objectstack-ai#5384 —`, `objectstack-ai#5227 —`, `objectstack-ai#5950`,
`objectstack-ai#5882`, `objectstack-ai#17518`, `objectstack-ai#18058 —` and `objectstack-ai#18605 —`; the four `objectstack-ai#15677`
citations on the "→ …Seconds" renames; and the tails `(objectstack-ai#3878)`,
`(objectstack-ai#6442)`, `(objectstack-ai#19543)` x2, `(objectstack-ai#7359)`, `(objectstack-ai#3939)`, `(objectstack-ai#18124)`, `(objectstack-ai#3842)`
x3, `(objectstack-ai#10338)`, `(objectstack-ai#6704)`, `(objectstack-ai#10330)`, `(objectstack-ai#4587)`, `(objectstack-ai#17667)`,
`(objectstack-ai#19116)`, `(objectstack-ai#17431)`, `(objectstack-ai#19441)`, `(objectstack-ai#8211)`, the five `(objectstack-ai#12038)` and
the one `(objectstack-ai#12038 4A)` after "declares the four fixed keys and stays
open". `(federated ledger, objectstack-ai#4805)`, `(ADR-0076 D12, objectstack-ai#2462)` and
`(ADR-0112 amendment 2026-08-18, objectstack-ai#9266)` keep their words and lose the
number. The ADR-0087 conversion id
`api-endpoint-cache-ttl-to-cache-ttl-seconds` stays: it is not a tracker
id.

**No file is renamed.**

## Readers

- **`error-code-ledger.test.ts`** (11 ids in 7 titles): no ledger, gate
or self-test reads its strings. `scripts/check-error-code-casing.mjs`
names the file only to exempt it whole ("the ledger admission test");
the ledger's docblock and its generated reference page name the file,
never a title; the provenance and dispatcher-vocabulary gates read
`error-code-ledger.zod.ts`, not the test.
- **Needles:** none. The five declared strings are all assertion failure
messages (the second argument of `expect`), none is an expected value,
and no title or message in the group is matched against a source
docblock or another file's text.
- **Test-name filters:** none. No tracked script, workflow or package
config passes `-t` / `--testNamePattern` to vitest; the one vitest `-t`
hit is a README example under `packages/qa/dogfood` filtering its own
fixture.
- **Snapshots:** none. No `__snapshots__` directory is tracked under
`packages/spec`, and none of the 27 files calls a snapshot matcher.
- **Projects:** `export-job-family-retirement.test.ts` is in the `repo`
project (`packages/spec/vitest.repo-tests.json:30`); the other 26 run in
`local`. The base-versus-head run below takes both projects.
- **By substring:** every old literal, its id-bearing fragment and a
window around each id (294 needles) was searched with `git grep` at the
base, across the tracked tree outside its own file. No gate, doc,
filter, snapshot, QA checklist entry or `scripts/check-*.mjs` self-test
reads one. The 17 hits are windows that share wording with code comments
and one CHANGELOG line: "(ADR-0076 D12, objectstack-ai#2462)" in comments in
`runtime/http-dispatcher.ts`, `spec/api/discovery.zod.ts` and
`objectql/protocol-discovery.test.ts` and at
`packages/runtime/CHANGELOG.md:14161`; "(objectstack-ai#18576 ruling, letter B)" in
three comments; "(objectstack-ai#3891 shim dialect)" in
`runtime/domains/analytics.ts:41`; "is retired (objectstack-ai#19543)" in
`spec/api/automation-api.zod.ts:645`.

## Text-only proof

Stage 10's scratch tool (`textonly10.cjs`, md5
`d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file
on three legs:
1. **Skeleton:** the full AST, with string pieces masked. It must be
identical.
2. **Comments:** every comment, byte-equal.
3. **Strings:** each changed string leaf must sit in a test-call title
position or on a declared line, must carry a tracker id before, and must
carry no `#` plus digits after. This stage declares the five
expect-message lines named above.

- **Result:** 27 of 27 files SAME on all three legs, with the per-file
counts predicted in writing before the run.
- **Totals:** 100 changed string leaves in 100 literals: 95 titles and 5
declared. The diff's `+` and `-` lines are exactly the 100 planned lines
as multisets, and every file keeps its line count.
- **Controls (14 of 14 as predicted on the first run, on scratch copies,
each anchor hit once):** identifier rename DIFF; numeric literal DIFF;
comment edit COMMENT DIFF; a non-title string given an id VIOLATION; a
rewritten title given a new id VIOLATION; a title that was id-free at
base edited VIOLATION; one title reverted to base SAME; an `it.each` row
given an id VIOLATION; an undeclared expect message changed VIOLATION; a
title re-split into a `+` chain DIFF; a declared expect message reverted
to base SAME; a declared expect message given a new id VIOLATION; a
declared `+`-chain leaf given a new id VIOLATION; a template-literal
message given a new id VIOLATION.
- **Templates and tables:** no `.each` title and no `$name` placeholder
changes. The one template literal,
`export-job-family-retirement.test.ts:112`, changes only its text after
`${name}`.

**Test counts:** the 27 files were run at the base, in a separate base
worktree, and at the head, with `--project local --project repo`. Both
sides read 831 tests in 27 files, all passed, with the same count and
status sequence per file in 27 of 27. 325 full test names change, and
each changed name equals the base name with the planned replacements
applied: 0 mismatches once the plan's text is read the way the source
writes it (the comparison tool reads the plan's `—` escape at
`errors.test.ts:439` literally, so its first pass reports that title's
three names as mismatches; decoding the escape, as vitest does, reads
0). No full name repeats on either side.

## Changeset: `skip-changeset`

Measured, not assumed:
- `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the
27 touched files are in it, and no `*.test.ts` at all. The controls
`src/api/package-lifecycle.zod.ts`, `src/api/error-code-ledger.zod.ts`
and `dist/index.mjs` are in it.
- In the built `dist/`, a new phrase and an old one each read in 0
files. The control `Unrecognized key` reads in 42.

So this PR publishes nothing, and no changeset is added.

## Verification (at `dffd240655`)

- `pnpm turbo run build` over all packages: 71 / 71, through the shared
verify lock (`VERDICT command-exit 0`).
- `@objectstack/spec`:
  - `vitest run --project local`: 619 files, 18471 passed, 1 todo.
- `typecheck`: exit 0, including `check:test-typecheck` (52 files / 246
errors / 135 pinned signatures held). Its program holds all 27 group
files, counted by path with `tsc --listFilesOnly -p tsconfig.test.json`.
- `check:generated`: all 15 generated artifacts up to date, against the
`dist/` the build above wrote.
- **Gates:** `dispatch-gates --commands` derived 80 families: stage 23's
79 plus `check:error-code-casing`, which the two touched files it names
bring in. All 80 exit 0. `--ran` reconciles: 80 derived, 80 run, 0
NOT-MEASURED, 0 UNRUN, every family with its exit code recorded. The
same 80 derive from `origin/main` `01e0f71ad8` with this diff applied.
The roster families stage 23 also ran (`check:meta-url-spelling`,
`check:spec-changes`, `check:authz-resolver`,
`check:filter-alias-parity`) each exit 0.
- **ESLint, a proven narrowing:** `--no-inline-config` over the 27 files
reads 0 errors and 0 warnings. The population comes from ESLint's own
config: 27 configured, 0 ignored. No file sets `parserOptions.project`
or `projectService`, so no untouched file's verdict can move.
- `check-governed-merges --test`: NOT governed, 200 changed lines (+100
/ -100).
- A control-byte scan over the 27 changed files finds none.

## `main` since the base

Re-fetched just before this PR opened, `origin/main` was two commits
past the base (`01e0f71ad8`: objectstack-ai#21940, objectstack-ai#21953). They touch 31 files, none
of the 27 and none under `packages/spec`, so `main` was not merged and
the census on that tree is the base's. `git merge-tree` onto
`01e0f71ad8` is clean, and none of the 5 open PRs touches any of the 27
files.

## Acceptance notes

- **Same-id test titles in this card's later stages** go with those
stages: 23 lines in `packages/spec/src`, among them
`api/protocol.test.ts` (`[objectstack-ai#5672]` x2, `(objectstack-ai#12038)` x5, `(objectstack-ai#12038 1C)`,
`(objectstack-ai#19543, door ③)`), `api/plugin-rest-api.test.ts`, `api/router.test.ts`
and `api/websocket.test.ts` (`(objectstack-ai#15677)`),
`stack-json-stage-package-body.test.ts` (`objectstack-ai#17518` x4),
`system/book.test.ts` (`(objectstack-ai#12038)`) and three `system/` titles citing
`(objectstack-ai#18124)`.
- **Same-id test titles in other packages** stay: 96 lines in 12
packages (`runtime` 37, `rest` 24, `client` 9, `metadata-protocol` 6,
`service-automation` 6, `metadata` 5, `cli` 3, `objectql` 2, and one
each in `examples/app-showcase`, `core`, `plugin-hono-server` and
`verify`), each package's share under the objectstack-ai#20513 lane children.
- **Code comments with live ids** remain in these files and their
sources, among them the `// package-rollback-response retirement (objectstack-ai#12038
3A)` banner above its describe, the `[objectstack-ai#5111 / objectstack-ai#5040 E7]` and `[objectstack-ai#5189 /
objectstack-ai#5040 E7b]` headers in `apis-publish-gates.test.ts`, and the `[objectstack-ai#17158]`
header in `export-job-family-retirement.test.ts`. Code comments are not
this card's share.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_

Co-authored-by: Claude <noreply@anthropic.com>
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants