fix(rest, runtime): the dispatcher's /meta reads answer what RestServer's answer (#20320) - #20404
Conversation
…er answers — one list chain, one public-audience predicate, the ?state=draft read WIP: shared chain + dispatcher wiring + census; tests and ablations follow. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…state=draft row move Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…parity Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…spatcher-meta-list-parity
…aRequest; drop two citations that no longer resolve Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…y header as its second hand-built response Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…rdict it returns Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 38 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 33 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 49231f28901d87c914432118c2ff784a8f9e0f84 && git checkout 49231f28901d87c914432118c2ff784a8f9e0f84
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dcd3bceaa068fc3cfb589bd6e04cd0b89b660580 07671d4d40e46a961327dc6d41b9ee7a01bea921 && git checkout -B drift-repro dcd3bceaa068fc3cfb589bd6e04cd0b89b660580 && git merge --no-ff 07671d4d40e46a961327dc6d41b9ee7a01bea921
node scripts/docs-audit/affected-docs.mjs --json dcd3bceaa068fc3cfb589bd6e04cd0b89b660580
|
Contract reviewServed-tier: ① Derived judgments
② Semver levelWrong: ③ Boundary flags
Implemented-by: Independence: INDEPENDENT AGENT (fed the card, the triage direction and the PR only; not the dispatch order or the seat's conclusions) VERDICT: FAIL
Generated by Claude Code |
…spatcher-meta-list-parity
…the root entry gains the shared chain's exports Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
Contract review (delta, patch round 1)Served-tier: ① Derived judgments
② Semver levelRight. ③ Boundary flags
Implemented-by: Independence: INDEPENDENT AGENT (fed the card, the triage direction and the PR only; not the dispatch order or the seat's conclusions) VERDICT: PASS Generated by Claude Code |
…efused as /meta/_drafts refuses (objectstack-ai#20378) (objectstack-ai#20440) Fixes objectstack-ai#20378 Clause-②: no `GET /api/v1/meta/:type/:name/diff` and `GET /api/v1/meta/:type/:name/history` are now authoring doors. A caller that `mayReadPendingDrafts` does not admit is refused as `GET /api/v1/meta/_drafts` refuses: `403`, code `FORBIDDEN`, in the same nested `error` envelope. The decision is made on the caller before the protocol is resolved, before the query is parsed, and before any item, event or version is read. This executes ruling `5865708652` on the card (letter B, the maintainer's 「同意」 through the director seat). That ruling narrows item 2 of ruling B on objectstack-ai#20156 (`5856774816`) for these two doors only. ## Why Both doors read `sys_metadata_history`, the authoring commit log (ADR-0067). A draft save appends a row there exactly as an active save does, and nothing on the row says which kind it was. A member with no authoring capability who could open an item could therefore read two things: - its pending draft through `/diff`, by naming the draft save's version in `from`/`to`, or through the default range once a draft is pending; - its draft-save events through `/history`. ADR-0106 D4 says 「draft/preview reads are admin-gated upstream already」. The version store has no published-only answer to fall back to, so these two doors take the `/meta/_drafts` shape (refuse). They do not take the draft switches' shape (answer as if the switch were absent). ## What changed - **`packages/rest/src/rest-server.ts`: a guard at the head of each handler.** Each door resolves its caller once (`resolveExecCtx`, memoised per request) and asks `mayReadPendingDrafts`. That is the one predicate `/meta/_drafts` and every draft switch already ask, so there is no second rule. The org partition further down reuses the same resolved caller. Callers it admits read exactly what they read before, including the `objectstack-ai#20156` per-caller gate and the author exemption on `/diff`. - **What the refusal carries.** It has no item name, version or event. Its message names the door ("version history" or "stored versions"), never drafts, so the answer is the same for a published item, a draft-only item and a name with nothing behind it. The door is not an existence oracle. - **Unchanged:** `/layers`, `?layers=true` and `/audit`. - **`packages/rest/src/meta-history-diff-authoring-door.test.ts` (new).** It boots the real stack as `meta-draft-read-builder-gate.test.ts` does: better-sqlite3 in memory, the real `sys_metadata*` objects, a real `ObjectStackProtocolImplementation` and the real routes. The only stubs are `resolveExecCtx` and the `tenancy` service probe. It pins four things: - For `app` and `view` on both doors, a member without an authoring capability gets `403 FORBIDDEN`. The envelope keys equal those of the member's own `/meta/_drafts` answer. The answers for a published item, a draft-only item and a missing name are byte-identical. No protocol read is reached: spies on `getMetaItem`, `getMetaItemLayered`, `historyMetaItem` and `diffMetaItem` stay uncalled, and a builder call on the same door proves the spies are live. - A draft-save range, the default range and an unparseable bound all get the member the same refusal. The unparseable bound is answered `400` to a builder, which shows the member's refusal is decided before the query parse. - Every builder (`studio.access`, `setup.access`, `manage_metadata`) reads both doors as before, with author-whole versus pruned on `/diff` for `app`. - The lit control: the member still reads `/layers` and `?layers=true`, and gets the active row pruned as the plain read prunes it. - **`packages/rest/src/meta-alternate-door-read-gates.test.ts`, the `objectstack-ai#20156` census.** - The `/diff` and `/history` rows gain `authoring: true`. For a caller that `/meta/_drafts` refuses, each census cell asserts `403 FORBIDDEN`, asserts that no secret appears in the answer, and asserts that `getMetaItem`, `diffMetaItem` and `historyMetaItem` were never called. - The presenter edge now expects the refusal on `/diff`. - The two edges that ask what an admitted caller sees on `/diff` and `/history` now drive the admitted `reader` caller. - The `/layers` and `?layers=true` rows are untouched. - **Docs.** In `content/docs/api/client-sdk.mdx`, one line beside the `client.meta.diffItem` example states the authoring-only rule. In `content/docs/ui/apps.mdx`, the paragraph that told every other caller they read `/diff` pruned now states that `/diff` and `/history` need the capability outright. - **Changeset:** `@objectstack/rest` `patch`, `Clause-②: no`. It pulls the declared contract (ADR-0106 D4) back in. ## Takeover of pushed work A previous dev pushed this branch (`5a09278bad` the guard, `1f4a1faf06` the pins, docs and changeset, `24c384d8d3` a merge). Its session ended before a PR or report. Every hunk was re-read against the card and the ruling. All were kept but one: - `meta-history-diff-authoring-door.test.ts` had a TS2345. The inferred union of the three `/diff` query literals is not assignable to the helper's `Record` type, and `check:test-typecheck` was red on a file its ledger does not cover. `db05a9e270` fixes it. `origin/main` was merged twice: `f4c601e889`, then `c0675573df`. The second merge carries the landing of objectstack-ai#20404 on the same file. That PR moved the list chain, `isPublicAudienceRead` and the item read, and touched neither handler here. After the merge both guards still sit at the head of their handlers, and the branch's delta against `main` is the same six files. The runtime dispatcher still serves neither `/history` nor `/diff`. ## Verification (all at head `c0675573df`) - Build: `pnpm --workspace-concurrency=2 --filter '@objectstack/rest^...' build` exited 0. `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2` exited 0 with 71/71 tasks, which the whole-tree gates need. - `pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2`: 215 files passed; 3904 tests passed and 26 skipped. - `pnpm --filter @objectstack/rest exec vitest run --project repo --maxWorkers=2`: 1 file passed, 8 tests passed. - `pnpm --filter @objectstack/rest typecheck` exited 0: `check:test-typecheck: OK`, with 0 files in the debt ledger. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 91 commands. All 91 were run and each exited 0. `--ran` reports: `91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN`. - `pnpm lint`, the whole repository, exited 0 in 37s. - `node scripts/check-issue-citations.mjs --base origin/main` exited 0: 7 citations, all resolve. **Ablation, per door.** The source is imported relatively (`./rest-server.js`), so no `dist` sits between the mutation and the tests. Each run went through `scripts/ablation-replace.mjs`, whose anchor must hit (1 to 0). The mutation replaced the door's guard with `if (false && !mayReadPendingDrafts(...))` and then ran both test files. The restore was proven: the blob is `e0f7a215dbe0`, equal to `HEAD`, and `git diff HEAD` is empty. | guard removed | red | green | |:--|:--|:--| | `/diff` | 14 of 310, every one a `/diff` refusal pin: the 9 census cells for a caller that may not read drafts, the presenter edge, the member pins for `app` and `view`, the range pin, and the one-predicate pin | every builder pin, every `/history` pin, both `/layers` controls | | `/history` | 13 of 310, every one a `/history` refusal pin: 9 census cells, the member pins for `app` and `view`, the `limit` pin, and the one-predicate pin | every builder pin, every `/diff` pin, both `/layers` controls | ## Acceptance notes - **The refusal message.** It is the one byte-level difference from `/meta/_drafts`. The status, the code and the envelope's key set are identical and pinned. The message names the door rather than drafts, because a refusal worded about drafts would read as "this item has one". It is not pinned, since no consumer parses it. - **`/audit` is outside this change.** The ruling names `/diff` and `/history` only. `/audit` serves `sys_metadata_audit` rows (actor, time, operation, outcome, no bodies). Whether a draft save writes an audit row that a member then reads was not measured; this note is read at source only. Carrier: none. - **objectui at the pin `f8a9d0fb05`.** - `/history` is consumed by `MetadataResourceHistoryPage`, on the metadata-designer routes, an authoring surface. - `MetadataClient.diff` has no caller. - A caller without an authoring capability who opens that route now receives the `403`. - **The sibling card is separate.** The mislabelled default `/diff` range (objectstack-ai#20397) is not addressed here and proceeds unchanged. ## Declared narrowing: the verify lock `scripts/pm/os-verify-lock.sh` printed this for every build, test and ablation above (this host is macOS): **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 --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 The same disclosure was printed for each of the other wrapped commands: the two builds, the `repo` project run, the typecheck and the two ablation runs. --- _Generated by [Claude Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com>
…organization, and its item read, book tree and list answer what RestServer answers (objectstack-ai#20408) (objectstack-ai#20473) Fixes objectstack-ai#20408 Clause-②: yes The runtime dispatcher's `/meta` doors now answer what `RestServer`'s answer, for the item read, the book-tree route and the list, and they scope a caller to the same organization. A host that mounts only the `${prefix}/*` catch-all serves `/meta` through the dispatcher: `createHonoApp`, or any adapter written on the public `HttpDispatcher` API. ADR-0076 item 9 keeps that catch-all as the fallback. Triage's direction was to extend the shared seam (AGENTS.md 〈Route & surface ownership〉 rule 1: one implementation, two transports). This PR extends the seam PR objectstack-ai#20404 built in `packages/rest/src/meta-item-read-gate.ts`, and builds no second one. ## First: the organization source was a cross-organization data-scope defect (H0, measured) The card's first read-at-source item. Triage said a cross-org difference outranks the six rows, and it does differ. - **The dispatcher read the session claim as stored.** Every dispatcher `/meta` door took its organization from `deps.resolveActiveOrganizationId`, which returns the auth session's `activeOrganizationId` unchanged. - **`RestServer` reads the vetted value.** It reads `ctx.tenantId` off the execution context. `resolveAuthzContext` vets that value: under a wall-enforcing posture, it DROPS a claim naming an organization the caller no longer belongs to. - **So a removed member kept the left organization's partition on the dispatcher**, for the rest of the session. Measured through `dispatch()` against `RestServer`. Both run the REAL identity resolution (`resolveExecutionContext` / `computeExecCtx` → `resolveAuthzContext`) under an `isolated` posture. The subject is `u_exmember`: the session is stamped `org_alpha`, and the only `sys_member` row is `org_beta`. Both principals hold one shared permission set, so only the organization claim separates the arms. On `b28550818`: | door (as the ex-member) | dispatcher | `RestServer` | |:--|:--|:--| | `GET /meta/view/lead_all` | `Alpha pipeline` (org_alpha's overlay) | `All leads` (env-wide) | | `GET /meta/view` | `Alpha pipeline` | `All leads` | | `GET /meta/view/lead_all?preview=draft` | `Alpha pipeline` | `All leads` | | `GET /meta/view/lead_all/published` | `Alpha pipeline` | `All leads` | | `GET /meta/view/lead_all?state=draft` | `200`, org_alpha's pending draft | `404 NO_DRAFT` | | `GET /meta/_drafts` | `['alpha_board']` | `['env_board']` | | `PUT /meta/view/lead_all` (manage_metadata) | write lands in `org_alpha` | write lands env-wide | The last row is a WRITE into the left organization's partition. - **Controls, green on both transports:** a current member reads its own organization, the double gates a non-overridable type's phantom row, and the ex-member switched to `org_beta` reads `org_beta`. - **The fix, in the seam:** - `metaCallerOrganizationId(caller)` answers the vetted `tenantId`. - `metaReadOrganizationId(type, caller)` answers `organizationIdForMetaRead` over the folded type and that value. - `RestServer`'s list and item reads ask the second, and so does every dispatcher `/meta` read. - The dispatcher's `PUT`, `_drafts` and `/published` take the first. That is what `RestServer`'s twins hand down (`ctx.tenantId`). - `meta.ts` no longer calls `deps.resolveActiveOrganizationId`. - **Pinned** in `packages/runtime/src/domains/meta-read-org-scope-parity.test.ts`: 11 tests, 7 red at the base and all green on the fix. - **The write door is a bounded in-place fix.** The read doors are the card's scope; the `PUT` row goes beyond it, and all four conditions hold: - the same defect class: the same source, the same file; - a mechanical, pinned shape; - `meta.ts` is this claim's file; - the same gate families. The pin's `PUT` row is its evidence: `['org_alpha']` against `[undefined]` at the base, equal on the fix. ## The six rows, and what the census found beside them Each row was re-measured first, and every one still reproduced on `b28550818`. The census in `meta-list-projection-parity.test.ts` now has item, book-tree and cache-posture blocks. Like the list block, each is derived from `RestServer`'s handler: the query parameters it reads and the type literals it keys on, plus the shared functions it calls. | # | row | before, on the census fixtures | |:--|:--|:--| | 1 | unknown type (`GET /meta/totally_invented_type`) | 33 list cells: `200 []` against `400 INVALID_REQUEST` | | 2 | `?preview=DRAFT` | 6 list cells, plus 2 item cells (`404` against `200`) and 4 item body cells | | 3 | item translation | 48 item cells | | 4 | doc item locale | 84 item cells (the `translations` map kept, no collapse) | | 5 | `GET /meta/book/:name/tree` | 64 of 80 tree cells (`404 ROUTE_NOT_FOUND` against `200`/`403`; `401` against `200` for an anonymous reader of the `public` book) | | 6 | object `?preview=draft` | 8 item cells (the active schema) | | H1 | `Cache-Control` on an undetermined posture | 20 list cells, 16 item cells | | new | item `Vary: Accept-Language` | 400 item cells (header only) | | new | object `sortability` | 64 item cells | The last two rows are same-family divergences the item census found that the card does not list. The dispatcher's item answer carried no `Vary`, and an object schema came with no `sortability` (objectstack-ai#10235). The item chain closes both by construction. H1 was measured, and it differed: the list, the item read, `/published` and the legacy one-segment object read all served an undetermined posture's unmasked schema with no `Cache-Control`. ## What changed - **The item read is one chain, `createMetaItemAnswer`.** Everything `RestServer`'s `GET /meta/:type/:name` does after the store read moved there, unchanged: 1. absence (objectstack-ai#18066, before the gate); 2. THE item gate under the door's policy; 3. the ADR-0046 doc locale collapse; 4. the ADR-0106 mask, under the posture resolved before the fetch; 5. the body, `translateMetaEnvelope`: the translation, and `sortability` beside an object schema. `RestServer`'s uncached arm calls the chain. So does every exit of the dispatcher's item read: the object branch, the generic branch, the `MetadataService` fallback and the `?state=draft` read. `RestServer.translateMetaItem` and `translateMetaEnvelope` delegate to the new `translateMetaDocument` and `translateMetaEnvelope`. The cached arm keeps calling them. - **Row 6:** an admitted `?preview=draft` makes the dispatcher's object branch ask the protocol first, as a scoped kernel always did. That protocol read now carries the request `RestServer` sends: `?package=` and the switch. - **Row 2:** both `?preview=` declarations in `meta.ts` parse the value case-insensitively, as `RestServer`'s do. The draft-door ledger in `meta-draft-read-builder-gate.test.ts` now names the new spelling. - **Row 1:** `refuseUnknownMetaListType` moved into the seam, unchanged, docblock included. `RestServer`'s private method is a one-line delegate. The dispatcher's list branch asks it before any listing work. It fails open when the live type listing cannot be read, so a host with no `getMetaTypes` keeps the legacy one-segment object read. - **Row 5:** the tree route's whole handler moved into `createMetaBookTreeAnswer`: the reads, THE `DocsAudience`, the §6.7 gate, the locale collapse and the narrowed tree. The dispatcher serves `GET /meta/book/:name/tree`, with the type segment literal as on `RestServer`, and `metaReadRouteOf` names it `book-tree` for the shared `isPublicAudienceRead`. `RestServer`'s two tree-only delegates (`audienceBooksOf`, `resolveDocsAudience`) went with it. - **H1:** the list chain applies the object mask itself. - `MetaListAnswerSources.maskObjects` became `resolveObjectMasker`. That port is new in this same release, with `createMetaListAnswer`. - The shared `projectMetaObjectSchema` projects each schema, so both transports make one decision. - `MetaListAnswer` reports `cacheControl`, and `RestServer`'s list writes it from the answer; its port used to write it. - The dispatcher's other mask exits (`/published`, the legacy read) use the same projection. One helper in `meta.ts` (`successWithHeaders`) carries the headers, so `check:route-envelope`'s `handBuilt: 2` is unchanged. `RestServer`'s answers are unchanged: every existing REST test passes unedited. **Runtime pins that moved, and why:** - **The census control's unrouted book path.** It was `/meta/book/public_guide/tree`, now `/meta/books/public_guide/tree`: the singular spelling is a route here now. - **The draft-door ledger:** the `?preview=` site spelling. - **`meta-write-org-scope.test.ts` and `meta-save-capability-gate.test.ts`.** Their execution context carried no `tenantId` while their auth session named an organization. That pairing is exactly the "dropped claim" state, which only the old raw-claim source read as org-scoped. Each now hands the organization on the execution context, as the real resolver does with no wall. 11 cases went red, and none of their assertions changed. ## Evidence - **Red first.** The census and pins at `410141ec34`, on base sources: census `259 failed | 247 passed (506)`, H0 `7 failed | 4 passed (11)`. - **Reverse verification.** The final tests, run against the BASE sources (the four source files restored from `b28550818` into the tree only): `267 failed | 263 passed (530)` across the census, H0 and ledger files. Restored with `git checkout HEAD --`, with proof: each blob equals HEAD's, and `git diff HEAD` is empty. - **Ablations** at `7903048a75` go through `scripts/ablation-replace.mjs`: the anchor hit once, and each mutation landed and was restored with blob == HEAD `3519d199a54c` and an empty `git diff HEAD`. The subject resolves through relative imports and the runtime vitest alias to `packages/rest/src`, so no `dist` was involved. Suite: the census plus the H0 pins, 517 tests. | ablation | predicted | measured | |:--|:--|:--| | (A) the dispatcher skips `refuseUnknownMetaListType` | exactly the row-1 cells | `11 failed \| 506 passed`: the 11 `totally_invented_type` tests, nothing else | | (B) the dispatcher's item chain bypasses `translateMetaEnvelope` | the translation and `sortability` cells | `40 failed \| 477 passed`: `object`/`objects` invoice 16 each, and the zh-CN cells of `app/crm`, `apps/crm`, `app/helpdesk`, `page/home` | | (C) the dispatcher's item read threads the raw claim again | the H0 item-read cells | `2 failed \| 515 passed`: the item read and its `?preview=draft` row | ## Gates, all on head `cf85a44ad5` (after merging `origin/main` at `e956924e1`) - **`pnpm --filter @objectstack/rest test`:** 216 files passed; `3912 passed | 26 skipped`. `test:repo`: 1 file, `8 passed`. - **`pnpm --filter @objectstack/runtime test`:** 284 files passed; `4084 passed | 1 skipped`. `test:repo`: 3 files, `575 passed`, the census included. - **`pnpm --filter @objectstack/rest --filter @objectstack/runtime typecheck`:** exit 0, and `check:test-typecheck` is OK on both. - **`pnpm lint`** (`eslint . --no-inline-config`, the whole repo): exit 0. - **`node scripts/check-issue-citations.mjs --base origin/main`:** exit 0. The first read was unauthenticated and answered `403` / exit 3 (PREREQUISITE NOT MET), so it was re-run with an authenticated read. - **`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`:** 62 commands derived, every one run on this head, all exit 0. `check:dual-build-cjs-loads` first needed 8 missing `dist/`s built. `--ran` answers `62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero …)`. - **Consumers the dispatcher's wire change reaches:** `@objectstack/hono` 5 files / 122 tests; `@objectstack/http-conformance` 8 files / 102; `@objectstack/client` `client.hono`, `client-url-conformance` and `meta-delete-item-carriers`, 27; and six `/meta` dogfood files, 72 (`meta-published-and-state-routes`, `route-ledger-live-mount-parity`, `showcase-anonymous-deny-surfaces`, `showcase-object-extension-meta-read`, `dashboard-designer-roundtrip`, `meta-types-create-seed`). All green. **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 (the runtime closure, the rest package, the consumer closures), pnpm --filter @objectstack/rest test / test:repo, pnpm --filter @objectstack/runtime test / test:repo, pnpm --filter @objectstack/rest --filter @objectstack/runtime typecheck, pnpm lint, pnpm --filter @objectstack/hono --filter @objectstack/http-conformance test, the client and dogfood file runs ## Acceptance notes - **Out of scope, measured, reported (class a): `?layers=` on the dispatcher's item read.** `GET /meta/app/crm?layers=true` through `dispatch()` answers `200 {type, name, item}`, the plain read. `RestServer` answers the three-layer `{type, name, code, overlay, effective}` with `Deprecation: true`. The dispatcher serves no layered view at all: `/meta/app/crm/layers` answers a located `404 ROUTE_NOT_FOUND`, which is loud. The flag, though, is silently a different representation. The item census names `layers` as its one declared exclusion (`ITEM_PARAMS_NOT_SERVED_HERE`), so every other new parameter still reddens it. - **Out of scope, read at source (possible data leak): the same raw-claim source elsewhere in the dispatcher.** `domains/packages.ts` calls `deps.resolveActiveOrganizationId` at 9 sites (publish-drafts, commits, uninstall, revert, duplicate-adopt, the export sweep). Not measured here. Reading `executionContext.tenantId` in `HttpDispatcher.resolveActiveOrganizationId` itself would close the class for every domain. That is `http-dispatcher.ts`, outside this claim. - **Not measured.** The object branch's protocol read now threads `?package=` as `RestServer`'s does. The census double does not discriminate packages on item reads, so no cell moves on it. - **A stale note, not a count.** `scripts/check-route-envelope.mjs`'s `meta.ts` ledger note still describes the second hand-built site as "the /meta/:type list answer". It is now `successWithHeaders`, which every `/meta` read answer that owes a header goes through. The count (2) holds, and the gate is green. The script is not in this claim; its next editor carries it. - **Repeated query parameters are unchanged here and not measured by this PR:** `RestServer`'s item, list and tree handlers refuse a repeated single-valued parameter (`refuseRepeatedQueryParams`), and the dispatcher's `/meta` domain has no such gate. PR objectstack-ai#20404 recorded the list half. --- _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>
Fixes #20320
Clause-②: yes (widening)
The runtime dispatcher's
/metareads now give the same answers asRestServer's. A host that mounts only the${prefix}/*catch-all (createHonoApp, or any adapter on the publicHttpDispatcherAPI) serves/metathrough the dispatcher. Triage's direction was to extend the shared seam (AGENTS.md 〈Route & surface ownership〉 rule 1: one implementation, two transports). ADR-0076 item 9 keeps the catch-all as the fallback.What changed
Row A: one list chain. Everything
RestServer'sGET /meta/:typedoes to a list after the store read moved, unchanged, intocreateMetaListAnswerinpackages/rest/src/meta-item-read-gate.ts, besidecreateMetaListReadGate. The steps, inRestServer's order:api行在 /meta/api 与 /openapi.json 里在场,匹配器却永远看不见(真实 boot 实测) #5224apiserved-set face;?id=for apps;?object=for views;RestServer's handler keeps its entry: the repeated-parameter refusal, the unknown-type refusal and the admittedpreviewDraftsdeclaration from #20338, still ONE declaration ahead of every read of it. It then calls the chain. Every exit of the dispatcher's list branch (protocol,MetadataService, ObjectQL registry) calls the same chain. The dispatcher's ownslimDocListis gone. Its list answer now carriesVary: Accept-Language, asRestServer's does.The chain needs the locale parse and the translation helpers, so those moved too.
RestServer's private methodsextractLocale,buildTranslationBundle,translateOptionsFor,packagedObjectBase,translateMetaItemsand the module-levelisTranslatableMetaTypeare now one-line delegates to them. Nothing inmeta.tsis a copy.Row B: one public-audience predicate.
isPublicAudienceReadmoved out ofRestServer. It now takes a method, a route shape and the raw:type.RestServer's static maps its registered path onto a shape and delegates. The dispatcher's anonymous gate athandleMetadataRequest's entry asks the same predicate with its own shapes:listfor one segment,itemfor two. The dispatcher has no/book/:name/treeroute; that path falls to the locatedROUTE_NOT_FOUNDtail. So the dispatcher never namesbook-tree, and that path, like/published, keeps the deny. The legacy one-segment object-name exit answers the anonymous deny, so the exemption can never reach an object schema.The
?state=draftrow. The dispatcher's item read declares?state=draftnext to?preview=draft, each with itsmayReadPendingDraftsadmission, above every branch. It parses the parameter asRestServerdoes. An admitted caller gets the protocol's draft read for any type, the object branch included:404 NO_DRAFTanswered as itself when nothing is pending;STORED_VERSION_DOOR_POLICY. The constant moved to the shared module;RestServerkeeps its static name as an alias.mayWriteItemcomes from the dispatcher's own save-door admission. That admission is now spelled once, as a local inhandleMetadataRequest, and thePUTbranch uses it too.A caller the predicate does not admit gets the plain read, byte for byte.
Add-on. The cached arm's
[#9741] Typed requestcomment now says the branch is unreachable for an ADMITTED switch. The query parameter itself still reaches it.Gate ledger.
scripts/check-route-envelope.mjsdeclaresmeta.tshandBuilt: 2(was 1), with a note. The dispatcher's list answer must carryVary: Accept-Language, anddeps.successtakes no headers.What a dispatcher-only host answered before (measured on
b1cbd9277)The census drives
dispatch()andRestServerover the same fixtures:?state=draftcells.List cells, by cause, before the fix:
Varyheader only401vs200)?id=crmlisted every visible app.?object=leadlisted every view./meta/docsserved bodies. Every doc list kepttranslations, with no locale collapse./meta/apilisted the unserved declaration.publicbook and doc lists and items answered401.?state=draftanswered the active item: 10 cells, builder and author.Evidence
198 failed | 13 passed (211). On the fix:211 passed (211).pnpm --filter @objectstack/rest test:211 passedfiles,3831 passed | 2 skippedtests, with every existing REST test unedited.test:repo:8 passed.pnpm --filter @objectstack/runtime test:283 passedfiles,4073 passed | 1 skippedtests.test:repo:280 passed, the census included.?state=draftsite.scripts/ablation-replace.mjs, and restored with the restore proven: blob equal to HEAD, emptygit diff HEAD, zero markers left. The subject resolves through the runtime vitest alias topackages/rest/src, so nodistwas involved. The suites run: the census andmeta-list-read-gate-parity.test.ts, 251 tests.19 failed. The direction was NOT the predicted one. The 720 parity cells stayed green, because both transports call the one chain and move together; that is what one implementation means. The reference pins went red: the reference-moves pin, 4 row-B list cells, and 14 [finding] the runtime dispatcher's /meta LIST reads apply no per-caller gate: through a catch-all host, GET /meta/doc?include=content lists a set-gated doc body, /meta/book lists a set-gated book, /meta/app lists gated apps and entries #20237 reference and gate cells. The first attempt was refused by the tool (its replacement contained its anchor) and ran nothing; the second attempt is the measurement.27 failed. 20 census cells went red (?id=4,?object=4,?include=content2, translation 10), plus 7 [finding] the runtime dispatcher's /meta LIST reads apply no per-caller gate: through a catch-all host, GET /meta/doc?include=content lists a set-gated doc body, /meta/book lists a set-gated book, /meta/app lists gated apps and entries #20237 cells. This is the ablation that shows the parity cells can fail.isPublicAudienceRead:49 failed. 40 anonymous book and doc census cells, 8 row-B cells and 1 [finding] the runtime dispatcher's /meta LIST reads apply no per-caller gate: through a catch-all host, GET /meta/doc?include=content lists a set-gated doc body, /meta/book lists a set-gated book, /meta/app lists gated apps and entries #20237 cell went red. The401controls stayed green.Gates, all on head
64a05bb97(after mergingorigin/mainat15bf186f5)pnpm --filter @objectstack/rest test: 213 files passed;3886 passed | 26 skipped.test:repo: 1 file,8 passed.pnpm --filter @objectstack/runtime test: 283 files passed;4073 passed | 1 skipped.test:repo: 3 files,280 passed.pnpm --filter @objectstack/rest typecheckandpnpm --filter @objectstack/runtime typecheck: exit 0,check:test-typecheckOK on both.pnpm lint: exit 0, the whole repo, 133s.node scripts/check-issue-citations.mjs --base origin/main: exit 0.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands: 81 commands derived. 80 were run on this head, each exit recorded, and all exit 0 except:pnpm check:dual-build-cjs-loads, exit 3: NOT MEASURED,PREREQUISITE NOT MET, because 36 packages have nodist/in this worktree.pnpm check:pm-dispatch-gates, exit 124: NOT MEASURED, because its self-test alone ran past a 580s foreground cap. The diff touches noscripts/pm/file; the family comes from thescripts/path ofcheck-route-envelope.mjs.pnpm check:dts-closurefirst answered 1 on tree state:client,organizationsandverifyhad adist/without.d.ts, and this diff touches none of them. After rebuilding those three it answers 0; that is the recorded reading.dispatch-gates --rananswers81 derived famil(ies) accounted for — 79 run, 2 NOT-MEASURED(the two named above), exit 0.check-route-envelope.mjsedited: the script has no test file of its own, and no test executes it. Its--self-testpasses insidepnpm check:route-envelope.Acceptance notes
/metaanswering differently fromRestServer. Each is measured throughdispatch()on the fixtures above.GET /meta/totally_invented_typeanswers200 []whereRestServeranswers400 INVALID_REQUEST(theGET /api/v1/meta/<unknown-type>answers 200 with an empty collection while the write door refuses the same type #9488 refusal is REST-only).?preview=DRAFTfrom a builder: the dispatcher compares case-sensitively and lists the published world, whereRestServeroverlays the drafts.GET /meta/app/crmwithAccept-Language: zh-CNanswersCRMagainst客户管理.translations, where REST answers入门./meta/book/:name/treeroute:404 ROUTE_NOT_FOUNDto an authenticated caller, whereRestServerserves the tree.?preview=draft: a builder readsInvoice, where REST answersInvoice (draft).resolveActiveOrganizationIdfor every type.RestServerthreadsorganizationIdForMetaReadover the folded type, and only for org-overridable types.Cache-Control: private, no-storeon an undetermined posture.RestServer's list does.RestServerrefuses a repeated?id=with400. The Hono catch-all flattens the query to last-wins beforedispatch(), so the dispatcher never sees a repeat. That is the adapter's seam, not this domain's.content/docs/permissions/system-context.mdxrow 49 lists/layers,?layers=trueand/diffas the doors where a caller the save verdict admits reads an app's full stored version. It does not list?state=draft: not on REST since [finding] an app author's draft baseline is read through the pruned plain read (GET /meta/app/:name?state=draft), and the designers merge it over the whole stored app, so a draft save drops the navigation entries withheld from that author #20290, and not on the dispatcher after this PR.@objectstack/restgains the exportscreateMetaListAnswer,translateMetaList,metaRequestLocale,isPublicAudienceRead,STORED_VERSION_DOOR_POLICYand their types. Nothing is removed. That is a widening of its published surface, so the changeset declares@objectstack/restminorwithClause-②: yes (widening);@objectstack/runtimestayspatch.Generated by Claude Code