Repository navigation
fix(runtime): the package install door parses its whole body through PackageInstallBodySchema - #20218
Conversation
…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>
Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…stall-door-body-parse
📓 Docs Drift CheckThis PR changes 1 package(s): 11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: ① Derived judgments
② Semver levelWrong as declared. Must be
③ Boundary flags
Implemented-by: Independence: INDEPENDENT AGENT (fed the card, the rulings and the PR only; not the dispatch order or the seat's conclusions) VERDICT: FAIL
|
…stall-door-body-parse
…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>
Contract reviewServed-tier: Delta of: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: Independence: INDEPENDENT AGENT (fed the card, the prior review, and the PR only; not the dispatch order or the seat's conclusions) VERDICT: FAIL
|
…stall-door-body-parse
…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>
Contract reviewServed-tier: Delta of: ① Derived judgments
② Semver levelUnchanged and still correct: ③ Boundary flags
Implemented-by: Independence: INDEPENDENT AGENT (fed the card, the prior review, and the PR only; not the dispatch order or the seat's conclusions) VERDICT: PASS |
…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>
…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>
…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>
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 answers201, withbogusdropped, 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 triage5780789216, this round lands asRefs.Clause-②: no (narrowing)
What changed
POST /api/v1/packages(packages/runtime/src/domains/packages.ts,handlePackagesRequest, theparts.length === 0 && m === 'POST'branch) now parses the whole body once, at the top of the install path. It uses the union@objectstack/specalready 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.idandversionlegs of the manifest. Each of the following therefore answered201and now answers400:name(the manifest's other required key); thenamespacegrammar; the closed value sets ofscope,runtimeandpackaging; 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 arecontributesand itskinds[],data[]seeds (SeedSchema),navigationContributions[]and their items,engine,engines, and the structured form ofpermissions. The list was measured by walkingManifestSchemaat 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 forname.idandversionlegs, which keep their published sentences. It comes before the duplicate-id409, so a request-shape refusal never depends on server state.idandversionlegs already use,deps.error(msg, 400): status400, codeVALIDATION_ERROR, taken from the standard catalog's 400 member. No error code was minted.overwrite,settingsandenableOnInstallare 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.'manifest' in parsed.data. This is the declaration's own disjointness:ManifestSchemais a strict close with nomanifestkey, and the wrapped branch requires one.ManifestSchemaadds defaults (scope: 'project',defaultDatasource: 'default'). The existing pins (packages-install-manifest-version.test.ts§2) andwithWritableVerdict'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.installBodyRefusalre-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 theidleg surfacesmanifestIdRefusal. When install options are spelled on the bare form, it appends one prescription, the declaration's own remedy: send the wrapped form, or?overwrite=trueforoverwrite. The option key set is read offPackageInstallRequestSchema.shapeand 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 notype.@objectstack/spec/kernelimport line and thePATCH /packages/:idversion 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
HttpDispatcherover a realSchemaRegistry, withOS_HOMEredirected, driven by a one-shot probe that is not committed. "Before" isorigin/main1c8b320a8. "After" is this branch atdf0108189.typetypeenableOnInstall: 'false', fresh idenableOnInstall: 'true', fresh idoverwrite: 'true', id already installedenableOnInstall: false, fresh idoverwrite: true, id already installedsettings: {a:1}enableOnInstall: falseoverwrite: true, id already installed?overwrite=true, id already installedRow 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:
enableOnInstallwas ignored, whileoverwrite: trueandsettingswere honoured. All three were stored inside the manifest. The door readbody?.overwriteandbody.settingswithout 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 refusessettings,enableOnInstallandoverwriteon 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)packages-install-body-contract.test.ts§7 builds the body exactly the wayclient.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 inpackages/client/src/client.test.ts.ObjectStackClientran through the realHttpDispatcherfrom the rebuilt@objectstack/runtimedist, built from0e3d3fdf5. Results:install(m, { enableOnInstall: false })→enabled: false, and the registry row isfalsetoo.install(m)→ enabled.install(m, { overwrite: true, enableOnInstall: true })→ 201, and the manifest was replaced.install(m, { settings: { a: 1 } })→ the settings landed.type→ the client threw, with codeVALIDATION_ERRORand status 400.packages-write-envelope.test.ts,client.test.tsandreadme-package-install-example.test.tsstayed green: 4 files, 222 tests.The objectui caller, read by the seat at
.objectui-shaf8a9d0fb(REWORK5855224201): «The create path sendsPOST /api/v1/packageswith{ manifest: manifestBody }, wheremanifestBody = { ...draft, id, name, version, type: draft.type ?? 'app' }.» The file ispackages/app-shell/src/views/metadata-admin/PackageFormDialog.tsx. Studio's create-package dialog therefore always sends a declaredtype, and row 1b's refusal does not break it.Fixture triage (off-spec door fixtures, by disposition)
type: 'app':package-door-namespace-conflict-code.test.ts(themanifesthelper);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=truecases).http-dispatcher.test.ts, the twoPOST /packages installcases changedtype: 'application'to'app'.'application'is not a member of the declared enum.packages-install-enable-on-install.test.ts. The case "the BARE body form does NOT honour the key" asserted201plus ENABLED. That was a standing pin on row 4, which the card believed had none. It now asserts400 VALIDATION_ERRORand that nothing installed.packages-install-preserves-lifecycle.test.ts. The case "the BARE body form still sets nothing" now asserts400 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
status400,error.codeVALIDATION_ERRORandsuccess: 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, andoverwrite: '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
versionleg's sentence survives when a body also lackstype. An off-spec body answers 400, not 409, on an installed id.§7 Controls: both forms install, the manifest is stored as sent, wrapped
settingsland, 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:name, in the wrapped and the bare form;namespaceoutside the grammar;scopeoutside its set;description;capabilities;contributes, inside adata[]seed, and insideengines.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)
e2c4c7d90,node scripts/ablation-replace.mjsreplaced the anchorconst 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 blob4f6b4be7to4a881701.../http-dispatcher.jsto./domains/packages.ts), so nodist/sits on the path.ablation-dist-preflight --absentconfirms the marker is absent from runtimedist/and that the tree is clean.version-leg ordering case and every §7 control stayed green. The direction was predicted before the run: red.git checkout HEAD, then showed the blob after restore equals HEAD (4f6b4be7) andgit diff HEADis empty. Re-verified by hand, the tree is clean.Tests and gates (head
df0108189unless stated; the patch rounds are recorded at the end of this section)pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2: 280 files, 3927 passed, 1 skipped, at0e3d3fdf5, before any merge oforigin/main. The branch has mergedorigin/mainthree times:df0108189, basee7f69dbba) brought no change topackages/runtime, the install schemas or the lockfile.f2e2c99bd, base4df101c38) brought the [finding] three sibling list doors declarelimit/cursorand never read them, one reportinghasMore: falseas 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) andtest-typecheck-debt.json. It does not touch the install door,PackageInstallBodySchema,ManifestSchemaor the install writers. 10 door and runtime test files re-ran on the merged tree at481ad07f: 515 passed.dd49317f1, base805af4f29) brought nopackages/runtimechange and no lockfile change.pnpm --filter @objectstack/runtime typecheckexited 0. Its test layer (tsconfig.test.json) compiles the new file;--listFilesOnlycounts it. The debt ledger held at 27 files, 191 errors and 69 signatures.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 60 families: 59 exited 0. The one other ispnpm check:dual-build-cjs-loads: NOT MEASURED (exit 3,PREREQUISITE NOT MET: 31 packages have nodist/without a full build, which is CI's). As a scoped substitute,require('packages/runtime/dist/index.cjs')loads (HttpDispatcheris a function).--ranreconciliation: 60 derived, 59 run, 1 NOT MEASURED, 0 UNRUN.pnpm lint, the fulleslint . --no-inline-config, not narrowed: exit 0.node scripts/check-issue-citations.mjs --base origin/main: exit 0 (12 citations: 11 resolve, 1 cross-repo).481ad07f, changeset only).check-adr-0087-registration --base origin/main→ 0, reading[BREAKING+clause-②-narrowing].check-changeset-no-major --base origin/main→ 0.check:dual-build-cjs-loadsNOT MEASURED (exit 3).pnpm lint→ 0; the real issue-citation check → 0.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.pnpm --filter @objectstack/runtime typecheck→ 0.201where it expects400, i.e. installed before the fix. The restore was byte-identical to HEAD.check:dual-build-cjs-loadsNOT MEASURED (exit 3; 36 packages have nodist/).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, carryingClause-②: no (narrowing), a BREAKING for callers of the install door paragraph with a FROM → TO remedy for each refused shape, and exactly one ADR-0087not-required (no-migration-prescription)disposition. It was rewritten in patch round 1, after contract review5855204061answered FAIL on the bump and the seat's REWORK5855224201upheld it. The claim's own line staysClause-②: no, because a claim line carries onlyyes | no.Why
minorand(narrowing): the diff shrinks the door's observed wire accept set. Bodies that answered201now answer400, and bare-formoverwrite: trueandsettingswere HONOURED before and are refused now.scripts/pm/clause2-line.mjsdefinesno (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:.changeset/19120-install-door-parses-manifest-version.md([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, theversionleg);.changeset/19417-install-door-parses-manifest-id.md([finding] the HTTP install door readsmanifest.idpositionally and never parses the body throughManifestSchema— POST /packages answers 201 to ids thatMANIFEST_ID_PATTERN(spec,defineStack,os build, the publish face) refuses #19417, theidleg).Each carries
minor,Clause-②: no (narrowing), a BREAKING paragraph andadr-0087: not-required (no-migration-prescription).node scripts/check-adr-0087-registration.mjs --base origin/mainnow 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 (theversionleg) and #19417 (theidleg), each pulled the door back to the same declaration. Both declaredClause-②: no (narrowing), bumpedminorand wrote an ADR-0087not-requireddisposition. This PR narrows more than either did: row 4's bareoverwriteandsettingswere honoured before and are refused now.Acceptance notes (observations, not filed)
nullbody answers 500. Through the dispatcher, anullorundefinedbody answers500 INTERNAL_ERRORwith the TypeError text ("Cannot read properties of null (reading 'manifest')"). The cause is the unchangedbody.manifest || bodyline ahead of the id gate. The Hono adapter maps an unparseable body to{}, but a literal JSONnullwould reach the door asnull. That path is inferred from the adapter code, not measured through a public door, so it is not filed. Owner: none.?overwriteis compared by hand. The query parameter is still read with a hand-written=== 'true' || === true, not withparseBooleanParam, which is the file's own declared query-boolean coercion. That code is outside this card's rows and unchanged here. Owner: none.ManifestSchemaat this head found them only in the object form of a navigation item'svisibleand itsmeta. The gate refuses unknown keys in the manifest and in every nested block the declaration closes:contributesand itskinds[],data[](SeedSchemais astrictObject),navigationContributions[]and their items,engine,engines, and the structuredpermissions. §8 pins three of these at the door. The earlier sentence here nameddata[]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'sPackageInstallBodySchemadocblock goes stale when this lands. Four places are affected:201.type; they now carrytype.enableOnInstalldocblock says the door "reads the raw body".package-api.test.tshas 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
PackageInstallRequestSchemabe closed (.strict())? The door now asks the declaration, so it follows with no edit, and §5 stays green either way.manifest,settings,enableOnInstallandoverwrite. objectui'sPackageFormDialogsends{ manifest }.enabledOnInstall: falseis silently dropped, and the package installs ENABLED. That is the same harm as row 3, reached by a typo.Clause-②arm and bump. ✅ Answered A by the seat (REWORK5855224201) and applied in patch round 1. The claim declaresClause-②: 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 readsmanifest.idpositionally and never parses the body throughManifestSchema— POST /packages answers 201 to ids thatMANIFEST_ID_PATTERN(spec,defineStack,os build, the publish face) refuses #19417) declaredno (narrowing)withminor. Should the seat re-declareno (narrowing)withminorand an ADR-0087not-requireddisposition? Recommended, because refusing previously honoured bare-formoverwriteandsettingsis a narrowing of observed wire behaviour.Generated by Claude Code