Skip to content

fix(rest, runtime): the dispatcher's /meta reads answer what RestServer's answer (#20320) - #20404

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-20320-dispatcher-meta-list-parity
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-20320-dispatcher-meta-list-parity

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20320
Clause-②: yes (widening)

The runtime dispatcher's /meta reads now give the same answers as RestServer's. A host that mounts only the ${prefix}/* catch-all (createHonoApp, or any adapter on the public HttpDispatcher API) serves /meta through 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's GET /meta/:type does to a list after the store read moved, unchanged, into createMetaListAnswer in packages/rest/src/meta-item-read-gate.ts, beside createMetaListReadGate. The steps, in RestServer's order:

  1. the 声明式端点的两个机器可读面会说谎:runtime-authored api 行在 /meta/api 与 /openapi.json 里在场,匹配器却永远看不见(真实 boot 实测) #5224 api served-set face;
  2. the per-caller list gate;
  3. ?id= for apps;
  4. ?object= for views;
  5. the ADR-0046 doc locale collapse;
  6. the doc content slim;
  7. the transport's own ADR-0106 object mask (a port);
  8. the translation.

RestServer's handler keeps its entry: the repeated-parameter refusal, the unknown-type refusal and the admitted previewDrafts declaration 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 own slimDocList is gone. Its list answer now carries Vary: Accept-Language, as RestServer's does.

The chain needs the locale parse and the translation helpers, so those moved too. RestServer's private methods extractLocale, buildTranslationBundle, translateOptionsFor, packagedObjectBase, translateMetaItems and the module-level isTranslatableMetaType are now one-line delegates to them. Nothing in meta.ts is a copy.

Row B: one public-audience predicate. isPublicAudienceRead moved out of RestServer. 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 at handleMetadataRequest's entry asks the same predicate with its own shapes: list for one segment, item for two. The dispatcher has no /book/:name/tree route; that path falls to the located ROUTE_NOT_FOUND tail. So the dispatcher never names book-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=draft row. The dispatcher's item read declares ?state=draft next to ?preview=draft, each with its mayReadPendingDrafts admission, above every branch. It parses the parameter as RestServer does. An admitted caller gets the protocol's draft read for any type, the object branch included:

  • 404 NO_DRAFT answered as itself when nothing is pending;
  • otherwise the draft through the per-caller gate under STORED_VERSION_DOOR_POLICY. The constant moved to the shared module; RestServer keeps its static name as an alias.
  • mayWriteItem comes from the dispatcher's own save-door admission. That admission is now spelled once, as a local in handleMetadataRequest, and the PUT branch 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 request comment now says the branch is unreachable for an ADMITTED switch. The query parameter itself still reaches it.

Gate ledger. scripts/check-route-envelope.mjs declares meta.ts handBuilt: 2 (was 1), with a note. The dispatcher's list answer must carry Vary: Accept-Language, and deps.success takes no headers.

What a dispatcher-only host answered before (measured on b1cbd9277)

The census drives dispatch() and RestServer over the same fixtures:

  • 18 type cells × 10 parameter probes × 4 callers = 720 list cells;
  • anonymous public book and doc reads, with controls;
  • the ?state=draft cells.

List cells, by cause, before the fix:

cause cells
the same answer 140
Vary header only 374
items differ 166
status differs (anonymous 401 vs 200) 40
  • ?id=crm listed every visible app. ?object=lead listed every view.
  • /meta/docs served bodies. Every doc list kept translations, with no locale collapse.
  • Translatable lists were untranslated.
  • /meta/api listed the unserved declaration.
  • Anonymous public book and doc lists and items answered 401.
  • Admitted ?state=draft answered the active item: 10 cells, builder and author.

Evidence

Gates, all on head 64a05bb97 (after merging origin/main at 15bf186f5)

  • 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 typecheck and pnpm --filter @objectstack/runtime typecheck: exit 0, check:test-typecheck OK 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 no dist/ 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 no scripts/pm/ file; the family comes from the scripts/ path of check-route-envelope.mjs.
    • pnpm check:dts-closure first answered 1 on tree state: client, organizations and verify had a dist/ without .d.ts, and this diff touches none of them. After rebuilding those three it answers 0; that is the recorded reading.
  • Reconciliation: dispatch-gates --ran answers 81 derived famil(ies) accounted for — 79 run, 2 NOT-MEASURED (the two named above), exit 0.
  • check-route-envelope.mjs edited: the script has no test file of its own, and no test executes it. Its --self-test passes inside pnpm check:route-envelope.

Acceptance notes

  • Out of scope; the census found these and they are reported, not fixed. The same family: the dispatcher's /meta answering differently from RestServer. Each is measured through dispatch() on the fixtures above.
    1. GET /meta/totally_invented_type answers 200 [] where RestServer answers 400 INVALID_REQUEST (the GET /api/v1/meta/<unknown-type> answers 200 with an empty collection while the write door refuses the same type #9488 refusal is REST-only).
    2. ?preview=DRAFT from a builder: the dispatcher compares case-sensitively and lists the published world, where RestServer overlays the drafts.
    3. The item read does not translate: GET /meta/app/crm with Accept-Language: zh-CN answers CRM against 客户管理.
    4. The item read does not collapse a doc's locale: it keeps translations, where REST answers 入门.
    5. There is no /meta/book/:name/tree route: 404 ROUTE_NOT_FOUND to an authenticated caller, where RestServer serves the tree.
    6. The object branch ignores ?preview=draft: a builder reads Invoice, where REST answers Invoice (draft).
  • Not measured; read at source only.
    • The dispatcher's list and item reads thread resolveActiveOrganizationId for every type. RestServer threads organizationIdForMetaRead over the folded type, and only for org-overridable types.
    • The dispatcher's object mask sets no Cache-Control: private, no-store on an undetermined posture. RestServer's list does.
  • Repeated parameters. RestServer refuses a repeated ?id= with 400. The Hono catch-all flattens the query to last-wins before dispatch(), so the dispatcher never sees a repeat. That is the adapter's seam, not this domain's.
  • Docs drift, not filed. content/docs/permissions/system-context.mdx row 49 lists /layers, ?layers=true and /diff as 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.
  • Public API. @objectstack/rest gains the exports createMetaListAnswer, translateMetaList, metaRequestLocale, isPublicAudienceRead, STORED_VERSION_DOOR_POLICY and their types. Nothing is removed. That is a widening of its published surface, so the changeset declares @objectstack/rest minor with Clause-②: yes (widening); @objectstack/runtime stays patch.

Generated by Claude Code

…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>
…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>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 28, 2026
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/rest, @objectstack/runtime, touching 80 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/rest/src/index.ts, packages/runtime/vitest.repo-tests.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

38 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json dcd3bceaa068fc3cfb589bd6e04cd0b89b660580.

⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/rest/src/index.ts, packages/runtime/vitest.repo-tests.json) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: resolveProtocol (26 routes)
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 33 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json dcd3bceaa068fc3cfb589bd6e04cd0b89b660580 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 49231f28901d87c914432118c2ff784a8f9e0f84 — the merge of head 07671d4d40e46a961327dc6d41b9ee7a01bea921 into base dcd3bceaa068fc3cfb589bd6e04cd0b89b660580, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs dcd3bceaa068fc3cfb589bd6e04cd0b89b660580 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 64a05bb978e40330b569ebe894f2a38bbbec963f

① Derived judgments

  • Moved chain unchanged for RestServer — correct. Compared the removed inline blocks (rest-server.ts on origin/main, the GET ${metaPath}/:type handler) with createMetaListAnswer (packages/rest/src/meta-item-read-gate.ts) step by step. Same order (api face → list gate → ?id= → ?object= → doc locale → doc slim → mask → translation), same conditions (metaType === 'api' && !previewDrafts; typeof appIdFilter === 'string' && appIdFilter !== ''; query?.object truthy; query?.include !== 'content'), same shape rule (listItemsOf/withItems ≡ the inline array-or-{items} unwrap; metaItemsArray unchanged). resolveEndpointMatchAuthority already returns EndpointMatchAuthority | undefined, so the chain's added isEndpointMatchAuthority re-check is idempotent. Doc locale step calls sources.requestLocale() with no i18n ≡ this.extractLocale(req). Mask port (RestServer.metaListAnswerSources.maskObjects): same resolveObjectMasker(…, 'object'), D6 ObjectSchemaMaskEvaluationError → {ok:false} → sendFieldVisibilityFault(res, object); applyObjectSchemaMask + emptied → the same fault, which is exactly what maskObjectDocument (head :4000) did; Cache-Control: private, no-store still set after the loop. Translation: translateList → translateMetaItems(req, req.params.type, …) → translateMetaList, identical sequence (translatable check → unwrap → i18n → bundle → locale → import → protocol only for object → map). Vary is set after the !answer.ok exit, as before. metaItemReadGateSources returns closures, so the spread into MetaListAnswerSources keeps this. isPublicAudienceRead: /\/:type(\/:name)?$/ ≡ item | list, book-tree tested first, pluralToSingular ≡ PLURAL_TO_SINGULAR[t] ?? t on every string that can equal book/doc. No divergence found; packages/rest/src/*.test.ts untouched in the diff.
  • Dispatcher answers what RestServer answers — correct, within the gaps the PR body itself lists. packages/runtime/src/domains/meta.ts: all four list exits (protocol, MetadataService.list, registry objects, registry listItems) go through answerList → answerMetaList → createMetaListAnswer, with previewDrafts declared once at the branch entry with its admission. Vary: { ...deps.success(answer.data), headers: { Vary: 'Accept-Language' } }; createHonoApp (packages/adapters/hono/src/index.ts:427) and dispatcher-plugin.ts:419 write response.headers to the wire. Cache: the dispatcher list has no cache; the missing Cache-Control: private, no-store on an undetermined posture is a real residual and is declared in the PR body. Anonymous exemption is exactly book/doc × list/item: isPublicAudienceRead(method, metaReadRouteOf(parts), parts[0]), list for 1 segment, item for 2, undefined otherwise, GET only, folded type; types, _drafts, _migrate-stored, zero segments, /published, the FSM /state/:field and the unrouted tail all keep the deny; the legacy one-segment object-name exit has if (anonymous) return anonymousDeny() before registry.getObject (head :1521). ?state=draft (head :1120-1135): isDraftRead = typeof query?.state === 'string' && …toLowerCase() === 'draft' && mayReadPendingDrafts(ctx), the same parse as rest-server.ts:6450; a non-admitted caller falls into the plain read, which never reads state (pinned byte-for-byte on both transports with the protocol never asked for state: 'draft'). Admitted: readPendingDraft → getMetaItem({ state: 'draft', previewDrafts, packageId, organizationId }); a thrown NO_DRAFT goes through errorFromThrown(e, 404) and NO_DRAFT is in error-code-ledger.zod.ts:615, so code survives as itself; gate under the shared STORED_VERSION_DOOR_POLICY with mayWriteItem = saveVerdict(canonicalMetaUrlType(type), activeOrganizationId).allowed, the same saveVerdict the PUT branch calls (head :1178); object mask after. Residuals on this row: no item locale collapse/translation and the org-id source (resolveActiveOrganizationId vs ctx.tenantId), both listed in the PR body's acceptance notes.
  • Security — correct, no new reach found. After the deny is skipped, a doc/book item can only exit through serveItem → gateMetaItemDocument (protocol and MetadataService.getItem exits) or a 404; a list only through the chain's createMetaListReadGate. In the shared gate, allReadable = caller.authenticated && !gated (meta-item-read-gate.ts:528), so an anonymous caller never takes the every-doc fast path; the doc arm filters by docAudienceAllows and the book arm by admitsBook, which admit 'public' only; the item gate answers anonymous refusals 401 UNAUTHENTICATED. Object schemas, apps, views, writes, HEAD/undefined method: denied at entry. Pinned: DOC_SECRET never on the wire, /meta/doc/crm_admin_runbook anonymous → 401.
  • Census is derived — correct, with one limit. deriveListReads() parses rest-server.ts for the GET ${metaPath}/:type handler literal and the chain's createMetaListAnswer/metaRequestLocale/translateMetaList, collecting query.X reads, refuseRepeatedQueryParams literals (['package','preview','object','include','id']), extractLocale calls and metaType === '…' literals, and asserts each is a probe/type cell (plus TRANSLATABLE_METADATA_TYPES). A projection reading a new parameter or keying on a new type literal reddens it. Limit: a step reading no new parameter and spelled without a metaType === literal is not derived; then only the 720 equality cells catch it, and only if it lands on one transport.
  • Ablations hold by reading. A: both transports call the chain, so equality stays green; the reds are the reference-moves pin (asserts ?id=, ?object=, slim, locale, translation, api face on RestServer), the 4 row-B list cells (need the gate inside the chain) and [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's fixed-expectation cells; the row-B item cells and the ?state=draft cells do not use the chain, consistent with staying green. So yes, the census cannot catch a chain defect that hits both transports by equality; the reference pin covers steps 1, 3, 4, 5, 6, 8, [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 covers 2 and 7, and RestServer's own unedited suite covers each. A2 (4 + 4 + 2 + 10 = 20) and B (4 types × 10 probes = 40 row-A anonymous cells; 4 list + 4 item row-B cells; 401 controls untouched) match the cell arithmetic.
  • Gate edit — correct. check-route-envelope.mjs counts response: object literals; the failure text (:2028-2031) prescribes exactly "raise the count and say which in note" for "a status or header the helper cannot express", and kind 1 in its docblock already names the header case (Allow:). deps.success(data, meta) (http-dispatcher.ts:1009) takes no headers. Not a weakening. vitest.repo-tests.json is required: the SPLIT rule (check-cross-package-test-inputs.mjs:2570-2595) holds the list equal to the escaping-test scan in both directions; the census readFileSyncs packages/rest/src/rest-server.ts and meta-item-read-gate.ts, and runtime's local project excludes REPO_TESTS (vitest.config.ts:219).
  • Comments and changeset prose — true, except the declaration. [#9741] comment: the cache condition !isDraftRead && !previewDrafts (head :6552) does admit a non-admitted caller's ?state=draft. metaReadRouteOf docblock, the notifyMissingEndpointMatcher twin (RestServer logError), the 5856866273 citation (carried from main's docblock) all check. Every "before" row in the changeset matches base meta.ts (slimDocList comparing type !== 'doc', only package/preview read, unconditional shouldDenyAnonymous, state never read). Wrong: the changeset says "New exports from @objectstack/rest…" and still declares Clause-②: no / patch — see ②.

② Semver level

Wrong: @objectstack/rest must be minor with Clause-②: yes (widening); @objectstack/runtime patch is right. packages/rest/src/index.ts adds 5 value exports and 6 type exports to the root . subpath, which the package's exports map makes addressable — the published surface as contract-review.md:9 defines it. The Clause-② criterion (execution-duties.md:66) is "widen the accept set OR enlarge the public surface"; the family precedents PR #20236 (createMetaItemReadGate + 5 types) and PR #20319 (createMetaListReadGate) each declared @objectstack/rest: minor + Clause-②: yes for exactly this class and said so in the changeset. AGENTS.md (:1074-1075) and the 2026-09-04 ruling: yes takes at least minor. Check Changeset is green only because check-changeset-no-major.mjs enforces yes ⇒ ≥ minor, not a wrong no; landing-operations.md:11 calls a wrong no an auditable false declaration and the enqueue gate reads the claim comment's line. Runtime: a fix, packages/runtime/src/index.ts untouched.

③ Boundary flags

  • Files outside claim 5863714418: packages/rest/src/index.ts (root export surface; not on the claim's list and not declared as a deviation in the os-dev report), packages/runtime/vitest.repo-tests.json (declared), scripts/check-route-envelope.mjs (declared). The read-only fence (core, metadata-core, metadata-protocol, spec) is untouched.
  • PR body vs diff: +1693/−527 across 11 files, the delegates, slimDocList removed, handBuilt: 2, the export list and "nothing removed" all match; "every existing REST test passes unedited" holds (no packages/rest/src/*.test.ts in the diff); Clause-②: no does not hold (②).
  • CI on the head: 30 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in)), 1 in progress: Lint & Repo Gates. No red. Check Changeset green.

Implemented-by: claude/issue-20320-dispatcher-meta-list-parity
Reviewed-by: session_01UYBdGBzWSrAMzpW8ah3GbP

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

…the root entry gains the shared chain's exports

Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review (delta, patch round 1)

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 07671d4d40e46a961327dc6d41b9ee7a01bea921

① Derived judgments

  • Changeset declaration — correct. .changeset/20320-dispatcher-meta-read-parity.md at the head: front matter '@objectstack/rest': minor, '@objectstack/runtime': patch; line-initial Clause-②: yes (widening) — …. The shared reader (scripts/pm/clause2-line.mjs, matchValueToken then readArmToken: ^\([ \t]*(widening|narrowing) anchored only at the start of the remainder) reads value yes, arm widening, and tolerates the trailing prose; the key is not in a backtick span, so clause2LineDescribes is false. check-changeset-no-major.mjs:393 and check-adr-0087-registration.mjs:374 both import that reader. Check Changeset is green on the head (both runs).
  • Export sentence matches packages/rest/src/index.ts exactly — correct. Diff vs origin/main adds five values (createMetaListAnswer, isPublicAudienceRead, metaRequestLocale, STORED_VERSION_DOOR_POLICY, translateMetaList) and six types (MetaListAnswer, MetaListAnswerSources, MetaListRequest, MetaListTranslationSources, MetaPublicReadRoute, MetaRequestHttp), all re-exported from ./meta-item-read-gate.js, all declared there at the head (:230, :1536, :1559, :1574, :1591, :1701, :1728, :1759, :1798, :1816, :1877) and none present in that module or in index.ts at origin/main. The changeset names the same eleven, nothing extra. "Nothing it exported before is removed, renamed or narrowed" holds: the index.ts hunk is add-only (createMetaItemReadGate, createMetaListReadGate and the five prior types retained), no export *, rest-server.ts's root-level export declaration set is identical at main and head, and RestServer's static set differs only by the dropped private static metaItemsArray (TS-private, not published surface).
  • Rest of the changeset prose — still true. The OLD→NEW delta on the file is exactly the two lines (front-matter level, Clause-② line); origin/main moved 15bf186→dcd3bceaa without touching any of the 11 files, so the "before" rows keep their base. No packages/rest/src/*.test.ts in the file list, so "every existing REST test passes unedited" stands. The closing "New exports … and their types" paragraph now duplicates the Clause-② sentence: redundant, not wrong.
  • PR body — correct. Line 2 is bare, line-initial Clause-②: yes (widening) (first declared read wins; the Acceptance-notes mention is inline and never reached). The added sentence ("That is a widening … minor with Clause-②: yes (widening); @objectstack/runtime stays patch") is true against the changeset and index.ts. No body statement contradicts the changeset. One staleness, not a falsehood: the ## Gates heading still dates every reading to 64a05bb97; the round's gate readings on 07671d4d4 exist only in report 5865936722.
  • Merge of origin/main — clean, no conflict resolution in the PR's files. d5e1b1bfb has parents 64a05bb97 and dcd3bceaa; its tree 1c172a3df equals a driver-free git merge-tree --write-tree 64a05bb97 dcd3bceaa from a bare probe clone, so it carries nothing beyond the two sides. Blob ids of all 11 files are identical at 64a05bb97, d5e1b1bfb and 07671d4d4 except the changeset (c1f14a11a→d4203b09f, in 07671d4d4 only). git diff origin/main...07671d4d4 --stat and …64a05bb97 --stat both list the same 11 files, +1693/−527; the files API agrees.
  • Claim 5863714418 — covers all 11 files; line matches. Surface: rest-server.ts, meta-item-read-gate.ts, index.ts, runtime meta.ts, "tests in packages/runtime/src/domains/" (the four test files), one .changeset/20320-*.md, vitest.repo-tests.json, scripts/check-route-envelope.mjs. Line-initial Clause-②: yes; the claim template admits exactly yes | no (execution-duties.md:66), so the arm rides on the changeset and body, and the value agrees. Amended in place at 07:58:20Z, before the REWORK; newest claim still names this branch.

② Semver level

Right. @objectstack/rest has one export subpath, . (packages/rest/package.json at the head), and that entry gains 5 values + 6 types: a widening of the published surface, so minor with Clause-②: yes (widening), exactly as the family precedents .changeset/20193-dispatcher-meta-read-gate.md (PR #20236) and .changeset/20237-dispatcher-meta-list-gate.md (PR #20319) declared ('@objectstack/rest': minor + Clause-②: yes). Nothing removed or narrowed, so not (narrowing) and not major. @objectstack/runtime: packages/runtime/src/index.ts is not in the diff, no new surface, behaviour fix only, so patch.

③ Boundary flags

  • Files outside the amended claim: none. Read-only fence (core, metadata-core, metadata-protocol, spec) untouched.
  • PR file list between heads: unchanged (11 files, same stat).
  • Body gate ledger: dated to the previous head 64a05bb97; nothing false, but the current head's gate readings are in the report comment, not the body. CI on the head is the check of record.
  • CI on 07671d4d4 (read last): 27 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in)), 0 red, 7 in progress: Lint & Repo Gates, Test Core (1/6), Test Core (2/6), Test Core (4/6), Test Core (5/6), Test Core (6/6), Type Check · workspace. Green so far among the required set: Build Core, Dogfood Regression Gate, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard; Check Changeset green. The PR is still draft.

Implemented-by: claude/issue-20320-dispatcher-meta-list-parity
Reviewed-by: session_01UYBdGBzWSrAMzpW8ah3GbP

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

@objectstack-fleet objectstack-fleet Bot assigned hotlong and unassigned os-litant Sep 28, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 11:41
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 95f729a Sep 28, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20320-dispatcher-meta-list-parity branch September 28, 2026 12:15
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…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>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

3 participants