Repository navigation
fix(runtime): a repeated ?version= on GET /packages/:id answers 400 VALIDATION_ERROR from the one shared rule, and @objectstack/rest publishes it (#17672) - #17815
Conversation
…ALIDATION_ERROR from the one shared rule (#17672) The door refused a repeated `?version=` with `404` and a sentence of its own, so a request-shape error was indistinguishable from the two genuine not-founds the same door answers. The repo's one rule for this condition already answers `400 VALIDATION_ERROR` in the ADR-0112 nested body; what blocked #17668 from calling it was that `packages/rest/src/query-multiplicity.ts` is reachable from nowhere outside its package. - `@objectstack/rest`'s barrel publishes `repeatedQueryParamMessage` and `refuseRepeatedQueryParams`, with the entry recording which half is portable across a package boundary and which is not. - The dispatcher's `/packages` domain calls the message function and drops its local copy; `deps.error(msg, 400)` derives `VALIDATION_ERROR`. - The module header's "and it reads no `version`" parenthetical is corrected — false since #17668 landed, and load-bearing prose about why the rule has one home. - `packages-get-version-scope.test.ts` §4's pin is deliberately changed from the interim `404` to the end state, and §5 pins the distinction the card is about. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
…bility Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 32 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 f79df6bbb98718c2c5f06670b7fb86d6763a751c && git checkout f79df6bbb98718c2c5f06670b7fb86d6763a751c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2070a1d865d98cd0f0299441f36d278c45ecdfdf 2969466d0cf86443511ede8986995ebccef36ab7 && git checkout -B drift-repro 2070a1d865d98cd0f0299441f36d278c45ecdfdf && git merge --no-ff 2969466d0cf86443511ede8986995ebccef36ab7
node scripts/docs-audit/affected-docs.mjs --json 2070a1d865d98cd0f0299441f36d278c45ecdfdf
|
Comment-only, on the pin file. The docs-drift advisory on the PR surfaced two pages that bear on this diff, both re-read on the branch: - `content/docs/api/client-sdk.mdx` already documents `VALIDATION_ERROR` / 400 for "a repeated query parameter", so the door contradicted a published page for as long as it answered 404 — a stronger justification than the precedent alone, and nothing to edit there. - `content/docs/kernel/contracts/metadata-service.mdx` states that a missing id on this route answers `404 RESOURCE_NOT_FOUND` with the message `Package 'ID' not found`. Both halves are already asserted here; the comment names the page so the pin says what it protects. No release-owned page is touched. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
|
| job | conclusion |
|---|---|
Type Check · source gates |
success |
Type Check · workspace |
cancelled |
Type Check · debt ledger |
cancelled |
Type Check · consumer gates |
cancelled |
Lint & Repo Gates |
cancelled |
TypeScript Type Check (aggregator) |
failure |
⇒ zero shards failed. One passed, four were cancelled, and the aggregator reports failure over cancellations — the ruled fail-closed behaviour of this check. ⛔ Never filed and ⛔ never "fixed": the maintainer refused to whitelist lifecycle values (2026-08-07), #3668 rewired it and #6082 counts shard attestations.
Why they were cancelled
| reading | result |
|---|---|
| the run itself | 34681268539 has run-level conclusion: cancelled, ⛔ not failure |
| the head it ran on | 4f530786ee88f2f663d8c23af9e3777bc58cb984 |
| this PR's head now | 2969466d0cf86443511ede8986995ebccef36ab7, pushed 07:42:11Z |
| CI on the current head | 11 success, 3 skipped, 17 still running, 0 red |
⇒ the dev pushed again while that run was in flight, GitHub's concurrency group cancelled it, and the aggregator closed fail-closed over the cancellation. The red belongs to a head that no longer exists on this branch.
⛔ No re-run. Re-running a superseded head measures nothing, and "flake" is not the diagnosis here — the cause is named and it is a cancellation with a cause, not an unexplained failure. The check that matters is the one on 2969466d0c, which is still running and carries no red.
Generated by Claude Code
|
ACCEPT — Gate state48 checks: 41 ⭐ The one thing no pressure made acceptable — checked first
And the file goes further than not-copying: The header line owed on every branch
Both genuine 404s survive, and they are pinned on the DISCRIMINATING field
⭐ And the test comments now name the two published pages the pins protect (
|
Contract reviewHead reviewed:
1. What the published surface gains — read off the BUILT artefact, not the source
repeatedQueryParamMessage(name: string, count: number): string
refuseRepeatedQueryParams(req: any, res: any, names: readonly string[]): booleanBoth are primitive-signature. Absent from the built 2. Does anything ELSE in the diff widen a published surface?
⇒ one declared widening, nothing undeclared. ⛔ 3. The premise that bent, and why it does not change the verdictTriage's premise ② has two halves. The second holds (§1). The first — 「导出那两个符号就够」 — bends: only Verdict on the open question — A, and the reason is jurisdictional, not aesthetic
A. ⛔ B would narrow a ruling, and this seat does not narrow rulings; that is the same rule that made the dev hand it up. The footgun B was reaching for is already answered where it does damage — the export site carries a GateThe clause-② declaration is legible and both carriers agree ( Reviewed-by: Generated by Claude Code |
⛔
|
| step | act | reading |
|---|---|---|
| 1 | POST …/ccr/convert_to_draft |
{"draft":true} ⇒ timeline gains convert_to_draft 08:33:57Z and removed_from_merge_queue 08:34:02Z — the dequeue actually happened, which is the half the guard warns is easy to skip |
| 2 | strip the carrier from the PR | labels documentation, needs:contract-review, size/m, tests, tooling → documentation, size/m, tests, tooling, read back |
| 3 | verify both carriers | card #17672 → domain:cli, pm:dispatched, priority:p2 (already clear); PR → clear ⇒ both |
| 4 | check-clause2-carriers --pair 17815 |
✓ "the clause-② declaration is readable in the fixed spelling and both carriers agree, and a review of record names this head" |
| 5 | re-enqueue | ready_for_review 08:34:22Z → added_to_merge_queue 08:34:23Z |
⇒ the PR is back in the queue with both carriers clear and a verdict naming this head. ⛔ Nothing about the diff changed, ⛔ no check was edited, and ⛔ the review was not re-run to paper over the sequence.
What this red was NOT
⛔ Not a failure of this PR's code — the same merge group's CI, Spec Liveness Check and Closing-Target Claim Guard all passed, and CI on the head is 41 green / 7 skipped / 0 red. ⛔ Not the fail-closed-over-cancelled aggregator pattern, which is a different red with a different cause (that one was diagnosed separately at 5644530293). This one was a real refusal of a real violation, and reading the job log rather than the check name is what told the two apart.
Generated by Claude Code
… read gate RestServer asks (objectstack-ai#20193) (objectstack-ai#20236) Fixes objectstack-ai#20193 Clause-②: yes The runtime dispatcher's `/meta` item read and its `/published` read now use the per-caller read gate that `RestServer` uses. It is one function with two callers. `RestServer`'s gate moved unchanged into `packages/rest/src/meta-item-read-gate.ts`, and `handleMetadataRequest` calls it. There is no second audience resolver in `packages/runtime`, as ruling `5793362670` item 1 requires. objectstack-ai#20156 remains open (its pending decision on the partial `app` cells of `/layers`, `?layers=true` and `/diff` is untouched here). objectstack-ai#20139 remains open. ## The measurement that picked route A Triage said the choice between B and A is a measurement. Here it is. ### Host census: which in-repo host compositions reach `handleMetadataRequest`'s item branch | host composition | REST mounted? | who answers `GET /meta/:type/:name` | can REST be mounted there? | |:--|:--|:--|:--| | `@objectstack/hono` `createHonoApp` (published; README and `content/docs/plugins/packages.mdx` name it the edge / serverless adapter) | **no** | **the dispatcher**: `app.all(prefix + '/*')`, then `dispatch()`, the domain registry, and `handleMetadataRequest` | **No, as shipped.** `RestServer` registers on an `IHttpServer` (`http.server` service). `createHonoApp` builds a bare Hono app whose catch-all is terminal by design (ADR-0076, open question 9), and `@objectstack/hono` does not depend on `@objectstack/rest`. | | a thin adapter on the public `HttpDispatcher` API (`packages.mdx`: "build a thin adapter on the public `HttpDispatcher` API") | no | the dispatcher (`dispatch()` / `handleMetadata()`) | No, by construction | | `plugin-hono-server` + `createRestApiPlugin` + `createDispatcherPlugin`: the CLI's `serve` / `dev` (`packages/cli/src/commands/serve.ts`) and `plugin-dev` | yes | `RestServer`. `createDispatcherPlugin` mounts explicit routes and **no** `/meta` route, so the dispatcher item branch is never reached | already mounted | | `packages/qa/http-conformance` `node:http` reference adapter (private QA) | yes | `RestServer` | already mounted | | `packages/verify` handle | no | never reaches `/meta` (it dispatches `/automation` and `/actions` only) | n/a | | cloud hosts (`objectstack-ai/cloud`, per ADR-0076 item 9: plugin routes with the `createHonoApp` catch-all underneath) | yes | `RestServer` first; the dispatcher answers REST misses | NOT MEASURED here: another repository | Reading. The documented embed shape cannot mount REST, and ADR-0076 item 9 records the dispatcher's `/meta` branches as "the cloud fallback fabric, not dead code", not killable while the catch-all stands. So **B** (retire the dispatcher's item reads) would remove a published answer (`meta.getItem` / `meta.getPublished` in the route ledger) from a documented host, and would reverse an accepted ADR. **A** is the route: one transport-neutral gate that both transports call. **C** is ruled out by `5793362670`. ### What the two item reads serve (B's scope, for the record) - **Dispatcher item branch:** `GET` / `HEAD /meta/:type/:name`, `/published`, `?package=`, `?preview=draft`, and a `MetadataService.getItem` fallback. The body is enveloped `{ success, data }`. Objects get the ADR-0106 mask on the plain read. - **REST:** the same two reads, plus `/layers`, `?layers=`, `/history`, `/audit`, `/diff`, `/references`, `?state=draft`, locale collapse (`resolveDocLocale` / translation), and ETag / 304 on the cached arm. The body is the bare `{ type, name, item, … }` envelope. Route A retires nothing, so no answer is dropped. The item branch keeps every one of its own parameters. ## Before and after All rows are driven through `dispatch()`, the delegate of `createHonoApp`'s catch-all: identity resolution, the domain registry and the handler. The caller is an authenticated member who does not hold `crm_admin`, on the fixtures of the REST door census (`meta-alternate-door-read-gates.test.ts`). The "before" column is at base `7e7fab73`. The "after" column is at head `0fcb064a2`, and the first five rows were also re-read through a **real `createHonoApp` app** (`app.request(…)`, a real `HttpDispatcher`, as a one-off probe that is not committed). | request (non-holder) | before | after | `RestServer`, same caller | |:--|:--|:--|:--| | `GET /meta/doc/crm_admin_runbook` | 200 + the gated body | **403 `PERMISSION_DENIED`** | 403 `PERMISSION_DENIED` | | `GET /meta/doc/crm_admin_runbook/published` | 200 + the gated body | **403 `PERMISSION_DENIED`** | 403 `PERMISSION_DENIED` | | `GET /meta/book/admin_guide` (set-gated) | 200 + the book | **403 `PERMISSION_DENIED`** | 403 `PERMISSION_DENIED` | | `GET /meta/app/crm` | 200, 3 entries | **200, `[nav_leads]`** | 200, `[nav_leads]` | | `GET /meta/app/crm/published` | 200, 3 entries | **200, `[nav_leads]`** | 200, `[nav_leads]` | | `GET /meta/book/admin_guide/published` | 200 | 403 `PERMISSION_DENIED` | 403 | | `GET /meta/app/payroll` (+ `/published`) | 200 | 403 `PERMISSION_DENIED` | 403 | | `GET /meta/app/launchpad` (unpublished, + `/published`) | 200 | 404 `RESOURCE_NOT_FOUND`, byte-identical to a missing name on this transport | 404 `RESOURCE_NOT_FOUND` | | `GET /meta/dashboard/ops` (+ `/published`, holder too) | 200, both widgets | 200, `[w_open_cases]` | 200, `[w_open_cases]` | | `GET /meta/object/invoice/published` | 200, `[amount, secret_margin]` | 200, `[amount]` | 200, `[amount]` | | `GET /meta/docs/crm_admin_runbook` (plural) | 200 + body | 403 `PERMISSION_DENIED` | 403 | Controls, unchanged: a holder reads the doc body (both doors), the book, and the whole app. `GET /meta/object/invoice` is masked for the member and served whole to the exempt holder. An anonymous caller gets `401 UNAUTHENTICATED` before any read. Through the real `createHonoApp`, at head: non-holder doc / doc `/published` / book → `403 PERMISSION_DENIED` with no secret on the wire; app and app `/published` → `200 [nav_leads]`. Holder: `200` with the doc and book bodies, and all 3 app entries. ## How - **`packages/rest/src/meta-item-read-gate.ts` (new).** `createMetaItemReadGate(sources, metaType, name, documents, policy)` is the former `RestServer#metaItemReadGate` body. The helpers it calls came with it unchanged: `filterAppForUserWithReason`, `filterDashboardForUser`, `resolveDocsAudience`, `resolveAudienceCaller`, the fault-reporting books and doc-corpus reads, `resolveRegisteredServices`, `resolveNavServability`, `resolveNavDocAudience` and `loadObjectItems`. Its verdict is **data** (`serve`, or `refuse` with `absent` / `app-permission` / `docs-audience`). Each transport supplies only I/O (`MetaItemReadGateSources`): the caller, a list read, the security service, a service probe, and a prune-log dedupe set. - **`RestServer`** keeps every private helper name as a one-line delegate, so its list routes, book tree and doors call the same code. `metaItemReadGate` maps the data verdict to the emitters it always used (`sendMetaItemAbsent`, `sendError`, `sendDeclaredFault`). REST's answers are byte-for-byte unchanged: the 196-case door census and the rest of the package's 3,558 tests pass. - **`handleMetadataRequest`** calls the gate on whichever lookup answers the item read and `/published`. The call sits *outside* the lookups' own swallowing `try`s, so a gate fault is answered as that fault and never as "not found". It uses policy `{ arms: 'all', app: 'gate' }`, the same one `RestServer`'s plain read and `/published` use. Refusals use the dispatcher's own envelope with `RestServer`'s status and code. `absent` is the same `deps.error('Not found', 404)` a missing name gets. - **`@objectstack/rest` exports** `createMetaItemReadGate` and its types. This is the same pattern as `repeatedQueryParamMessage`: the decision travels, nothing transport-shaped does. **Also closed in the same claimed branch (bounded in-place fix, all four conditions hold).** The dispatcher's `/published` did not apply the ADR-0106 object mask that its own plain read applies. `GET /meta/object/invoice/published` served `secret_margin` to a member whose readable set is `[amount]` (row above). This is the same defect class: this transport's `/meta` item doors skipping a per-caller read gate that `RestServer` applies. The fix is mechanical: the dispatcher's existing `maskObjectSchema` call, ordered after the gate as in `RestServer`'s `/published`. It is in the claimed `/published` branch and pinned by the same census. The card's own reading assumed "objects are masked here". That held for the plain read and not for `/published`. ## Tests (head `0fcb064a2`) - **Pin:** `packages/runtime/src/domains/meta-item-read-gate-parity.test.ts`, 77 cases. It drives the same fixtures through `dispatch()` and through `RestServer`. For each caller (holder, non-holder, anonymous) it asserts equal `status` + `code` and the same served document (nav ids, widget ids, field names, secrets present or absent) on the plain read and on `/published`, for doc, book, app ×3, dashboard, object and view. It also asserts the reference answers `RestServer` gives, plus five controls. - `pnpm --filter @objectstack/runtime exec vitest run --project local`: **280 files, 3986 passed, 1 skipped**. - `pnpm --filter @objectstack/rest exec vitest run --project local`: **200 files, 3558 passed, 1 skipped**. - `pnpm --filter @objectstack/rest typecheck` and `pnpm --filter @objectstack/runtime typecheck`: exit 0. Both test layers are OK, with the runtime debt ledger unchanged. - The `execctx-consumer-census` counts move by the gate's relocation: 70 → 68 sites and 93 → 92 mentions. Three same-line-caught sites left with the gate, and one caught caller port replaced them. Each number carries its arithmetic in the test. **Ablation.** The fix was committed first. Each leg went through `scripts/ablation-replace.mjs` (WRAP mode, anchor hit 1 → 0, blob changed), with a shell `trap` restore, and each restore was proven by blob equals `HEAD` and an empty `git diff HEAD`. The subject resolves from `src`: `../http-dispatcher.js`, and `@objectstack/rest` is aliased to `src` in the runtime vitest config, so no `dist` leg applies. - Gate leg (`gateMetaItemDocument` serves without judging): **16 red.** These are all 14 non-holder / dashboard parity rows of the before-table plus the plural and unpublished-app controls. The object `/published` row stays green because it is the mask's. - Mask leg (`/published`'s `if (publishedMasker)` disabled): **1 red**, `GET /meta/object/invoice/published × non-holder`. - Restore leg, unmutated: **77/77 green.** The direction was the expected one: red. **Gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 70 commands for these 9 paths. All 70 exited 0 at `0fcb064a2`, and `--ran` reconciled them: 70 derived, 70 run, 0 NOT-MEASURED (a derived zero from recorded exit codes). Also at that head: - `pnpm lint`: full run, exit 0, 142 s. - `node scripts/check-issue-citations.mjs --base origin/main`: exit 0. - These roster and wide-population gates, which read these directories, all exited 0: `check-changeset-fixed`, `check-published-list-mirrors`, `check:error-code-casing`, `check:route-ledger-census`, `check:published-readme-exports`, `check:meta-type-normalized`, `check:init-service-contract`, `check:startup-registry-verdict`, `check:wildcard-fallthrough`, `check:optional-error-sink`, `check:filter-alias-parity`, `check:console-injection`, `check:i18n-stale-fill`. `check:doc-authoring` first went red for a moved string. The nav-prune log line carried `[objectstack-ai#7912]` into the new file. The id now sits in an adjacent `//` comment, and `scripts/doc-authoring-prose-id.baseline.json` shrinks by that one pinned pair (`--census-ledger`, shrink only). ## Patch round 1 (head `acb99baa9`) Contract review `5856229893` answered FAIL on `0fcb064a2`, and the seat's REWORK is `5856245736`. This round makes three changes and merges `origin/main` (`6d38c526f`, then `acb99baa9`). 1. **Liveness ledger re-anchored.** This fixes the `Test Core (1/6)` red: 「8 anchored citation(s) name a symbol the cited file does not contain」. Each pointer was re-measured against the site that now reads the key, and its entry is stamped `verifiedAt: 2026-09-27`. - `app.json` `navigation.requiredPermissions` and `navigation.requiresService` now point at `meta-item-read-gate.ts#filterNav`. - `book.json` `name` now points at `meta-item-read-gate.ts#deriveImplicitPackageBook` (the `bookNamed` lookup). - `doc.json` `name` now points at `meta-item-read-gate.ts#resolveDocAudiences` (`docReader`: `audiences.get(docName)`). `order` and `group` point at `meta-item-read-gate.ts#docCorpusOf`, the list-branch corpus projection. - `doc.json` `description` and `tags` point at `rest-server.ts#readableTree`. The tree route's `{ name, label, description, order, group, tags, packageId }` projection stayed in `rest-server.ts`, and the new file does not name `description` at all: the key-mention check refused that first repoint. So the pointer names the site that reads the key. `tags` also points at `meta-item-read-gate.ts#resolveBookTree`. - Also moved, although they still resolved as text (a docblock mention in `rest-server.ts` satisfied the anchor): - `app.json` `requiredPermissions`, `_unpublished` and the file's `_note` now point at `meta-item-read-gate.ts#filterAppForUserWithReason`; - `book.json` `audience` now points at `meta-item-read-gate.ts#audienceAllows` (`admitsBook`); - `dashboard.json` `widgets.requiresService` now points at `meta-item-read-gate.ts#filterDashboardForUser`. - Left alone: row 18 of `docs/adr/0056-permission-model-landing-verification.md` cites `rest-server.ts#filterAppForUser`. That is still a live declaration (the delegate REST's list route calls), and the file is a governed surface. - Results: `pnpm --filter @objectstack/spec run check:liveness` exit 0 (「758 pointer(s) written `path#symbol`, 758 naming a symbol the cited file contains」). `scripts/liveness/check-liveness.test.ts`: 64 passed, where 21 were red in CI. All `scripts/liveness/` tests: 252 passed (local project) and 81 passed (repo project). 2. **`Clause-②: yes`.** `@objectstack/rest`'s only export subpath gains `createMetaItemReadGate` and five types: `MetaItemReadGateSources`, `MetaItemReadVerdict`, `MetaItemReadRefusal`, `MetaReadGateCaller` and `MetaReadGatePolicy`. - They are public because the runtime dispatcher consumes this one gate, and `@objectstack/rest` cannot import the runtime. - Precedent: objectstack-ai#17672 / PR objectstack-ai#17815. - The changeset now carries the declaration line and names the exports. The bumps are unchanged. - `node scripts/check-adr-0087-registration.mjs --base origin/main`: exit 0. `node scripts/check-changeset-no-major.mjs --base origin/main`: exit 0. 3. **Composed-host pin, committed:** `packages/qa/http-conformance/src/hono-meta-item-read-gate.conformance.test.ts`, 7 cases. It boots a real `LiteKernel` and the real `createHonoApp`, and drives them with `app.request(...)`. - Non-holder: the doc, the doc's `/published` and the set-gated book answer `403`, with `error.code` `PERMISSION_DENIED`, `success: false` and no secret on the wire. The `crm` app answers `200`, pruned to `[nav_leads]`. - The holder control is served all four in full. - **Why this package and not `packages/adapters/hono`:** that package's `vitest.config.ts` aliases `@objectstack/runtime` to a stub (`src/__mocks__/runtime.ts`) for every test. `createHonoApp` imports `HttpDispatcher` from that specifier, so a host composed there would compose the stub. `http-conformance` is where the repo already boots the two for real (`hono-dispatcher-result-response.conformance.test.ts`): `@objectstack/hono` is aliased to source, and the runtime resolves through `dist/`, a ledgered pair in `check-test-source-alias`. - **Per-PR tier:** `@objectstack/http-conformance` is in `turbo ls --affected` for this diff, because it depends on `@objectstack/runtime` and `@objectstack/hono`. `Test Core`'s PR shards therefore collect it; they exclude only `@objectstack/dogfood`. - **Ablation (through `dist/`).** The mutate leg replaced the gate call `verdict = await judge(document);` with a serve-as-stored verdict carrying the marker `ablated20193`, then rebuilt the runtime. `ablation-dist-preflight` found the marker in `dist/index.js` and `dist/index.cjs`. Result: **4 failed | 3 passed**, exactly the four non-holder rows red and the holder controls green. The restore leg put the file back (blob equals `HEAD`, `git diff HEAD` empty), rebuilt, and `--absent` found no marker with a clean tree. Result: **7/7**. Both legs ran at `b58d036ed` and again at `acb99baa9`. **Verification at `acb99baa9`:** - runtime local tests: 281 files, 4016 passed, 1 skipped; - rest local tests: 201 files, 3576 passed, 1 skipped; - `@objectstack/hono`: 5 files, 122 passed; `@objectstack/http-conformance`: 7 files, 96 passed; - both typechecks: exit 0; - `pnpm lint`: exit 0; - `node scripts/check-issue-citations.mjs --base origin/main`: exit 0; - `dispatch-gates --commands` derived 77 commands, all ran, and `--ran` reconciled 77 of 77 with exit codes (0 NOT-MEASURED). ## Changeset `.changeset/20193-dispatcher-meta-read-gate.md`: - **`@objectstack/runtime`: `patch`.** A fix in a released package (Post-Task Checklist step 3). - **`@objectstack/rest`: `minor`.** It adds public exports (`createMetaItemReadGate` and five types), the same bump the changeset for `repeatedQueryParamMessage`'s publication took. REST's own behaviour is unchanged. `Clause-②: yes`: `@objectstack/rest`'s published export surface widens. Its only export subpath gains `createMetaItemReadGate` and five types, which are public because the runtime dispatcher consumes this one gate (precedent objectstack-ai#17672 / PR objectstack-ai#17815). The dispatcher's refusals themselves are not the widening: they pull a second transport back to the gate the contract already declares (ADR-0046 §6.7, ADR-0045 §3, ADR-0106, `apps.mdx`'s `requiredPermissions` row). No accept set widens, and nothing authorable moves. ADR anchor added: `scripts/adr-anchors/packages__rest__src__meta-item-read-gate.ts.json` (ADR-0045, ADR-0046). ## Acceptance notes - **Same family, outside this card's claimed surface: the dispatcher's `/meta/:type` LIST read is ungated too.** Measured through `dispatch()` for the same non-holder at head: - `GET /meta/doc?include=content` → 200, `crm_admin_runbook` **with its body**; - `GET /meta/book` → 200, `admin_guide` with its description; - `GET /meta/app` → 200, `payroll` (app-level `requiredPermissions`) and `crm` with `nav_finance_ledger`. `RestServer`'s list route answers `[crm_intro]`, `[]` and `[crm]` pruned. Fixing it needs `RestServer`'s LIST filters extracted the same way, which is a second seam and not mechanical, so it was filed separately as objectstack-ai#20237 rather than folded in here. objectstack-ai#20237 remains open. - The before-table was taken through `dispatch()`. Through a real `createHonoApp` host, the four rows are now a committed pin (`hono-meta-item-read-gate.conformance.test.ts`). Its ablation leg, with the gate call removed and the runtime rebuilt, is the Hono-level *before* reading: the four non-holder rows go red, and the holder rows stay green. - A host whose `protocol` has no `getMetaItems` now answers a doc or book item read with the fault (fail closed, ADR-0049) instead of the ungated body. `RestServer` has the same requirement. --- _Generated by [Claude Code](https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #17672
Clause-②: yes
GET /api/v1/packages/:id?version=a&version=banswered404. This repo already had a landed answer for exactly that condition on exactly that route —400 VALIDATION_ERRORin the ADR-0112 nested body — and one implementation of it. What blocked PR #17668 from calling it was reachability:@objectstack/restdeclares a single export subpath andquery-multiplicity.tswas not on it.Triage ruled route 1 at
5643017415and refused routes 2 and 3. This branch executes route 1. ⛔ No copy of the rule was made inpackages/runtime, under any framing.1. The repro, driven first — one host, four requests
Before any edit, on the branch point
310760d22, through the realHttpDispatcherand a realSchemaRegistry:The card said the two answers were indistinguishable by status. Measured, they were indistinguishable by
error.codeas well — all three refusals are404 RESOURCE_NOT_FOUND, so a client branching on either field could not separate a request-shape error from a not-found.After, same harness, same four requests:
The full body is the dispatcher's declared envelope,
{ success: false, error: { code: 'VALIDATION_ERROR', message, httpStatus: 400 } }.VALIDATION_ERRORis derived bybuildApiErrorfromstandardErrorCodeForHttpStatus(400), the standard catalog's member for 400. ⛔ Nothing inpackages/specmoves.2.⚠️ Premise ② was measured, and it does not hold in the reading the route-1 wording implies
Triage's named premise: 「导出那两个符号就够,且导出它们不会连带把该模块的内部面一起拉上公开面」.
Half two holds. Built
packages/rest/dist/index.d.tsgains exactly two declarations, both of primitives:SingleQueryRead,readSingleQueryValue,FILTER_SLOT_QUERY_PARAMS,assertFilterParamSuppliedOnceandrepeatedFilterParamMessageare all absent from the published surface (grep over the built.d.ts). The module's internal surface is not dragged along.Half one is where the premise bends. Nothing MORE than those two symbols is needed — so the hard fork's trigger (「若可达性需要导出的不止那两个符号」) does not fire, and this branch does not stop. But only ONE of the two is callable at this door, and the reason is structural rather than stylistic:
refuseRepeatedQueryParamswrites the answer itself,res.status(400).json(…). A dispatcher domain has nores— it RETURNS{ handled, response }, and every error body on that surface is built bybuildApiError. Driven, with the gate given a capturingres:So the gate's body is not a legal body on this wire surface. The message function is the portable half; the gate is not. The door therefore calls
repeatedQueryParamMessageand builds its body throughdeps.error(msg, 400).refuseRepeatedQueryParamsis exported anyway, and that is deliberate rather than accidental. The ruling names both symbols, and narrowing a ruling is a report rather than a dev's decision — so both are published, the barrel entry records which half travels and which does not, and this paragraph hands the question to the at-tier contract review that this PR waits on. If review decides a published symbol with no cross-package consumer should not ship, droppingrefuseRepeatedQueryParamsfrom the barrel is a one-line change that touches nothing else in this diff.Clause-②: yesis ⛔ NOT downgraded:packages/rest's published surface widens on either outcome.3. The answer comes from the shared implementation — a control that can fail
The committed pin asserts the wire text BY DERIVATION (
toBe(repeatedQueryParamMessage('version', 2))), so it cannot fail when the shared sentence moves. The falsifiable control is an ablation, run from the committed state:packages/rest/src/query-multiplicity.ts, replacingthis endpoint will not choose between conflicting values.withABLATION-17672 the shared sentence moved.grep -cof the removed text1 → 0, of the injected text0 → 1;git hash-objectof the pathe6887a55… → 1482e095…, against theHEADblob hashe6887a55….packages/rest's own literal pin,rest-server-query-multiplicity.test.ts:15 failed | 17 passed, exit 1.message="The \"version\" query parameter was supplied 2 times. Supply it at most once — ABLATION-17672 the shared sentence moved.", and a temporary literal assertion on the door went red. ⇒ the door's wire text follows the shared module's source.13 passed, exit 0, which is the behaviour it is written for.git checkout HEAD -- ABSOLUTE-PATHfrom anEXIT INT TERMtrap;git diff HEADfor that path empty afterwards.packages/runtime/vitest.config.tsaliases@objectstack/restto../rest/src/index.ts, so this suite resolves the specifier to SOURCE.scripts/ablation-dist-preflight.mjsis the preflight for the dist-resolved case and does not apply here — the ablation reaching the door with no build is itself the proof that source is the resolved path.4.⚠️ A pin was changed deliberately
packages/runtime/src/domains/packages-get-version-scope.test.ts§4.As written for #17416 it asserted only⚠️ §4 asserts the DEFECT CLASS is closed (no
status !== 200and that no installed row rode out, and its own docblock said the status was left out on purpose: 「200with the installed row), deliberately not the exact status … So this pin stays green when that rule lands here.」404: measured, it did not. It was written loose enough to survive this fix, and it would have stayed green through it. ⛔ That is not a reason to leave it — a door whose contract is400 VALIDATION_ERRORshould have a pin that says so, and a loose pin that survives both answers cannot tell a reader which one is the contract.So §4 now pins the END state: the status, the code, the ADR-0112 nested body, and the message by derivation from
@objectstack/rest's function. §5 is new and pins the card's actual criterion — the three refusals read400 VALIDATION_ERROR/404 RESOURCE_NOT_FOUND/404 RESOURCE_NOT_FOUNDand are mutually distinguishable. ⛔ The404it replaces is not treated as existing contract: it was the interim answer of an unreachable rule.5. ⭐ The one line owed on every branch
packages/rest/src/query-multiplicity.tssaid the dispatcher's/packagesdomain "reads noversion" — false since #17668 landed, and load-bearing, since it is part of why the rule needs only one home. The paragraph now states that the domain does read it, that the rule's home neither moved nor split, and that it serves two doors with one message. The⚠️clause under it records which half is portable across a package boundary, so the next author at a dispatcher domain does not reach for the gate.6. Two published pages, re-read on this branch — one was already right, one is a live condition
A docs-drift advisory on this PR listed 21 pages. ⛔ Its row count is not an instruction to edit anything; two rows actually bear on this diff, and both were re-read here rather than taken on trust.
⭐
content/docs/api/client-sdk.mdxalready documented the end state. Its error table reads:⇒ a published page has been giving
400 VALIDATION_ERRORfor a repeated query parameter while this door answered404 RESOURCE_NOT_FOUNDfor exactly that condition. So this is not only a landed-precedent argument: the door contradicted a page the platform ships. ⛔ Nothing to edit there — the code now agrees with a page that was already correct, which is the opposite of drift.content/docs/kernel/contracts/metadata-service.mdxis a live condition, not a mention. Its route table says ofGET /api/v1/packages/:id: "a missing id answers404 RESOURCE_NOT_FOUND, messagePackage 'ID' not found". That sentence stays true only if the missing-id refusal survives with its exact message. §1 asserts both halves —toBeon the message and onRESOURCE_NOT_FOUND— and the new ordering pin asserts the message again with a repeated?version=riding along, so moving either turns a test red instead of silently falsifying that page. The test comment names the page, so the pin says what it protects. ⛔ No docs edit is owed.⛔⚠️ And one gap carried forward rather than papered over: the advisory reports that
content/docs/releases/**is read-only and untouched.packages/rest/src/index.tsyielded no anchor, so pages documenting what this PR exported there are invisible to that run — "not listed" is not "not affected" for that file, and nothing here should be read as coverage of it.7. Not a breaking change, measured rather than assumed
The
404being replaced was introduced by #17668 (1a25f4a8d).git merge-base --is-ancestor 1a25f4a8d '@objectstack/runtime@17.4.0'exits 1; two control commits from that tag's own history answer exit 0 on the same predicate, in a checkout wheregit rev-parse --is-shallow-repositoryisfalse. ⇒ it has never been published, so no released consumer can have branched on it. Changeset:@objectstack/restminor (published exports),@objectstack/runtimepatch.8. Verification
Run on the final commit,
git rev-parse --short HEAD=2969466d0— the branch is4f530786e(a merge oforigin/main43df8db3a, which touches only CI workflow and script paths, none overlapping this diff) plus one comment-only commit naming the two pages in §6. The whole table below was re-run on2969466d0, ⛔ not carried over from the earlier head.pnpm --filter @objectstack/rest testpnpm --filter @objectstack/runtime testpnpm --filter @objectstack/runtime test:repopnpm --filter @objectstack/rest --filter @objectstack/runtime typecheckcheck:test-typecheckpnpm --filter '@objectstack/runtime^...' build+ both changed packagespnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'pnpm lint(repo-wide,eslint . --no-inline-config)scripts/pm/dispatch-gates.mjs)2969466d0(family set identical), reconciled with--rancarrying an exit code per family, all 0On the earlier head two gates first answered
exit 3—PREREQUISITE NOT MET, which each states in its own text is ⛔ not a pass and not a finding.check:dual-build-cjs-loadsneeded a whole-workspacedistand went to 0 after the full build;check:type-check-debtOOM'd under aNODE_OPTIONSheap cap tighter than the one it declares for itself, and went to 0 re-run without it. Both are plain 0 in the2969466d0sweep above (5 ledger entries re-measured, 55 raw tsc errors, none above its recorded number). Also run, because the derivation flags their rosters as sitting under a changed path:check:error-status-conformance,check:error-code-casing,check:route-ledger-census,check:authz-resolver,check:published-readme-exports— all exit 0.Acceptance notes
Filed — #17813:
metadata-protocol'srepeatedQueryParamErrordocblock says its wording ispackages/rest's "verbatim so a caller … is told the same thing twice", and driven, the two sentences differ at the first quoted character (The "top"vsThe 'top'). Found because this card made the message importable for the first time; ⛔ not fixable here —@objectstack/metadata-protocoldoes not depend on@objectstack/restin either direction that would allow the import, so it is a layering decision. That card is not addressed by this PR and remains open.noted, not filed — the multiplicity check sits AFTER the id lookup, so
GET /packages/UNKNOWN-ID?version=a&version=bstill answers404 Package '…' not foundrather than400. That is #17416's deliberate ordering ("only a package that IS here can be at the wrong version") and this card moved the status of a refusal, not the order of two refusals. It is now pinned explicitly in §1 rather than left incidental, so a future re-ordering is a visible decision. Successor: the contract review on this PR, or whoever converts the/packageslane to a closed parameter set — which is #17667's territory, currentlyneeds-user-decision.noted, not filed —
readRequestedVersionin the domain still implements the count-not-shape predicate thatreadSingleQueryValueimplements inpackages/rest(length > 1refuses,length === 1unwraps,length === 0is absent). Importing the predicate too would be a third exported symbol plus its result type, which is exactly the widening the fork instruction guards; the door's copy also carries thelatestsentinel, which is domain semantics the shared reader has no business knowing. Left as is, with the shared MESSAGE — the drift surface the module's header actually names — imported. Successor: none identified; this is a note for the reviewer of this diff and nothing else is queued against these lines.Generated by Claude Code