Repository navigation
fix(runtime): POST /api/v1/packages parses the manifest version leg instead of installing anything it is handed - #19326
Conversation
`PackageInstallRequestSchema` binds `manifest: ManifestSchema`, which declares `version` required with a semantic grammar. The install door parsed nothing at all — `const manifest = body.manifest || body` went straight to `installPackage` with only an id check on the way — so a manifest with no `version` installed and answered `201`. The door now asks the declaration by reference (`ManifestSchema.shape.version`) and refuses loudly with a prescription, `400` / `VALIDATION_ERROR` through the surface's own `deps.error`. Scope is the `version` leg alone. The four other residual classes the declaration's docblock records are separable and are left standing. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf
…l, order §0 asserts each fixture off-spec BY THE DECLARATION before §1 holds the door to it, so a moving version-grammar canon reds the premise and names itself rather than failing an opaque case downstream. §1 pins both halves a refusal owes: 400 / VALIDATION_ERROR on the wire, and neither install writer called. §2 is the positive control on both body forms and both install limbs. §3 pins the gate ahead of the duplicate-id 409, with the 409 itself as the control. ⛔ No case asserts anything about the four residual classes this card does not close — in either direction. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf
…arried
`ManifestSchema` has always declared `version` required, so
`{ id: 'pkg-a', name: 'A' }` was never a legal input to the door this
case drives — the fixture's own defect is what the case reports now that
the install door parses that leg.
The repair is owed whatever order the new gate sits in: the `?overwrite=true`
limb asserts `201`, which an under-specified manifest must never reach.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf
`minor`, not `major`: the launch-window guard ships breaking changes as minor while the stack versions in lockstep. Declared breaking and `Clause-②: no (narrowing)` — the door's published accept set shrinks, back toward what the declaration always said. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf
…ckages-install-manifest-parse
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 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 4a5b17eae29c22f9da63dc33a8a8ab5afe2249ea && git checkout 4a5b17eae29c22f9da63dc33a8a8ab5afe2249ea
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 84a7e9e62f874b8e931a3f2fa2961505a351dc4a 0ccf7296d4ff13d2513214dc12fd28f1807c5581 && git checkout -B drift-repro 84a7e9e62f874b8e931a3f2fa2961505a351dc4a && git merge --no-ff 0ccf7296d4ff13d2513214dc12fd28f1807c5581
node scripts/docs-audit/affected-docs.mjs --json 84a7e9e62f874b8e931a3f2fa2961505a351dc4a
|
The install door's residual docblock, its pinning test and the changeset
transcribed the duplicate-id drive in domain-handler-registry.test.ts as
{ id: 'pkg-a', name: 'A', version: '1.0.0' }. That drive stopped posting
that body at PR #19473, which made the door parse the manifest's id leg
and repaired the fixture's id to com.example.pkg-a. The old body is the
REVERSED pin beside it and is answered 400. It had been filed as a 201
residual under clause 1b.
Read at the merge base fdeeea0 and at origin/main 2c1011b (the file
is byte-identical at both): the drive posts
{ id: 'com.example.pkg-a', name: 'A', version: '1.0.0' }, answered 409 and
then 201 on ?overwrite=true. The declaration refuses it on `type` alone.
Clause 1b's "both door drives above" is true of that body, so 1b is
unchanged.
- package-api.zod.ts: the drives paragraph quotes the real body and
credits both repairs, the version to PR #19326 and the id to PR #19473.
- package-api.test.ts: DOOR_DRIVE_REGISTRY transcribes the real body.
The control that rested on the stale id now asserts that the registry
drive parses once `type` is added, like the conflict drive. The old
`pkg-a` reading is kept, pointed at the old body: it is refused on the
id alone.
- changeset: the drives sentence names the body the drive posts.
Prose and test only. No schema, accept set, export or runtime change.
Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
Co-authored-by: Claude <noreply@anthropic.com>
…cute, and retire the two they never did (objectstack-ai#19364) Part of objectstack-ai#17667 Clause-②: yes Ruling of record: comment `5651023067` — director seat, decision batch objectstack-ai#126 item 1, maintainer 「同意」 (live PM chat 2026-09-13) to `1(2)·2A·3A·4B`. **Route 2**: the door's declaration and its reads are aligned. ⛔ Not re-adjudicated here. `Part of`, not `Fixes`, and the reason is measured rather than cautious — see **Why this does not close the card** below. The dispatch asked for `Fixes objectstack-ai#17667`; that instruction is overridden by the standing rule that a merge which should not close a card uses `Part of` and names the half it leaves. Flagged rather than silently chosen. ## STEP ZERO first — the ruling's own precondition did NOT stop the work Ruling item 4 makes the dispatch's first act a stop condition: if a platform-wide cursor convention already exists and `/packages` is the only holdout, route 1 **by reuse** is re-priced and the taker stops. Measured in this worktree at `81e12e1`, 2026-09-20T12:05Z: - **No shared pagination helper reaches any REST list door.** The only cursor codec in the tree is `encodeStorageListCursor` / `decodeStorageListCursor` (`packages/spec/src/contracts/storage-service.ts`) — the storage-**adapter** `list()` contract. Imports of it outside `service-storage` and its own contract file: **zero**. Imports of any `Cursor`-named symbol by `packages/runtime/src/**` or `packages/rest/src/**`: **zero**. - **Lit controls on the same greps, so those zeros are readable.** `parseIntegerParam` (`packages/runtime/src/query-param.ts`) IS found shared across two dispatcher domains, and `refuseUnknownQueryParams` IS found shared across two `packages/rest` files. The search finds shared helpers when they exist. - **`/packages` is not the only holdout — it is one of four.** `ListExportJobsRequestSchema`, `ListAiConversationsRequestSchema` and `ListRunsRequestSchema` all declare `limit` and/or `cursor`; none paginates. `GET /automation/:name/runs` even validates `cursor` at the boundary and then returns `{ runs, hasMore: false }`, with its own comment recording that "today's engine ignores the option entirely" — the same shape as this door, one domain over. - **The platform's travel is the other way.** `api/ListNotificationsRequest:cursor` (objectstack-ai#6361) and `data.query.cursor` (objectstack-ai#4286) were both retired before this one. ⇒ the stop condition is false in both of its conjuncts. Proceeding to items 1 and 3 was measured, not assumed. ## Ruling item 1 — declare what the doors already execute | door | parameter | read site | now declared on | |---|---|---|---| | `GET /api/v1/packages` | `type` | list branch, `manifest.type` equality | `ListInstalledPackagesRequestSchema` | | `GET /api/v1/packages/:id` | `version` | `readRequestedVersion(query?.version)` | `GetInstalledPackageRequestSchema` | | `DELETE /api/v1/packages/:id` | `keepData` | uninstall branch | `UninstallPackageApiRequestSchema` | | `POST /api/v1/packages` | `overwrite` | install branch | **already declared — see below** | No accept set moves: the doors served all four before and serve them identically now. Each declaration is measured from the handler's actual read, not from the card: - **`type`** is an open `z.string()`, deliberately not an enum. The door compares `manifest.type === query.type` on any non-empty value, and `ManifestSchema.type` is no shared vocabulary — a narrower declaration would state a rejection this wire does not perform. An unmatched value is not an error; it selects nothing. - **`version`** is a plain string. `latest` means "the installed row" and is equivalent to omitting the key; comparison is exact string equality against `manifest.version`, with no semver-range semantics, and the id is resolved first so an unknown id keeps its existing 404 wording. All of that is in the key's docblock so the next reader does not have to open the runtime. - **`keepData`** is declared boolean, and the docblock records the two spellings the wire actually honours — `keepData=true` and `keepData=1` — and warns that anything else, `keepData=yes` included, reads as absent and DROPS the tables. Widening the door's own comparison would be a runtime change this declaration is not. ###⚠️ Premise drift: `overwrite` was already discharged, by the PR that unblocked this card The card's body (2026-09-11) lists `?overwrite=` as read-and-undeclared. That is no longer true. PR objectstack-ai#19130 merged 2026-09-20T11:11:16Z — the same landing this card had been serialised behind — and it declares `overwrite: z.boolean().optional()` on `PackageInstallRequestSchema`, with a docblock that already names the `?overwrite=true` query spelling. One quarter of ruling item 1 needed nothing. **No edit was made for it**, deliberately: re-declaring it would have been churn, and the existing declaration is better than one written from the card. ## Ruling item 3 — retire `limit` and `cursor`, `.default(50)` included Both keys are `retiredKey()` tombstones, not deletions. The schema is not `.strict()`, so a bare deletion makes Zod silently strip whatever a generated client keeps sending — a clean parse and a parameter that never takes effect, which is this card's own defect moved one layer down (ADR-0104). Writing either key is now a `tsc` error and a parse error carrying the prescription. The prescription names the removed default specifically, because that is the load-bearing half: a reader of the published schema was entitled to believe an unparameterised list is capped at 50 rows, and it has never been capped at all. **The retirement kit, and the two entries it deliberately does NOT have.** Precedent hunted and followed: `api/ListNotificationsRequest:cursor` (objectstack-ai#6361) is the same shape one route over — an HTTP-only request key retired with a tombstone and a D3 semantic entry. Zone 2 flagged this precedent as unverified by the seat; it exists, and this change copies it. - `RETIRED_KEYS_BY_MAJOR[18]` — two entries, one file each, generated into `migrations/registry.ts` by `gen:migration-registry`. - D3 semantic entry `packages-list-pagination-retired`, carrying `surface` / `replacement` / `reason` / `acceptanceCriteria` to `spec-changes.json`, the generated upgrade guide and `os migrate meta`. - Registered at **18, not 17**, per the `ui/ListView:pageName` and `security/ObjectPermission:allowPurge` convention: the removal ships on the 17.x line as a minor, and the prescription lives at the major boundary where `migrate meta` users look. The guidance string says `17.5.0`, the shipping version, matching `view.pageName`. - **No D2 conversion**, and the asymmetry is the point: a conversion rewrites an authored source or a stored `sys_metadata` row, and this shape is HTTP-only — nobody authors a `ListInstalledPackagesRequest` and nothing persists one. The `os migrate meta` house sentence is therefore correctly absent from the prescription; the pin only judges prescriptions that name the command. - **No `acceptRetiredDefaultResidue` stage**, for the same reason one layer along. That helper exists for a retired default the published toolchain materialized into built artifacts. Nothing has ever parsed this schema, so the `.default(50)` reached no artifact and there is no residue population. The `authorable-defaults/api.json` line simply leaves with the key — `DEFAULT_CHANGES_BY_MAJOR` excludes retirements by name, and `check:authorable-surface` accepted it without one. - **No liveness-ledger row** to touch: `liveness/api.json` is the `api` METADATA type's ledger, not the spec `api/` category. Zero occurrences of `ListInstalledPackages` in it. **Ratchet readings, stated because their direction is route-dependent:** `authorable-surface/api.json` gains two `[RETIRED]` rows and three new keys; `authorable-defaults/api.json` loses exactly the `= 50` line; `api-surface/` is unchanged, which is correct for a key-level narrowing on a surviving def. ### `hasMore` is now true by construction, and the comment says so `hasMore` stays the constant `false` it already was. With no `limit` and no `cursor` to ask with, nothing can request a page, so there is never a next one to announce. That is recorded at the response declaration — the return site itself is in `packages/runtime/src/domains/packages.ts`, which this dispatch is fenced off — with an explicit warning against "fixing" the constant back into a computed value before a request-side way to ask exists. Pinned by a test. ## Why this does not close the card Ruling item 2 — `enabled` implemented in `packages/runtime/src/domains/packages.ts`, one filter line in the shape `status` already has — is assigned by the ruling to the **cli seat's sibling PR** and is fenced off this dispatch. Measured on the merged `origin/main` at `2277d1f`, 2026-09-20T13:30Z: `query?.enabled` occurs **0** times in that file; control on the same file, `query?.status` occurs **1** time. So `enabled` is still declared-and-unread after this PR, which is one live instance of the very class this card names. That is recorded in the schema docblock rather than glossed, and it is why the closing line is `Part of`. PR objectstack-ai#19326, which held that file during dispatch, turns out to be the manifest-`version` change for objectstack-ai#19120 and has merged; it is not the `enabled` sibling. ## Verification Readings taken in this worktree; the gate union below was run after the final commit, at `80937f5`. - **Gate family, derived from the real changed paths** (`scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, re-derived after the merge): **107 derived · 104 run green · 3 NOT MEASURED · 0 UNRUN · 0 red**, reconciled with `--ran` carrying each exit code captured before any pipe. The three NOT MEASURED are the gates' own `exit 3` PREREQUISITE NOT MET: `check:dual-build-cjs-loads` (wants a whole-repo build), `check:type-check-debt` (a re-measure, which exits 3 by design and is a maintainer's act to act on), and `check-plugin-teardown-shape --self-test` (wants an unshallow checkout). None is a finding. - `pnpm --filter @objectstack/spec check:generated` — **all 15 artifacts up to date**, at the final head. - `pnpm --filter @objectstack/spec test` — **501 files / 14662 tests passed**. `pnpm --filter @objectstack/spec typecheck` — clean. - `check:test-typecheck` GRADUATED `src/api/package-api.test.ts`: its two recorded `TS6133` unused-import errors are gone because the new tests use both symbols, so the shrink-only ledger entry is deleted in this PR, as that ratchet requires. - **Reverse verification (one-off, not left in the tree).** The `limit` tombstone was replaced on disk with its old `z.number().int().min(1).max(100).default(50)` via `scripts/ablation-replace.mjs`, which proved the write landed (anchor 1 → 0, blob `de8722127dc3` → `10542945489d`) before running anything. Direction observed: **red**, 2 failed / 68 passed — both the prescription pin and the absence pin fire. Restore verified by blob identity against `HEAD` and an empty `git diff HEAD`, by the tool, not by an exit code. - **Absence sweep, tree-scoped, with lit controls.** Authoring sites for `limit` / `cursor` on this request shape outside the new registry entries: **zero**; `packages.list(` calls passing either: **zero**. Controls: `overwrite` IS found in the same spec file (10 hits) and `packages.list(` IS found across five files by the same pattern shape. The first-party SDK already declares `list(filters?: { status, type, enabled })` — no `limit`, no `cursor` — so unlike objectstack-ai#6361 there is no shipped producer to delete alongside the key. ## Acceptance notes Out of scope, noted and deliberately not filed: - **`gen:api-surface-declarations` output was not stable across builds of identical sources**, and it cost this run a wrong turn worth recording. Build objectstack-ai#1 of the unchanged `ui` sources emitted one enum-member ordering, build objectstack-ai#2 emitted another; 184 lines of `api-surface-declarations/ui.txt` flipped between them, and a single control build at BASE reproduced BASE — which made one sample look like proof that my diff caused the churn. It did not. The correct reading needed three builds. **This finding has no surviving consumer**: `origin/main` at `2277d1f` reverted the whole declaration-text snapshot (objectstack-ai#19024) and deleted `api-surface-declarations/` along with its gate, which is also the merge conflict this branch resolved by accepting the deletion. Successor: none. Recorded here rather than filed because the artefact and the gate that read it no longer exist. - **Three sibling list doors carry the same declared-not-honoured pagination shape** — `ListExportJobsRequestSchema` (`limit` with `.default(20)`, `cursor`), `ListAiConversationsRequestSchema` (`limit`, `cursor`) and `ListRunsRequestSchema` (`limit`, `cursor`, the last validated at the boundary and then ignored by the engine, with `hasMore: false` hard-coded). This is a reproducible contract divergence of exactly this card's class, on doors this card does not name, and the handback report carries it with dedupe words for the seat to file. ⛔ Not filed from here and ⛔ not widened onto this PR. - The `/packages` dispatcher domain declares no closed query-parameter set, so an unrecognised name is still dropped rather than refused. That is route 3, which the ruling considered and refused; noted so a later reader does not read this PR as having taken it. Successor: whoever converts the dispatcher domains per the incremental ingress lane. Landing waits for the seat: this PR is a contract-review carrier and the seat handles both the carrier and the at-tier review. Nothing here flips ready, enqueues, arms auto-merge, requests review, or writes a label or assignee. --- _Generated by [Claude Code](https://claude.ai/code/session_01HnRAeVTLJevtQ5iCPX6JSm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ry parameter (objectstack-ai#19405) Fixes objectstack-ai#19394 Clause-②: no (narrowing) Ruling item 2 of objectstack-ai#17667 — comment `5651023067`, director seat, decision batch objectstack-ai#126 item 1, maintainer 「同意」 to `1(2)·2A·3A·4B`. Items 1, 3 and 4 landed in PR objectstack-ai#19364; this is the sibling the ruling assigned to 「the cli seat's sibling PR」. ⛔ Not re-adjudicated here. objectstack-ai#17667 stays open — see *Card relationships* below. ##⚠️ Two places where this PR does not match the dispatch order, flagged rather than silently matched **1. The changeset is `minor` with a BREAKING banner, not `patch`.** The order said "if required, `patch` — ⛔ never `major`", and derived from the repo's own gates the answer is neither. The diff narrows a published wire accept set: `?enabled=1` used to answer `200` with every installed package and now answers `400`, and `?enabled=true` used to answer every row and now answers a subset. `scripts/check-changeset-no-major.mjs` records the launch-window convention — "During the launch window we ship breaking changes as `minor`" — and the sibling round in this very file, PR objectstack-ai#19326, spelled exactly this class as `minor` + `**BREAKING**` + `Clause-②: no (narrowing)` + an ADR-0087 marker. This PR copies that. ⛔ No `major` anywhere. **2. The `Clause-②` line carries the `(narrowing)` arm the claim comment did not.** The claim declared `Clause-②: no`; the value is unchanged and still `no` (nothing here adds a schema key, a closed-set member, a published export or a registry row — `check-widening-tells` reads 0 tells). The arm is added because the diff IS breaking in the direction `check-adr-0087-registration` judges, and that gate's own self-test names 「the `narrowing` ARM with no banner, no `major` and no `!`」 as a breaking declaration. Declaring the direction is what makes the marker owed rather than optional. ## ⭐ Which declaration this was read from, quoted `packages/spec/src/api/package-api.zod.ts`, `ListInstalledPackagesRequestSchema`: ```ts /** Filter by enabled state */ enabled: z.boolean().optional() .describe('Filter by enabled state'), ``` and the same file's docblock on that schema, which names the gap this card closes: >⚠️ ONE key is not there yet, and it is recorded rather than glossed: **`enabled` is still declared here and still unread by that door.** The same ruling closes it (item 2 — one filter line, the shape `status` already has) in the runtime, which is a different file and a different PR Four things are read off that one line, and every one of them is a decision this door would otherwise have had to invent: - **name** — `enabled`, not `disabled` and not `status`. - **type** — `z.boolean()`: two spellings on the wire and no third. - **default when absent** — there is NONE. `.optional()` carries no `.default()`. - **absent vs an explicit value** — they DIFFER, and in the direction that is easy to get backwards. Absent means NO filter: every row, enabled and disabled alike, which is what this door served before. `enabled=false` is a filter and selects the disabled rows only. That distinction is not inferred — the first-party SDK already spells it, `if (filters?.enabled !== undefined) params.set('enabled', String(filters.enabled))` in `packages/client/src/index.ts`, so `?enabled=false` arrives here from a shipped producer. ## What changed `packages/runtime/src/domains/packages.ts`, the `parts.length === 0 && m === 'GET'` branch: - the filter is read before the registry, so a request-shape refusal does not depend on server state (the ordering argument the `version` gate one branch over already records), and after the capability gate, so a 400-vs-403 difference tells an unauthorized caller nothing; - `undefined` stays reachable and never collapses into `false`; - the row side is the row's own `enabled`, ⛔ not `status`. They are independent keys on `InstalledPackageSchema` and a row carrying no `enabled` counts as enabled — that schema's declared `.default(true)`. Spelled as a two-way partition so `?enabled=true` and `?enabled=false` sum to the unfiltered list; `p.enabled === want` would drop a key-less row out of both halves, silently, on a 200. ### The grammar is asked, ⛔ never re-spelled The coercion is `parseBooleanParam` (`packages/runtime/src/query-param.ts`), the repo's one parser for a query parameter a schema declares `z.boolean()`. `GET /api/v1/notifications` reads its `read` key with it, and that key is declared character for character the same — `read: z.boolean().optional()`. So `enabled` inherits the rule that module's header states: 「A wire contract that declares `z.boolean()` has exactly two spellings; a third is refused rather than guessed」. A local `=== 'true'` here would have been a second dialect for one declared type, which is the `?read=1` defect (objectstack-ai#6928) that module exists to stop anyone writing again. Multiplicity is answered first, with the sentence this door already uses for a repeated `?version=` (`repeatedQueryParamMessage`, objectstack-ai#17672) rather than a second one for the identical condition. A one-element array is one occurrence encoded differently by an adapter and is unwrapped; an empty array is no occurrence — both per that rule's own header, which is why the raw value cannot simply be handed to `parseBooleanParam` (it refuses every array). ### ⛔ Why this is not literally 「one filter line, same shape as `status`」 The ruling's wording for this item is quoted verbatim above and its *shape* half is the one thing here that does not transfer, so it is flagged rather than matched. `status` is declared `z.enum([…])` and read as `if (query?.status)` plus a string comparison. Applied to a declared BOOLEAN that guard and that comparison are wrong twice: the string `'false'` is truthy so the guard admits it, and `p.enabled === 'false'` matches no row at all — so the caller who asked for the disabled half would be handed an empty list with a `200`, which is this card's own defect pointing the other way. The filter is one line; its READ cannot be a copy of `status`'s. Nothing in `packages/spec` moves for this and no spec change was needed. ## Verification Readings taken in this worktree, against `origin/main` at `c5d3d1d8b6`. - **Gate family, derived from the real changed paths** (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, re-derived after the final commit): **59 derived · 58 run green · 1 NOT MEASURED · 0 UNRUN · 0 red**, reconciled with `--ran` carrying each exit code captured before any pipe. The one NOT MEASURED is the gate's own `exit 3` PREREQUISITE NOT MET: `check:dual-build-cjs-loads` wants a whole-repo build ("Run `pnpm build` first. ⛔ This is NOT a pass: nothing was measured."). It is not a finding. - `pnpm --filter @objectstack/runtime typecheck` — clean, `tsc --noEmit` plus `check:test-typecheck` OK (the new test file adds no ledger debt). - `pnpm --filter @objectstack/runtime test` — **271 files / 3761 passed / 1 skipped**, and `test:repo` **2 files / 69 passed**. The new file alone is 27 tests. Repo-wide `eslint . --no-inline-config` also ran in full at this head: **6946 files, 0 errors, 0 warnings** — a whole-population reading, not a narrowing. - **Reverse verification (one-off, not left in the tree).** Both halves of the read were removed on disk with `scripts/ablation-replace.mjs`, which proved each write landed before anything ran (anchor 1 → 0; blob `6db66d8936e0` → `21e49bbcb79b` → `207c948d69ce`), restoring the pre-card door: the parameter parsed by nothing and filtered by nothing. Direction observed: **red**, **17 failed / 10 passed**. The 10 that stayed green are the three declaration pins, the three absent-case pins — correct, since an ignoring door still serves the absent case right — and four preservation pins. Restore verified by blob identity against `HEAD` and an empty `git diff HEAD`, by the harness, ⛔ not by an exit code. - The subject resolves through a RELATIVE import inside its own package, so no `dist` rides between the mutation and the verdict and no rebuild is owed on either leg. ## Card relationships - **`Fixes objectstack-ai#19394`** — this card only. - **objectstack-ai#17667 is not closed here.** It stays open until this lands and until its own `nextCursor` residue rides the release retirement batch; the card says so in as many words. - **objectstack-ai#19328 is not addressed here.** Those four residual install-door classes live in the `POST` branch of the same file. The diff does not touch that branch and the new test file asserts nothing about them in either direction — pinning them as `201` would freeze known residuals as intended behaviour, and pinning them as refused would widen a graded scope. ## Acceptance notes Out of scope, noted and deliberately not filed: - **`ListInstalledPackagesRequestSchema`'s docblock is now stale in one paragraph** — the⚠️ block quoted above says `enabled` is "still declared here and still unread by that door", which stops being true when this merges. It is prose in `packages/spec/**`, a lane this dispatch is fenced out of, and the successor is named rather than hypothetical: objectstack-ai#17667 is still open and its closing PR belongs to the spec seat. Not filed as its own card, because a card whose whole content is "delete a paragraph the open parent card already owns" is board noise. - **`parseBooleanParam` refuses a ONE-element array while the repo's multiplicity rule accepts it as one occurrence.** That is why this door composes the two rules instead of handing the raw value over. `GET /api/v1/notifications` calls the parser directly and therefore refuses `?read=['true']`. Not filed: it is a difference between two house rules, not a reproducible defect against a declared contract, and no measurement was taken against that door. Successor: whoever converts the dispatcher domains per the incremental ingress lane. - **The `/packages` dispatcher domain still declares no closed query-parameter set**, so an unrecognised NAME is dropped rather than refused. That is route 3, which the ruling considered and refused; objectstack-ai#19364 recorded the same note and it is unchanged by this PR. Successor: the same ingress lane. Landing waits for the seat. Nothing here flips ready, enqueues, arms auto-merge, requests review, or writes an assignee. --- _Generated by [Claude Code](https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…d `version` half and open `type` half (objectstack-ai#19935) Fixes objectstack-ai#19327 Clause-②: no `PackageInstallBodySchema`'s docblock in `packages/spec/src/api/package-api.zod.ts` lists the bodies `POST /api/v1/packages` answers `201` to while the declaration refuses them. Its clause 1 recorded "a manifest missing `type` and/or `version`" as ONE class. PR objectstack-ai#19326 made the door parse `ManifestSchema.shape.version` by reference, so only the `type` half of that clause is still true. This PR splits the clause. The diff is the docblock, one spec test file and one changeset. No schema, accept set, export or runtime code moves, and residual classes 2 to 5 are untouched. ## Both halves re-measured at `origin/main` `fdeeea0cc9`, before any edit Both halves read exactly as the card says. | half | body sent to the door | status | `error.code` | installed | |---|---|---|---|---| | control | wrapped, well-formed | 201 | none | yes | | `version` | wrapped, no `version` | **400** | `VALIDATION_ERROR` | no | | `version` | bare, no `version` | **400** | `VALIDATION_ERROR` | no | | `type` | wrapped, no `type` | **201** | none | yes | | `type` | bare, no `type` | **201** | none | yes | **How it was driven.** I used a one-shot probe, deleted right after the reading and never committed. It was a vitest file at `packages/runtime/src/domains/probe-19327-oneshot.test.ts` with the harness of `packages-install-manifest-version.test.ts`: a real `HttpDispatcher`, spied protocol and registry install writers, and `OS_HOME` redirected. It called `handlePackages('', 'POST', body, {}, admin)` once per row and printed status, code and whether either writer was called. It ran in one invocation with the existing drives that the docblock's parenthesis «both door drives above» points at: ``` bash scripts/pm/os-verify-lock.sh -c "pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2 src/domains/probe-19327-oneshot.test.ts src/domains/packages-install-manifest-version.test.ts src/domain-handler-registry.test.ts src/package-door-namespace-conflict-code.test.ts" Test Files 4 passed (4) Tests 82 passed (82) os-verify-lock: VERDICT command-exit 0 ``` - **The `type` half, from both existing drives.** Each drive posts a manifest with no `type`, and each asserts that the door answers `201`: - `package-door-namespace-conflict-code.test.ts` posts `{ id, name: id, namespace, version: '1.0.0' }`. - The duplicate-id case in `domain-handler-registry.test.ts` posts `{ id: 'com.example.pkg-a', name: 'A', version: '1.0.0' }` (`:600`): `409` first, then `201` on `?overwrite=true`. The body `{ id: 'pkg-a', name: 'A', version: '1.0.0' }` has been that file's REVERSED pin since PR objectstack-ai#19473 and is answered `400` (`:622-623`), so it is not a residual. - **The `version` half, from objectstack-ai#19326's door test.** §1 of `packages/runtime/src/domains/packages-install-manifest-version.test.ts` pins `400` + `VALIDATION_ERROR` + neither writer called, on both body forms. It is **cited, not duplicated**. ## What changed **`packages/spec/src/api/package-api.zod.ts` (docblock only)** - **Clause 1 is split:** - **1a, missing `version`:** marked CLOSED, naming PR objectstack-ai#19326 and the door-side pin. - **1b, missing `type`:** still OPEN, answered `201`, with «both door drives above». - The labels 1a/1b match the table in objectstack-ai#19326's own body, so the two records read the same way. - **The count stays true.** "five classes" remains correct because class 1 is still open through its `type` half. The count sentence now says «class 1 in its `type` half only, since PR objectstack-ai#19326». Numbering 1 to 5 is unchanged, so no citation of clauses 2 to 5 moves. - **In-place fix of the drives paragraph (same defect class).** The paragraph above the list quoted the registry drive as `{ id: 'pkg-a', name: 'A' }` and said «the second carries no `version` either». That drive was repaired twice: PR objectstack-ai#19326 gave it a `version`, and PR objectstack-ai#19473 replaced its id `pkg-a`, which `MANIFEST_ID_PATTERN` refuses. At the merge base `fdeeea0cc9` and at `origin/main` it posts `{ id: 'com.example.pkg-a', name: 'A', version: '1.0.0' }`. The paragraph is clause 1b's antecedent, so left alone it would have filed a no-longer-posted body under the open residual. It now quotes the body the drive posts, says the declaration refuses both drives on `type` alone and the door answers both `201`, credits both repairs, and says the door answers the old `pkg-a` body `400`. **`packages/spec/src/api/package-api.test.ts` (the pin of the clause)** - `DOOR_DRIVE_REGISTRY` now transcribes the body the drive posts: `{ id: 'com.example.pkg-a', name: 'A', version: '1.0.0' }`. Its comment credits both repairs, and its `it` title drops "no `version`". - The control that rested on the stale id is now «the missing `type` is what decides it, for BOTH drives — the registry drive's old id is refused on its own». It asserts that the registry drive parses once `type` is added, as the conflict drive does. The old `pkg-a` reading is kept, pointed at the old body: `{ ...registryKeysCompleted, id: 'pkg-a' }` is refused on the id alone, and the green parse just above it is its lit control. - The residual list is hoisted to `DOOR_201_RESIDUALS`, and the existing "the door answers 201 to all of them anyway" assertion is unchanged over it. - **New pin:** «✅ clause 1a is CLOSED — every body in the live residual carries a `version` the declaration accepts». It checks each residual's manifest with `ManifestSchema.shape.version`. Its lit control is that `undefined` is refused. It cites the door-side pin rather than repeating it, because this package cannot import the door. **`.changeset/19327-install-door-residual-split.md`: `patch` for `@objectstack/spec`** A changeset is owed, not `skip-changeset`, because `files[]` ships `src/**/*.zod.ts` and the docblock is also emitted into the built declarations. After the build, the new clause text is in `dist/api/index.d.ts` and `dist/api/index.d.mts`, and the old spelling "and/or `version`" hits 0 across `dist` and `src`. The bump is `patch` because it is a text correction in a released package, with no API change. Note that npm `@objectstack/spec@17.4.0` has no `PackageInstallBodySchema` at all: grep 0, with `PackageInstallRequestSchema` = 4 as the control in the same file. The stale clause has therefore not shipped yet. The next release would be the first to carry it. ## Verification, round 1 (head `826e612b39`) **Reverse verification.** The fix was committed first. I then restored the old drive transcription `{ id: 'pkg-a', name: 'A' }` through `scripts/ablation-replace.mjs`, with the anchor counted 1 → 0, the replacement 0 → 1, and the blob `e976bff69a` → `5b952ed89a`. - **Predicted beforehand:** 2 red, 73 green. The two reds were the new 1a pin, plus the objectstack-ai#17534 lit control in "the missing keys are what decide it", which now takes the drive's `version` through the spread. - **Observed:** `Tests 2 failed | 73 passed (75)`, exactly those two. - **Restore:** the blob is back to `e976bff69a`, equal to HEAD, and `git diff HEAD` is empty. Restore was proven by hash, not by exit code. **Spec package** - **Test:** `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2` gives `Test Files 527 passed (527)`, `Tests 15504 passed | 1 todo`, exit 0. - **Typecheck:** `pnpm --filter @objectstack/spec run typecheck` exits 0. `check:test-typecheck` reports OK. `tsc -p tsconfig.test.json --listFilesOnly` lists `src/api/package-api.test.ts` (1 hit), and that file has no debt-ledger entry. - **Build:** `turbo run build --filter=@objectstack/spec` exits 0. `git status` was clean afterwards, so no generated artifact moved. **Gates.** From `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at this head: 82 derived, run one by one with each exit code captured before any pipe. `--ran` reports: «✓ dispatch-gates --ran: 82 derived famil(ies) accounted for — 82 run, 0 NOT-MEASURED (a DERIVED zero — all 82 recorded an exit code and none of them is 3)». - Two gates first answered exit 3 (PREREQUISITE NOT MET) on this fresh worktree: `check:dual-build-cjs-loads` and `check:type-check-debt`. After `turbo run build --filter='./packages/*' --filter='./packages/*/*'` (72 tasks, exit 0), both measured 0: - «✓ check:dual-build-cjs-loads — 104 published require entry point(s) across 67 package(s) load». - «check-type-check-coverage --re-measure: OK — 4 ledger entr(ies) re-measured». **Lint (a narrowed pass, stated as one).** I ran `eslint --no-inline-config --format json` on the three touched paths. The JSON has 3 results. The two `.ts` files show 0 errors and 0 warnings. The `.md` is outside eslint's own population ("File ignored because no matching configuration was supplied"). This narrowing covers everything the full run would: `eslint.config.mjs` never enables type-aware linting (its note at lines 327-328: no `parserOptions.project`, no typed rules), so this diff cannot move the verdict of any untouched file. The full `pnpm lint` union is CI's. **History.** The first commit `b685c67ad2` also rewrote the pending `.changeset/18058-install-door-contract-rebind.md`. `check:empty-changeset`'s foreign-changeset rule refused that with exit 1. The second commit `826e612b39` restores the file byte-identical to the merge base, and the gate reads exit 0 at head. Acceptance note 1 below has the details. ## Patch round: the registry drive, transcribed as it posts (head `0251069c5`) The at-tier contract review of head `826e612b39` (record `5808378321`) failed one item. This PR had transcribed the registry drive as `{ id: 'pkg-a', name: 'A', version: '1.0.0' }` and filed it under the open `201` residual. That drive stopped posting that body at PR objectstack-ai#19473; the body is now the drive file's REVERSED pin, answered `400`. Commit `0251069c5` corrects every copy in one round. The clause split, 1a's CLOSED marking, the count sentence and the `patch` level are unchanged. **Read at both refs before any edit.** `packages/runtime/src/domain-handler-registry.test.ts` and `packages/runtime/src/package-door-namespace-conflict-code.test.ts` are each byte-identical at the merge base `fdeeea0cc9` and at `origin/main` `2c1011b01b`: - The duplicate-id case posts `{ id: 'com.example.pkg-a', name: 'A', version: '1.0.0' }` (`:600`). It is answered `409`, then `201` on `?overwrite=true` (`:601-604`). - The REVERSED pin posts `{ id: 'pkg-a', name: 'A', version: '1.0.0' }` (`:622`) and asserts `400` (`:623`). - The namespace drive posts `{ id, name: id, namespace, version: '1.0.0' }` (`:83`) and asserts `201` on the first install. **Clause 1b re-judged against both drives.** Both still show it, so 1b stays byte-unchanged. - **Declaration**, from a one-shot `tsx` probe on `src` (not committed): both real bodies fail `ManifestSchema` on `type` alone, and both parse once `type: 'app'` is added. The stale body fails on `id` and `type`. - **Door**, at `0251069c5`: `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2 --reporter=verbose src/domain-handler-registry.test.ts src/package-door-namespace-conflict-code.test.ts` gives `Tests 57 passed (57)`. That includes the duplicate-id case (`409`, then `201`), the REVERSED pin (`400`) and namespace section 1 (first install `201`). **What changed** - `package-api.zod.ts`, the drives paragraph only: - It quotes the real body, and says the declaration refuses both bodies on `type` alone while the door answers both `201`. - It credits the `version` repair to PR objectstack-ai#19326 and the id repair to PR objectstack-ai#19473. - It says the door answers the old `pkg-a` body `400`, so that body is not part of the residual. - `package-api.test.ts`: - `DOOR_DRIVE_REGISTRY` holds the real body. - The control that rested on the stale id now asserts that the real body parses once `type` is added. It keeps the old `pkg-a` reading, refused on the id alone. - The clause-1a pin's comment now names the versionless first transcription it meant. - `DOOR_201_RESIDUALS` and its doc comment are unchanged: with the corrected constant, every entry is a body the door answers `201`. - `.changeset/19327-install-door-residual-split.md`: the drives sentence names the body the drive posts, and the `pkg-a` body the door answers `400`. **Reverse verification.** Run after the fix was committed, through `node scripts/ablation-replace.mjs`, which put the stale transcription back. - Mutation: anchor ×1 → ×0, replacement ×0 → ×1, blob `37e99f04ca` → `0331f39bc4`. - Predicted beforehand: 1 red, the renamed control. - Observed: `Tests 1 failed | 74 passed (75)`. The failure is at `package-api.test.ts:945` (`expected false to be true`), the parse of the real body plus `type`. - Restored: the blob is back to `37e99f04ca`, equal to HEAD, and `git diff HEAD` is empty. - Round 1 had no assertion that could go red on this transcription. This head has one. **Verification at `0251069c5`** - **`package-api.test.ts`:** `Tests 75 passed (75)`. On `origin/main` `2c1011b01b` plus this branch's patch it gives `Tests 79 passed (79)`; the patch applies cleanly beside objectstack-ai#19937's changes to the same two files. - **Spec tests:** `--project local` gives `Test Files 527 passed (527)` and `Tests 15504 passed | 1 todo`. The three `repo`-project tests that read `.changeset/` or mention `package-api` give `136 passed`. - **Typecheck:** `pnpm --filter @objectstack/spec run typecheck` exits 0. `package-api.test.ts` is in `tsconfig.test.json`'s program and has no debt entry. - **Build:** the built `dist/api/index.d.ts` and `.d.mts` each carry the real body once and the stale body zero times. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 82. Deriving on `origin/main` plus the patch gives the same 82, with an empty set difference both ways. - `--ran` on the record as captured: «82 derived famil(ies) accounted for — 80 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)». - The two were `check:dual-build-cjs-loads` and `check:type-check-debt`, with PREREQUISITE NOT MET on a fresh worktree. - After `turbo run build --filter='./packages/*' --filter='./packages/*/*'` (72 tasks, exit 0), separate re-runs measured both at exit 0: «104 published require entry point(s) across 67 package(s) load» and «4 ledger entr(ies) re-measured … none above its recorded number». - **Lint, a narrowed pass stated as one:** `eslint --no-inline-config --format json` on the three paths gives 3 results. - Both `.ts` files have 0 errors and 0 warnings. The `.md` is outside eslint's population. - `eslint.config.mjs` enables no type-aware linting (its note at `:327-328`), so this diff cannot move any untouched file's verdict. - The full `pnpm lint` is CI's. ## Acceptance notes 1. **The pending release note `.changeset/18058-install-door-contract-rebind.md` restates the same clause.** - It reads «a manifest missing `type` and/or `version` (both of the runtime's own door drives post one)». That has been half false since objectstack-ai#19326. - It will publish into the `@objectstack/spec`, `@objectstack/runtime` and `@objectstack/client` CHANGELOGs at the next release. - It is not edited here. Correcting another PR's pending release note is a release decision that `check:empty-changeset` routes to a person, and that gate stays red on any PR that makes the edit. The proposed replacement sentence goes to the seat in the report. 2. **Clause 5 of the same residual list is stale too.** - It says the door answers `400` to a whitespace-only `id` «this declaration admits». - Since PR objectstack-ai#18319, `ManifestSchema.id` carries `MANIFEST_ID_PATTERN` and the declaration refuses it. `package-api.test.ts` already pins that refusal. - Not touched: the card fences the other classes. Reported to the seat. 3. **The header of `packages/runtime/src/domains/packages-install-manifest-version.test.ts` counts the docblock's five classes differently from the docblock.** - The header counts `version`, `type`, unknown keys, string-typed options and bare-form options. - The docblock has `type`/`version` as one class and the whitespace `id` as class 5. - This predates this PR and is not made false by it. That runtime file is not touched. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19120
Clause-②: no (narrowing)
POST /api/v1/packagesinstalled a manifest with noversionand answered201, while the published declaration for that door has always required one. The door now parses that leg and refuses loudly.What changed
PackageInstallRequestSchemabindsmanifest: ManifestSchema, andManifestSchemadeclaresversionrequired with a semantic grammar. The door parsed nothing at all —const manifest = body.manifest || bodywent straight toinstallPackage, with an id check as the only gate on the way.The install branch now asks the declaration by reference and refuses with
400/VALIDATION_ERRORplus a prescription naming the key and its shape.Three properties of that spelling are load-bearing:
PATCH /packages/:iddoor further down the same file keeps its own hand-written semver regex. A second copy here would be the third judgment of one key on one surface. Because this door asks the declaration, whatever the open version-grammar canon question settles on reaches it with no edit to this file.deps.error(msg, 400)derivesVALIDATION_ERRORfromstandardErrorCodeForHttpStatus(400), the standard catalog's member for 400 — the same dialect the repeated-query-parameter refusal on this route already answers in. Nothing inpackages/specmoves, and nothing in it was touched.400or409according to whether that id happened to be installed already — two answers to one authoring mistake. The id gate above it still wins, because with no id there is nothing to name in the sentence this gate prints.The scope fence held — the five classes are SEPARABLE
The card is graded on the
versionleg alone. The declaration's residual docblock records five classes this door answers201to; the dispatch asked whether enforcingversionforces the other four along.It does not. The structural reading: the five are produced at three different levels — a per-key field schema in
ManifestSchema.shape(this leg), the union arms' strict close (unknown keys, bare-form options), and this handler's own=== true/=== 'true'comparisons (the string-typed options). The single call that would close four at once isPackageInstallBodySchema.safeParse(body), and that call is pointedly not made.ManifestSchema.shape.versionis an independent field schema that parses one value with no reference to any other key or to the envelope.Measured at this head, after the fix, driving the real door (one-shot probe, not a committed test):
versionversiontypetypeenableOnInstall: 'false'overwrite: 'true'on the bodysettingson the bare formOnly the
versionrows moved. The other four classes are untouched in both directions, and no committed test asserts anything about them — pinning them as201would freeze four known residuals as intended behaviour and turn the card that closes one of them red for doing its job.Note for whoever takes the residual next: the docblock's clause 1 reads "a manifest missing
typeand/orversion", and after this PR only thetypehalf of it is still true. That text lives inpackages/spec, which is thedomain:specseat's — flagged in the report, not edited here.Tests
packages/runtime/src/domains/packages-install-manifest-version.test.ts, 20 cases:400+VALIDATION_ERROR+success: falseon the wire, and that neither install writer was called. A door that answered400and installed anyway satisfies a status assertion alone.installPackageunchanged — this is a gate, not a normaliser.One fixture repaired outside the new file.
packages/runtime/src/domain-handler-registry.test.ts's duplicate-id case drove the door with{ id: 'pkg-a', name: 'A' }— a bodyManifestSchemahas never admitted. Its subject is the 409 guard and the?overwrite=truebypass, not manifest completeness, so it gets theversionit never carried. The repair is owed whatever order the new gate sits in: that case's?overwrite=truelimb asserts201, which an under-specified manifest must never reach, so no placement of the gate leaves the fixture valid.Verification
Final head
0ccf7296d4, aftergit merge origin/main.pnpm --filter @objectstack/runtime test— 269 files / 3725 passed, 1 skipped, exit 0pnpm --filter @objectstack/runtime typecheck— exit 0pnpm --filter '@objectstack/runtime...' build— exit 0dispatch-gates.mjs --ran, exit codes captured before any pipe).check:dual-build-cjs-loadsansweredPREREQUISITE NOT METon the pre-merge run and measures green here once its build closure existed.pnpm lint— the full unnarrowed repo union (eslint . --no-inline-config), exit 0, at this head. Not a narrowed run, so no narrowing is claimed.check-adr-0087-registrationreads the changeset as[BREAKING+clause-②-narrowing]carryingnot-required (no-migration-prescription).Changeset
minor, declared breaking. Notmajor: the launch-window guard ships breaking changes as minor while the stack versions in lockstep. The ADR-0087 disposition isnot-required (no-migration-prescription)— nothing authorable is removed, renamed or reshaped, soobjectstack migrate metahas nothing to reach; there is no old spelling that maps to a new one, only a key the declaration already required.Acceptance notes
domain:specseat: clause 1 ofPackageInstallBodySchema's docblock recordstypeand/orversionas one201class, and onlytyperemains. Reported, not edited —packages/specis not this card's to touch.PATCH /packages/:iddoor keeps a hand-copied semver regex. Correct today and not a defect, so not filed — but it is the copy that has to be found by hand if the version-grammar canon moves, while this door follows the declaration on its own. Noted for whoever takes that canon card.Generated by Claude Code