Commit 5c7aa46
Fixes #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 #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` (#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 (#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 #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>
1 parent 9801da1 commit 5c7aa46
11 files changed
Lines changed: 1870 additions & 639 deletions
File tree
- .changeset
- packages
- rest/src
- runtime/src
- domains
- spec/liveness
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
93 | | - | |
| 93 | + | |
94 | 94 | | |
95 | 95 | | |
96 | 96 | | |
97 | 97 | | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
98 | 109 | | |
| 110 | + | |
| 111 | + | |
99 | 112 | | |
100 | 113 | | |
101 | 114 | | |
102 | 115 | | |
| 116 | + | |
| 117 | + | |
103 | 118 | | |
| 119 | + | |
| 120 | + | |
104 | 121 | | |
| 122 | + | |
105 | 123 | | |
106 | 124 | | |
107 | 125 | | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
108 | 130 | | |
109 | 131 | | |
110 | 132 | | |
| 133 | + | |
111 | 134 | | |
112 | 135 | | |
113 | 136 | | |
| |||
0 commit comments