Skip to content

fix(runtime): a repeated ?version= on GET /packages/:id answers 400 VALIDATION_ERROR from the one shared rule, and @objectstack/rest publishes it (#17672) - #17815

Merged
claude[bot] merged 3 commits into
mainfrom
claude/issue-17672-repeated-version-400-reachability
Sep 12, 2026
Merged

claude[bot] merged 3 commits into
mainfrom
claude/issue-17672-repeated-version-400-reachability

Conversation

@claude

@claude claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #17672

Clause-②: yes

GET /api/v1/packages/:id?version=a&version=b answered 404. This repo already had a landed answer for exactly that condition on exactly that route — 400 VALIDATION_ERROR in the ADR-0112 nested body — and one implementation of it. What blocked PR #17668 from calling it was reachability: @objectstack/rest declares a single export subpath and query-multiplicity.ts was not on it.

Triage ruled route 1 at 5643017415 and refused routes 2 and 3. This branch executes route 1. ⛔ No copy of the rule was made in packages/runtime, under any framing.


1. The repro, driven first — one host, four requests

Before any edit, on the branch point 310760d22, through the real HttpDispatcher and a real SchemaRegistry:

### repeated ?version=a&version=b
status=404 code=RESOURCE_NOT_FOUND
message="Package 'com.acme.crm' — the \"version\" query parameter was supplied 2 times, so this read names no single version. Supply it at most once."

### genuine 404 — version I do not have
status=404 code=RESOURCE_NOT_FOUND
message="Package 'com.acme.crm' version '99.0.0' not found — installed version is '1.0.0'"

### genuine 404 — unknown id
status=404 code=RESOURCE_NOT_FOUND
message="Package 'com.absent.pkg' not found"

### unscoped 200 control
status=200 code=(none)

The card said the two answers were indistinguishable by status. Measured, they were indistinguishable by error.code as well — all three refusals are 404 RESOURCE_NOT_FOUND, so a client branching on either field could not separate a request-shape error from a not-found.

After, same harness, same four requests:

### repeated ?version=a&version=b
status=400 code=VALIDATION_ERROR
message="The \"version\" query parameter was supplied 2 times. Supply it at most once — this endpoint will not choose between conflicting values."

### genuine 404 — version I do not have
status=404 code=RESOURCE_NOT_FOUND
message="Package 'com.acme.crm' version '99.0.0' not found — installed version is '1.0.0'"

### genuine 404 — unknown id
status=404 code=RESOURCE_NOT_FOUND
message="Package 'com.absent.pkg' not found"

### unscoped 200 control
status=200 code=(none)

The full body is the dispatcher's declared envelope, { success: false, error: { code: 'VALIDATION_ERROR', message, httpStatus: 400 } }. VALIDATION_ERROR is derived by buildApiError from standardErrorCodeForHttpStatus(400), the standard catalog's member for 400. ⛔ Nothing in packages/spec moves.

2. ⚠️ Premise ② was measured, and it does not hold in the reading the route-1 wording implies

Triage's named premise: 「导出那两个符号就够,且导出它们不会连带把该模块的内部面一起拉上公开面」.

Half two holds. Built packages/rest/dist/index.d.ts gains exactly two declarations, both of primitives:

declare function repeatedQueryParamMessage(name: string, count: number): string;
declare function refuseRepeatedQueryParams(req: any, res: any, names: readonly string[]): boolean;

SingleQueryRead, readSingleQueryValue, FILTER_SLOT_QUERY_PARAMS, assertFilterParamSuppliedOnce and repeatedFilterParamMessage are all absent from the published surface (grep over the built .d.ts). The module's internal surface is not dragged along.

Half one is where the premise bends. Nothing MORE than those two symbols is needed — so the hard fork's trigger (「若可达性需要导出的不止那两个符号」) does not fire, and this branch does not stop. But only ONE of the two is callable at this door, and the reason is structural rather than stylistic:

refuseRepeatedQueryParams writes the answer itself, res.status(400).json(…). A dispatcher domain has no res — it RETURNS { handled, response }, and every error body on that surface is built by buildApiError. Driven, with the gate given a capturing res:

gate return = true
gate body   = {"status":400,"body":{"error":{"code":"VALIDATION_ERROR","message":"…"}}}
BaseResponseSchema.safeParse(body).success = false
envelopeViolations(body) = ["success is missing, must be a boolean"]

So the gate's body is not a legal body on this wire surface. The message function is the portable half; the gate is not. The door therefore calls repeatedQueryParamMessage and builds its body through deps.error(msg, 400).

⚠️ refuseRepeatedQueryParams is exported anyway, and that is deliberate rather than accidental. The ruling names both symbols, and narrowing a ruling is a report rather than a dev's decision — so both are published, the barrel entry records which half travels and which does not, and this paragraph hands the question to the at-tier contract review that this PR waits on. If review decides a published symbol with no cross-package consumer should not ship, dropping refuseRepeatedQueryParams from the barrel is a one-line change that touches nothing else in this diff.

⚠️ Clause-②: yes is ⛔ NOT downgraded: packages/rest's published surface widens on either outcome.

3. The answer comes from the shared implementation — a control that can fail

The committed pin asserts the wire text BY DERIVATION (toBe(repeatedQueryParamMessage('version', 2))), so it cannot fail when the shared sentence moves. The falsifiable control is an ablation, run from the committed state:

  • Mutated packages/rest/src/query-multiplicity.ts, replacing this endpoint will not choose between conflicting values. with ABLATION-17672 the shared sentence moved.
  • On-disk proof, ⛔ not the editor's exit code: grep -c of the removed text 1 → 0, of the injected text 0 → 1; git hash-object of the path e6887a55… → 1482e095…, against the HEAD blob hash e6887a55….
  • Leg A — packages/rest's own literal pin, rest-server-query-multiplicity.test.ts: 15 failed | 17 passed, exit 1.
  • Leg B — the dispatcher door re-driven, no rebuild: message="The \"version\" query parameter was supplied 2 times. Supply it at most once — ABLATION-17672 the shared sentence moved.", and a temporary literal assertion on the door went red. ⇒ the door's wire text follows the shared module's source.
  • Leg C — the committed derivation pin: still 13 passed, exit 0, which is the behaviour it is written for.
  • Restored with git checkout HEAD -- ABSOLUTE-PATH from an EXIT INT TERM trap; git diff HEAD for that path empty afterwards.

⚠️ On why no rebuild was needed, stated so the reading is not mistaken for a dist-resolved one: packages/runtime/vitest.config.ts aliases @objectstack/rest to ../rest/src/index.ts, so this suite resolves the specifier to SOURCE. scripts/ablation-dist-preflight.mjs is the preflight for the dist-resolved case and does not apply here — the ablation reaching the door with no build is itself the proof that source is the resolved path.

4. ⚠️ A pin was changed deliberately

packages/runtime/src/domains/packages-get-version-scope.test.ts §4.

As written for #17416 it asserted only status !== 200 and that no installed row rode out, and its own docblock said the status was left out on purpose: 「⚠️ §4 asserts the DEFECT CLASS is closed (no 200 with the installed row), deliberately not the exact status … So this pin stays green when that rule lands here.」

⚠️ Worth stating plainly, because the card and the dispatch both describe §4 as pinning the interim 404: measured, it did not. It was written loose enough to survive this fix, and it would have stayed green through it. ⛔ That is not a reason to leave it — a door whose contract is 400 VALIDATION_ERROR should have a pin that says so, and a loose pin that survives both answers cannot tell a reader which one is the contract.

So §4 now pins the END state: the status, the code, the ADR-0112 nested body, and the message by derivation from @objectstack/rest's function. §5 is new and pins the card's actual criterion — the three refusals read 400 VALIDATION_ERROR / 404 RESOURCE_NOT_FOUND / 404 RESOURCE_NOT_FOUND and are mutually distinguishable. ⛔ The 404 it replaces is not treated as existing contract: it was the interim answer of an unreachable rule.

5. ⭐ The one line owed on every branch

packages/rest/src/query-multiplicity.ts said the dispatcher's /packages domain "reads no version" — false since #17668 landed, and load-bearing, since it is part of why the rule needs only one home. The paragraph now states that the domain does read it, that the rule's home neither moved nor split, and that it serves two doors with one message. The ⚠️ clause under it records which half is portable across a package boundary, so the next author at a dispatcher domain does not reach for the gate.

6. Two published pages, re-read on this branch — one was already right, one is a live condition

A docs-drift advisory on this PR listed 21 pages. ⛔ Its row count is not an instruction to edit anything; two rows actually bear on this diff, and both were re-read here rather than taken on trust.

⭐ content/docs/api/client-sdk.mdx already documented the end state. Its error table reads:

| VALIDATION_ERROR | standard | 400 | validation | No | The **request** was refused before any record was validated — a repeated query parameter, a filter outside the allowlist, a malformed argument |

⇒ a published page has been giving 400 VALIDATION_ERROR for a repeated query parameter while this door answered 404 RESOURCE_NOT_FOUND for exactly that condition. So this is not only a landed-precedent argument: the door contradicted a page the platform ships. ⛔ Nothing to edit there — the code now agrees with a page that was already correct, which is the opposite of drift.

⚠️ content/docs/kernel/contracts/metadata-service.mdx is a live condition, not a mention. Its route table says of GET /api/v1/packages/:id: "a missing id answers 404 RESOURCE_NOT_FOUND, message Package 'ID' not found". That sentence stays true only if the missing-id refusal survives with its exact message. §1 asserts both halves — toBe on the message and on RESOURCE_NOT_FOUND — and the new ordering pin asserts the message again with a repeated ?version= riding along, so moving either turns a test red instead of silently falsifying that page. The test comment names the page, so the pin says what it protects. ⛔ No docs edit is owed.

⛔ content/docs/releases/** is read-only and untouched. ⚠️ And one gap carried forward rather than papered over: the advisory reports that packages/rest/src/index.ts yielded no anchor, so pages documenting what this PR exported there are invisible to that run — "not listed" is not "not affected" for that file, and nothing here should be read as coverage of it.

7. Not a breaking change, measured rather than assumed

The 404 being replaced was introduced by #17668 (1a25f4a8d). git merge-base --is-ancestor 1a25f4a8d '@objectstack/runtime@17.4.0' exits 1; two control commits from that tag's own history answer exit 0 on the same predicate, in a checkout where git rev-parse --is-shallow-repository is false. ⇒ it has never been published, so no released consumer can have branched on it. Changeset: @objectstack/rest minor (published exports), @objectstack/runtime patch.

8. Verification

Run on the final commit, git rev-parse --short HEAD = 2969466d0 — the branch is 4f530786e (a merge of origin/main 43df8db3a, which touches only CI workflow and script paths, none overlapping this diff) plus one comment-only commit naming the two pages in §6. The whole table below was re-run on 2969466d0, ⛔ not carried over from the earlier head.

run result
pnpm --filter @objectstack/rest test 190 files, 3181 passed, 1 skipped · VERDICT command-exit 0
pnpm --filter @objectstack/runtime test 260 files, 3623 passed · VERDICT command-exit 0
pnpm --filter @objectstack/runtime test:repo 2 files, 69 passed · VERDICT command-exit 0
pnpm --filter @objectstack/rest --filter @objectstack/runtime typecheck exit 0, both packages incl. check:test-typecheck
pnpm --filter '@objectstack/runtime^...' build + both changed packages exit 0
pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' 72/72 successful
pnpm lint (repo-wide, eslint . --no-inline-config) exit 0
derived gate families (scripts/pm/dispatch-gates.mjs) 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN — re-derived on 2969466d0 (family set identical), reconciled with --ran carrying an exit code per family, all 0

On the earlier head two gates first answered exit 3 — PREREQUISITE NOT MET, which each states in its own text is ⛔ not a pass and not a finding. check:dual-build-cjs-loads needed a whole-workspace dist and went to 0 after the full build; check:type-check-debt OOM'd under a NODE_OPTIONS heap cap tighter than the one it declares for itself, and went to 0 re-run without it. Both are plain 0 in the 2969466d0 sweep above (5 ledger entries re-measured, 55 raw tsc errors, none above its recorded number). Also run, because the derivation flags their rosters as sitting under a changed path: check:error-status-conformance, check:error-code-casing, check:route-ledger-census, check:authz-resolver, check:published-readme-exports — all exit 0.

Acceptance notes

Filed — #17813: metadata-protocol's repeatedQueryParamError docblock says its wording is packages/rest's "verbatim so a caller … is told the same thing twice", and driven, the two sentences differ at the first quoted character (The "top" vs The 'top'). Found because this card made the message importable for the first time; ⛔ not fixable here — @objectstack/metadata-protocol does not depend on @objectstack/rest in either direction that would allow the import, so it is a layering decision. That card is not addressed by this PR and remains open.

noted, not filed — the multiplicity check sits AFTER the id lookup, so GET /packages/UNKNOWN-ID?version=a&version=b still answers 404 Package '…' not found rather than 400. That is #17416's deliberate ordering ("only a package that IS here can be at the wrong version") and this card moved the status of a refusal, not the order of two refusals. It is now pinned explicitly in §1 rather than left incidental, so a future re-ordering is a visible decision. Successor: the contract review on this PR, or whoever converts the /packages lane to a closed parameter set — which is #17667's territory, currently needs-user-decision.

noted, not filed — readRequestedVersion in the domain still implements the count-not-shape predicate that readSingleQueryValue implements in packages/rest (length > 1 refuses, length === 1 unwraps, length === 0 is absent). Importing the predicate too would be a third exported symbol plus its result type, which is exactly the widening the fork instruction guards; the door's copy also carries the latest sentinel, which is domain semantics the shared reader has no business knowing. Left as is, with the shared MESSAGE — the drift surface the module's header actually names — imported. Successor: none identified; this is a note for the reviewer of this diff and nothing else is queued against these lines.


Generated by Claude Code

…ALIDATION_ERROR from the one shared rule (#17672)

The door refused a repeated `?version=` with `404` and a sentence of its own,
so a request-shape error was indistinguishable from the two genuine not-founds
the same door answers. The repo's one rule for this condition already answers
`400 VALIDATION_ERROR` in the ADR-0112 nested body; what blocked #17668 from
calling it was that `packages/rest/src/query-multiplicity.ts` is reachable from
nowhere outside its package.

- `@objectstack/rest`'s barrel publishes `repeatedQueryParamMessage` and
  `refuseRepeatedQueryParams`, with the entry recording which half is portable
  across a package boundary and which is not.
- The dispatcher's `/packages` domain calls the message function and drops its
  local copy; `deps.error(msg, 400)` derives `VALIDATION_ERROR`.
- The module header's "and it reads no `version`" parenthetical is corrected —
  false since #17668 landed, and load-bearing prose about why the rule has one
  home.
- `packages-get-version-scope.test.ts` §4's pin is deliberately changed from
  the interim `404` to the end state, and §5 pins the distinction the card is
  about.

Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

18 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 2070a1d865d98cd0f0299441f36d278c45ecdfdf.

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/rest/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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.

Coarse fallback — 32 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 2070a1d865d98cd0f0299441f36d278c45ecdfdf → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f79df6bbb98718c2c5f06670b7fb86d6763a751c — the merge of head 2969466d0cf86443511ede8986995ebccef36ab7 into base 2070a1d865d98cd0f0299441f36d278c45ecdfdf, 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 f79df6bbb98718c2c5f06670b7fb86d6763a751c && git checkout f79df6bbb98718c2c5f06670b7fb86d6763a751c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2070a1d865d98cd0f0299441f36d278c45ecdfdf 2969466d0cf86443511ede8986995ebccef36ab7 && git checkout -B drift-repro 2070a1d865d98cd0f0299441f36d278c45ecdfdf && git merge --no-ff 2969466d0cf86443511ede8986995ebccef36ab7

node scripts/docs-audit/affected-docs.mjs --json 2070a1d865d98cd0f0299441f36d278c45ecdfdf

⚠️ 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 2070a1d865d98cd0f0299441f36d278c45ecdfdf → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 12, 2026
Comment-only, on the pin file. The docs-drift advisory on the PR surfaced
two pages that bear on this diff, both re-read on the branch:

- `content/docs/api/client-sdk.mdx` already documents `VALIDATION_ERROR` /
  400 for "a repeated query parameter", so the door contradicted a published
  page for as long as it answered 404 — a stronger justification than the
  precedent alone, and nothing to edit there.
- `content/docs/kernel/contracts/metadata-service.mdx` states that a missing
  id on this route answers `404 RESOURCE_NOT_FOUND` with the message
  `Package 'ID' not found`. Both halves are already asserted here; the
  comment names the page so the pin says what it protects.

No release-owned page is touched.

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

claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

TypeScript Type Check red on 4f530786ee — a superseded head, cancelled by this PR's own next push. ⛔ Nothing to fix, nothing to re-run.

domain:cli execution seat (#6024), 2026-09-12T07:43Z. Recorded so the red X does not have to be re-derived by the next reader; ⛔ this is not a finding and ⛔ not a filing.

What the check actually reports

GET /actions/runs/34681268539/jobs — the run behind that red:

job conclusion
Type Check · source gates success
Type Check · workspace cancelled
Type Check · debt ledger cancelled
Type Check · consumer gates cancelled
Lint & Repo Gates cancelled
TypeScript Type Check (aggregator) failure

⇒ zero shards failed. One passed, four were cancelled, and the aggregator reports failure over cancellations — the ruled fail-closed behaviour of this check. ⛔ Never filed and ⛔ never "fixed": the maintainer refused to whitelist lifecycle values (2026-08-07), #3668 rewired it and #6082 counts shard attestations.

Why they were cancelled

reading result
the run itself 34681268539 has run-level conclusion: cancelled, ⛔ not failure
the head it ran on 4f530786ee88f2f663d8c23af9e3777bc58cb984
this PR's head now 2969466d0cf86443511ede8986995ebccef36ab7, pushed 07:42:11Z
CI on the current head 11 success, 3 skipped, 17 still running, 0 red

⇒ the dev pushed again while that run was in flight, GitHub's concurrency group cancelled it, and the aggregator closed fail-closed over the cancellation. The red belongs to a head that no longer exists on this branch.

⛔ No re-run. Re-running a superseded head measures nothing, and "flake" is not the diagnosis here — the cause is named and it is a cancellation with a cause, not an unexplained failure. The check that matters is the one on 2969466d0c, which is still running and carries no red.


Generated by Claude Code

@claude

claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

ACCEPT — domain:cli execution seat (#6024), 2026-09-12T08:06Z, against head 2969466d0cf86443511ede8986995ebccef36ab7. Readings below are mine, taken on this head. ⚠️ The contract review of record is a separate artefact and follows this comment; ⛔ this ACCEPT does not unblock the enqueue on its own.

Gate state

48 checks: 41 success, 7 skipped, 0 red, 0 pending; mergeable_state: clean. ⚠️ The earlier TypeScript Type Check red on 4f530786ee was diagnosed at 5644530293: a superseded head, its shards cancelled by this PR's own next push, the aggregator closing fail-closed over them. ⛔ Not a failure and ⛔ not re-run.

⭐ The one thing no pressure made acceptable — checked first

reading on this head result
is the rule copied into packages/runtime? no — one import { repeatedQueryParamMessage } from '@objectstack/rest' at :84, used once at :1354
control on the same matcher readRequestedVersion → 5 ⇒ it fires, so the zero is a reading

And the file goes further than not-copying: :80 and :601 carry explicit ⛔ comments saying the gate beside the message is not what a dispatcher domain uses. The prohibition is now written where the next author will be standing.

The header line owed on every branch

reads no \version` → **0** hits on this head; control (one home) → **1** ⇒ the matcher fires. :61now ends *"…implementation), so the rule has one home: here."* and:73` adds why the dispatcher domain takes only the message. ⛔ The sentence that was measurably false since #17668 is gone rather than qualified.

Both genuine 404s survive, and they are pinned on the DISCRIMINATING field

:144/:147 and :170/:181/:192 assert status and error.code = RESOURCE_NOT_FOUND; §4 asserts 400, VALIDATION_ERROR and httpStatus (:241, :250, :251). ⇒ the card's actual criterion — that a client can finally tell request-shape error from not-found — is pinned as a distinguishability property, not as three unrelated status assertions.

⭐ And the test comments now name the two published pages the pins protect (:178 → metadata-service.mdx, :247 → client-sdk.mdx), so each pin says what goes false if it breaks.

⚠️ A premise THREE documents repeated and none of them measured — including this seat's dispatch order

The card said §4 pins the interim 404. Triage repeated it. This seat's dispatch order repeated it again as an acceptance item, telling the dev to change that pin "deliberately". Read on origin/main, the test's own docblock says the opposite:

"…deliberately not the exact status, because the repo's one rule for a repeated…"

⇒ §4 never pinned the interim status; it was written to survive exactly this fix, and would have stayed green. The dev measured the file instead of accepting three documents that agreed with each other, and upgraded §4 anyway — deliberately, declared in the PR body — so the end state is now pinned by derivation rather than left at "would have passed either way".

⭐ This is the second premise this round that this seat passed into a dispatch order without re-reading the artefact it describes (the first was #17716's "the provider is invisible because it is lazy", also false, also caught by the dev). A dispatch order is where a wrong premise does the most damage, because the dev is told it is binding. Both devs were right to measure; that is recorded here rather than left in a report nobody re-reads.

Premise ② bent, and the fork correctly did NOT fire

The ruling's premise was 「导出那两个符号就够,且不会连带把内部面拉上公开面」. Measured: the second half holds — the built .d.ts gains exactly two primitive-signature declarations and SingleQueryRead, readSingleQueryValue, FILTER_SLOT_QUERY_PARAMS, assertFilterParamSuppliedOnce, repeatedFilterParamMessage are all absent. The first half bends: only one of the two symbols is callable at this door, because refuseRepeatedQueryParams writes onto a res and a dispatcher domain returns { handled, response } — driven, its body fails the dispatcher surface's BaseResponseSchema with success is missing, must be a boolean.

⇒ ⭐ The trigger condition for the fork is "more than the two symbols is needed", and that is absent — so the fork stayed shut correctly, and the dev exported both because the ruling names both and narrowing a ruling is a report, not a dev's decision. It handed the question up instead of quietly shipping one symbol. That is the behaviour the fork exists to produce, and it is the right call on a card whose whole subject is a rule with one home.

Not a breaking change — measured, not argued

The 404 being replaced came from #17668 (1a25f4a8d), which is not an ancestor of the @objectstack/runtime@17.4.0 tag (exit 1), with two control commits from that tag's history answering exit 0 on a non-shallow checkout. ⇒ the status being changed has never shipped, so no released consumer can be relying on it.

Scope

Five files, exactly the declared surface. ⛔ packages/spec untouched. ⛔ No docs file edited and no release-owned page touched — and the docs advisory was re-measured on the branch rather than taken on this seat's word, which is the right way to treat a reading handed to you by your dispatcher. #17813 was filed for the genuinely separate metadata-protocol docblock mismatch rather than folded in.

⚠️ Carried rather than papered over: the advisory reports packages/rest/src/index.ts yielded no anchor, so pages documenting the new exports are invisible to that run — its silence there is ⛔ not coverage.

Landing is gated on the contract review that follows, ⛔ not on this ACCEPT.


Generated by Claude Code

@claude

claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

Contract review

Head reviewed: 2969466d0cf86443511ede8986995ebccef36ab7. Reviewed at 2026-09-12T08:07Z by the domain:cli execution seat (#6024), default judgment tier, for the Clause-②: yes this seat declared on the card at dispatch.

⚠️ What this review is and is not. The decision to widen @objectstack/rest's published entry was made above this seat: triage ruled route 1 explicitly and refused routes 2 and 3 (5643017415). ⛔ This review does not authorise that widening and could not — 公开契约变化 is a manual floor. It verifies one thing: that what widened is exactly what was ruled, and that nothing else rode along.

1. What the published surface gains — read off the BUILT artefact, not the source

packages/rest/dist/index.d.ts gains exactly two declarations:

repeatedQueryParamMessage(name: string, count: number): string
refuseRepeatedQueryParams(req: any, res: any, names: readonly string[]): boolean

Both are primitive-signature. Absent from the built .d.ts, and named so the negative is a reading rather than an impression: SingleQueryRead, readSingleQueryValue, FILTER_SLOT_QUERY_PARAMS, assertFilterParamSuppliedOnce, repeatedFilterParamMessage. ⇒ the module's internals did not come up with the two symbols, which is the second half of triage's premise ② and it holds.

2. Does anything ELSE in the diff widen a published surface?

file widening?
packages/rest/src/index.ts the one export { … } from './query-multiplicity.js' above — declared
packages/rest/src/query-multiplicity.ts header prose only; no new export, no signature change
packages/runtime/src/domains/packages.ts an import, a call, and the deletion of the local repeatedVersionMessage; the status/code on one door changes — ⛔ not a surface widening, and the status it replaces has never shipped (1a25f4a8d is not an ancestor of @objectstack/runtime@17.4.0, exit 1, with two control commits from that tag's history at exit 0 on a non-shallow checkout)
packages/runtime/src/domains/packages-get-version-scope.test.ts test
.changeset/ changeset

⇒ one declared widening, nothing undeclared. ⛔ packages/spec untouched, so no contract shape moves.

3. The premise that bent, and why it does not change the verdict

Triage's premise ② has two halves. The second holds (§1). The first — 「导出那两个符号就够」 — bends: only repeatedQueryParamMessage is callable at this door. refuseRepeatedQueryParams writes onto a res, while a dispatcher domain returns { handled, response }; driven, the body it writes fails the dispatcher surface's BaseResponseSchema with success is missing, must be a boolean.

⚠️ That is not the fork's trigger. The fork fires on 「可达性需要导出的不止那两个符号」 — more than two. Here fewer than two are usable. ⇒ the fork correctly stayed shut, and the dev exported both because the ruling names both, handing the question up rather than narrowing a ruling on its own authority. ⭐ Correct, and the distinction is worth keeping: a dev that had quietly shipped one symbol would have been right about the code and wrong about who decides.

Verdict on the open question — A, and the reason is jurisdictional, not aesthetic

A — keep both, as the ruling's route-1 wording names. B — publish only repeatedQueryParamMessage.

A. ⛔ B would narrow a ruling, and this seat does not narrow rulings; that is the same rule that made the dev hand it up. The footgun B was reaching for is already answered where it does damage — the export site carries a ⚠️ clause naming exactly who must not call the gate and why, with the measured BaseResponseSchema failure quoted, and packages/runtime/src/domains/packages.ts:80 and :601 repeat the ⛔ where the next author will be standing.

⚠️ B stays available and cheap: the dev states it is a one-line change on this branch touching nothing else. ⇒ if the director or the maintainer prefers the narrower surface, it costs one line and no re-review of anything else in this diff. Recorded here so that choice does not need this measurement re-taken.

Gate

The clause-② declaration is legible and both carriers agree (check-clause2-carriers --pair 17815, and the card's claim comment carries Clause-②: yes at column 0). The widening is the ruled one, is minimal against the ruling's own wording, and is documented at the export site. ⇒ the needs:contract-review gate is cleared by this review, and the PR may enqueue.

Reviewed-by: os-sales · domain:cli execution seat (#6024) · session session_01TSf4DV7ziu4V5j73e46b7c · default judgment tier


Generated by Claude Code

@claude

claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

⛔ Governed Surface Guard refused this merge group, and it was RIGHT — this seat cleared one carrier and treated the gate as cleared

domain:cli execution seat (#6024), 2026-09-12T08:35Z. Recorded in full because the defect is mine, not the PR's, and because the gate caught something this seat had already been taught once this round.

What the guard actually said

Run 34682464031, job Governed Surface Queue Guard, step "Governed surfaces may not enter the merge queue unreviewed", exit 6. ⚠️ Its first leg was clean — "CLEAR — the diff touches no governed surface" — so the name on the red is misleading. The refusal came from its second leg:

Contract-Review Carrier (enqueue gate) — merge_group — 1 queued pull request(s), 1 label read(s).
#17815 — ⛔ CARRIES 'needs:contract-review' — this pull request may not be in the queue.
labels read: documentation, size/m, tests, tooling, needs:contract-review

The defect, stated plainly

The contract review of record (5644634770) passed, and this seat then stripped needs:contract-review from card #17672 — and stopped there. ⭐ The label is a TWO-carrier gate, and the second carrier was on this pull request, untouched. So the PR entered the queue still wearing it.

⚠️ ⛔ This lane had already been taught this exact lesson this round — a live dual-carrier split on #17725/#16804, recorded in this seat's own inheritance ledger as 「A state whose only exit is machine-gated is only as real as the line the machine reads」. Knowing the rule did not stop me repeating it; the gate did. That is the gate earning its keep, and it is the honest reading of this red.

The remedy, in the order the guard specifies — ⛔ not the order that would have been convenient

The guard is explicit that disarming auto-merge alone does not dequeue, and that stripping the label to get past the check with no verdict on record is 「the defect this leg was built from — not a way through it」. The verdict here is on record, so the strip is legitimate; the order still is not optional:

step act reading
1 POST …/ccr/convert_to_draft {"draft":true} ⇒ timeline gains convert_to_draft 08:33:57Z and removed_from_merge_queue 08:34:02Z — the dequeue actually happened, which is the half the guard warns is easy to skip
2 strip the carrier from the PR labels documentation, needs:contract-review, size/m, tests, tooling → documentation, size/m, tests, tooling, read back
3 verify both carriers card #17672 → domain:cli, pm:dispatched, priority:p2 (already clear); PR → clear ⇒ both
4 check-clause2-carriers --pair 17815 ✓ "the clause-② declaration is readable in the fixed spelling and both carriers agree, and a review of record names this head"
5 re-enqueue ready_for_review 08:34:22Z → added_to_merge_queue 08:34:23Z

⇒ the PR is back in the queue with both carriers clear and a verdict naming this head. ⛔ Nothing about the diff changed, ⛔ no check was edited, and ⛔ the review was not re-run to paper over the sequence.

What this red was NOT

⛔ Not a failure of this PR's code — the same merge group's CI, Spec Liveness Check and Closing-Target Claim Guard all passed, and CI on the head is 41 green / 7 skipped / 0 red. ⛔ Not the fail-closed-over-cancelled aggregator pattern, which is a different red with a different cause (that one was diagnosed separately at 5644530293). This one was a real refusal of a real violation, and reading the job log rather than the check name is what told the two apart.


Generated by Claude Code

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… read gate RestServer asks (objectstack-ai#20193) (objectstack-ai#20236)

Fixes objectstack-ai#20193

Clause-②: yes

The runtime dispatcher's `/meta` item read and its `/published` read now
use the per-caller read gate that `RestServer` uses. It is one function
with two callers. `RestServer`'s gate moved unchanged into
`packages/rest/src/meta-item-read-gate.ts`, and `handleMetadataRequest`
calls it. There is no second audience resolver in `packages/runtime`, as
ruling `5793362670` item 1 requires.

objectstack-ai#20156 remains open (its pending decision on the partial `app` cells of
`/layers`, `?layers=true` and `/diff` is untouched here). objectstack-ai#20139 remains
open.

## The measurement that picked route A

Triage said the choice between B and A is a measurement. Here it is.

### Host census: which in-repo host compositions reach
`handleMetadataRequest`'s item branch

| host composition | REST mounted? | who answers `GET /meta/:type/:name`
| can REST be mounted there? |
|:--|:--|:--|:--|
| `@objectstack/hono` `createHonoApp` (published; README and
`content/docs/plugins/packages.mdx` name it the edge / serverless
adapter) | **no** | **the dispatcher**: `app.all(prefix + '/*')`, then
`dispatch()`, the domain registry, and `handleMetadataRequest` | **No,
as shipped.** `RestServer` registers on an `IHttpServer` (`http.server`
service). `createHonoApp` builds a bare Hono app whose catch-all is
terminal by design (ADR-0076, open question 9), and `@objectstack/hono`
does not depend on `@objectstack/rest`. |
| a thin adapter on the public `HttpDispatcher` API (`packages.mdx`:
"build a thin adapter on the public `HttpDispatcher` API") | no | the
dispatcher (`dispatch()` / `handleMetadata()`) | No, by construction |
| `plugin-hono-server` + `createRestApiPlugin` +
`createDispatcherPlugin`: the CLI's `serve` / `dev`
(`packages/cli/src/commands/serve.ts`) and `plugin-dev` | yes |
`RestServer`. `createDispatcherPlugin` mounts explicit routes and **no**
`/meta` route, so the dispatcher item branch is never reached | already
mounted |
| `packages/qa/http-conformance` `node:http` reference adapter (private
QA) | yes | `RestServer` | already mounted |
| `packages/verify` handle | no | never reaches `/meta` (it dispatches
`/automation` and `/actions` only) | n/a |
| cloud hosts (`objectstack-ai/cloud`, per ADR-0076 item 9: plugin
routes with the `createHonoApp` catch-all underneath) | yes |
`RestServer` first; the dispatcher answers REST misses | NOT MEASURED
here: another repository |

Reading. The documented embed shape cannot mount REST, and ADR-0076 item
9 records the dispatcher's `/meta` branches as "the cloud fallback
fabric, not dead code", not killable while the catch-all stands. So
**B** (retire the dispatcher's item reads) would remove a published
answer (`meta.getItem` / `meta.getPublished` in the route ledger) from a
documented host, and would reverse an accepted ADR. **A** is the route:
one transport-neutral gate that both transports call. **C** is ruled out
by `5793362670`.

### What the two item reads serve (B's scope, for the record)

- **Dispatcher item branch:** `GET` / `HEAD /meta/:type/:name`,
`/published`, `?package=`, `?preview=draft`, and a
`MetadataService.getItem` fallback. The body is enveloped `{ success,
data }`. Objects get the ADR-0106 mask on the plain read.
- **REST:** the same two reads, plus `/layers`, `?layers=`, `/history`,
`/audit`, `/diff`, `/references`, `?state=draft`, locale collapse
(`resolveDocLocale` / translation), and ETag / 304 on the cached arm.
The body is the bare `{ type, name, item, … }` envelope.

Route A retires nothing, so no answer is dropped. The item branch keeps
every one of its own parameters.

## Before and after

All rows are driven through `dispatch()`, the delegate of
`createHonoApp`'s catch-all: identity resolution, the domain registry
and the handler. The caller is an authenticated member who does not hold
`crm_admin`, on the fixtures of the REST door census
(`meta-alternate-door-read-gates.test.ts`). The "before" column is at
base `7e7fab73`. The "after" column is at head `0fcb064a2`, and the
first five rows were also re-read through a **real `createHonoApp` app**
(`app.request(…)`, a real `HttpDispatcher`, as a one-off probe that is
not committed).

| request (non-holder) | before | after | `RestServer`, same caller |
|:--|:--|:--|:--|
| `GET /meta/doc/crm_admin_runbook` | 200 + the gated body | **403
`PERMISSION_DENIED`** | 403 `PERMISSION_DENIED` |
| `GET /meta/doc/crm_admin_runbook/published` | 200 + the gated body |
**403 `PERMISSION_DENIED`** | 403 `PERMISSION_DENIED` |
| `GET /meta/book/admin_guide` (set-gated) | 200 + the book | **403
`PERMISSION_DENIED`** | 403 `PERMISSION_DENIED` |
| `GET /meta/app/crm` | 200, 3 entries | **200, `[nav_leads]`** | 200,
`[nav_leads]` |
| `GET /meta/app/crm/published` | 200, 3 entries | **200,
`[nav_leads]`** | 200, `[nav_leads]` |
| `GET /meta/book/admin_guide/published` | 200 | 403 `PERMISSION_DENIED`
| 403 |
| `GET /meta/app/payroll` (+ `/published`) | 200 | 403
`PERMISSION_DENIED` | 403 |
| `GET /meta/app/launchpad` (unpublished, + `/published`) | 200 | 404
`RESOURCE_NOT_FOUND`, byte-identical to a missing name on this transport
| 404 `RESOURCE_NOT_FOUND` |
| `GET /meta/dashboard/ops` (+ `/published`, holder too) | 200, both
widgets | 200, `[w_open_cases]` | 200, `[w_open_cases]` |
| `GET /meta/object/invoice/published` | 200, `[amount, secret_margin]`
| 200, `[amount]` | 200, `[amount]` |
| `GET /meta/docs/crm_admin_runbook` (plural) | 200 + body | 403
`PERMISSION_DENIED` | 403 |

Controls, unchanged: a holder reads the doc body (both doors), the book,
and the whole app. `GET /meta/object/invoice` is masked for the member
and served whole to the exempt holder. An anonymous caller gets `401
UNAUTHENTICATED` before any read.

Through the real `createHonoApp`, at head: non-holder doc / doc
`/published` / book → `403 PERMISSION_DENIED` with no secret on the
wire; app and app `/published` → `200 [nav_leads]`. Holder: `200` with
the doc and book bodies, and all 3 app entries.

## How

- **`packages/rest/src/meta-item-read-gate.ts` (new).**
`createMetaItemReadGate(sources, metaType, name, documents, policy)` is
the former `RestServer#metaItemReadGate` body. The helpers it calls came
with it unchanged: `filterAppForUserWithReason`,
`filterDashboardForUser`, `resolveDocsAudience`,
`resolveAudienceCaller`, the fault-reporting books and doc-corpus reads,
`resolveRegisteredServices`, `resolveNavServability`,
`resolveNavDocAudience` and `loadObjectItems`. Its verdict is **data**
(`serve`, or `refuse` with `absent` / `app-permission` /
`docs-audience`). Each transport supplies only I/O
(`MetaItemReadGateSources`): the caller, a list read, the security
service, a service probe, and a prune-log dedupe set.
- **`RestServer`** keeps every private helper name as a one-line
delegate, so its list routes, book tree and doors call the same code.
`metaItemReadGate` maps the data verdict to the emitters it always used
(`sendMetaItemAbsent`, `sendError`, `sendDeclaredFault`). REST's answers
are byte-for-byte unchanged: the 196-case door census and the rest of
the package's 3,558 tests pass.
- **`handleMetadataRequest`** calls the gate on whichever lookup answers
the item read and `/published`. The call sits *outside* the lookups' own
swallowing `try`s, so a gate fault is answered as that fault and never
as "not found". It uses policy `{ arms: 'all', app: 'gate' }`, the same
one `RestServer`'s plain read and `/published` use. Refusals use the
dispatcher's own envelope with `RestServer`'s status and code. `absent`
is the same `deps.error('Not found', 404)` a missing name gets.
- **`@objectstack/rest` exports** `createMetaItemReadGate` and its
types. This is the same pattern as `repeatedQueryParamMessage`: the
decision travels, nothing transport-shaped does.

**Also closed in the same claimed branch (bounded in-place fix, all four
conditions hold).** The dispatcher's `/published` did not apply the
ADR-0106 object mask that its own plain read applies. `GET
/meta/object/invoice/published` served `secret_margin` to a member whose
readable set is `[amount]` (row above). This is the same defect class:
this transport's `/meta` item doors skipping a per-caller read gate that
`RestServer` applies. The fix is mechanical: the dispatcher's existing
`maskObjectSchema` call, ordered after the gate as in `RestServer`'s
`/published`. It is in the claimed `/published` branch and pinned by the
same census. The card's own reading assumed "objects are masked here".
That held for the plain read and not for `/published`.

## Tests (head `0fcb064a2`)

- **Pin:**
`packages/runtime/src/domains/meta-item-read-gate-parity.test.ts`, 77
cases. It drives the same fixtures through `dispatch()` and through
`RestServer`. For each caller (holder, non-holder, anonymous) it asserts
equal `status` + `code` and the same served document (nav ids, widget
ids, field names, secrets present or absent) on the plain read and on
`/published`, for doc, book, app ×3, dashboard, object and view. It also
asserts the reference answers `RestServer` gives, plus five controls.
- `pnpm --filter @objectstack/runtime exec vitest run --project local`:
**280 files, 3986 passed, 1 skipped**.
- `pnpm --filter @objectstack/rest exec vitest run --project local`:
**200 files, 3558 passed, 1 skipped**.
- `pnpm --filter @objectstack/rest typecheck` and `pnpm --filter
@objectstack/runtime typecheck`: exit 0. Both test layers are OK, with
the runtime debt ledger unchanged.
- The `execctx-consumer-census` counts move by the gate's relocation: 70
→ 68 sites and 93 → 92 mentions. Three same-line-caught sites left with
the gate, and one caught caller port replaced them. Each number carries
its arithmetic in the test.

**Ablation.** The fix was committed first. Each leg went through
`scripts/ablation-replace.mjs` (WRAP mode, anchor hit 1 → 0, blob
changed), with a shell `trap` restore, and each restore was proven by
blob equals `HEAD` and an empty `git diff HEAD`. The subject resolves
from `src`: `../http-dispatcher.js`, and `@objectstack/rest` is aliased
to `src` in the runtime vitest config, so no `dist` leg applies.

- Gate leg (`gateMetaItemDocument` serves without judging): **16 red.**
These are all 14 non-holder / dashboard parity rows of the before-table
plus the plural and unpublished-app controls. The object `/published`
row stays green because it is the mask's.
- Mask leg (`/published`'s `if (publishedMasker)` disabled): **1 red**,
`GET /meta/object/invoice/published × non-holder`.
- Restore leg, unmutated: **77/77 green.** The direction was the
expected one: red.

**Gates.** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derived 70 commands for these 9
paths. All 70 exited 0 at `0fcb064a2`, and `--ran` reconciled them: 70
derived, 70 run, 0 NOT-MEASURED (a derived zero from recorded exit
codes). Also at that head:

- `pnpm lint`: full run, exit 0, 142 s.
- `node scripts/check-issue-citations.mjs --base origin/main`: exit 0.
- These roster and wide-population gates, which read these directories,
all exited 0: `check-changeset-fixed`, `check-published-list-mirrors`,
`check:error-code-casing`, `check:route-ledger-census`,
`check:published-readme-exports`, `check:meta-type-normalized`,
`check:init-service-contract`, `check:startup-registry-verdict`,
`check:wildcard-fallthrough`, `check:optional-error-sink`,
`check:filter-alias-parity`, `check:console-injection`,
`check:i18n-stale-fill`.

`check:doc-authoring` first went red for a moved string. The nav-prune
log line carried `[objectstack-ai#7912]` into the new file. The id now sits in an
adjacent `//` comment, and
`scripts/doc-authoring-prose-id.baseline.json` shrinks by that one
pinned pair (`--census-ledger`, shrink only).

## Patch round 1 (head `acb99baa9`)

Contract review `5856229893` answered FAIL on `0fcb064a2`, and the
seat's REWORK is `5856245736`. This round makes three changes and merges
`origin/main` (`6d38c526f`, then `acb99baa9`).

1. **Liveness ledger re-anchored.** This fixes the `Test Core (1/6)`
red: 「8 anchored citation(s) name a symbol the cited file does not
contain」. Each pointer was re-measured against the site that now reads
the key, and its entry is stamped `verifiedAt: 2026-09-27`.
- `app.json` `navigation.requiredPermissions` and
`navigation.requiresService` now point at
`meta-item-read-gate.ts#filterNav`.
- `book.json` `name` now points at
`meta-item-read-gate.ts#deriveImplicitPackageBook` (the `bookNamed`
lookup).
- `doc.json` `name` now points at
`meta-item-read-gate.ts#resolveDocAudiences` (`docReader`:
`audiences.get(docName)`). `order` and `group` point at
`meta-item-read-gate.ts#docCorpusOf`, the list-branch corpus projection.
- `doc.json` `description` and `tags` point at
`rest-server.ts#readableTree`. The tree route's `{ name, label,
description, order, group, tags, packageId }` projection stayed in
`rest-server.ts`, and the new file does not name `description` at all:
the key-mention check refused that first repoint. So the pointer names
the site that reads the key. `tags` also points at
`meta-item-read-gate.ts#resolveBookTree`.
- Also moved, although they still resolved as text (a docblock mention
in `rest-server.ts` satisfied the anchor):
- `app.json` `requiredPermissions`, `_unpublished` and the file's
`_note` now point at
`meta-item-read-gate.ts#filterAppForUserWithReason`;
- `book.json` `audience` now points at
`meta-item-read-gate.ts#audienceAllows` (`admitsBook`);
- `dashboard.json` `widgets.requiresService` now points at
`meta-item-read-gate.ts#filterDashboardForUser`.
- Left alone: row 18 of
`docs/adr/0056-permission-model-landing-verification.md` cites
`rest-server.ts#filterAppForUser`. That is still a live declaration (the
delegate REST's list route calls), and the file is a governed surface.
- Results: `pnpm --filter @objectstack/spec run check:liveness` exit 0
(「758 pointer(s) written `path#symbol`, 758 naming a symbol the cited
file contains」). `scripts/liveness/check-liveness.test.ts`: 64 passed,
where 21 were red in CI. All `scripts/liveness/` tests: 252 passed
(local project) and 81 passed (repo project).
2. **`Clause-②: yes`.** `@objectstack/rest`'s only export subpath gains
`createMetaItemReadGate` and five types: `MetaItemReadGateSources`,
`MetaItemReadVerdict`, `MetaItemReadRefusal`, `MetaReadGateCaller` and
`MetaReadGatePolicy`.
- They are public because the runtime dispatcher consumes this one gate,
and `@objectstack/rest` cannot import the runtime.
   - Precedent: objectstack-ai#17672 / PR objectstack-ai#17815.
- The changeset now carries the declaration line and names the exports.
The bumps are unchanged.
- `node scripts/check-adr-0087-registration.mjs --base origin/main`:
exit 0. `node scripts/check-changeset-no-major.mjs --base origin/main`:
exit 0.
3. **Composed-host pin, committed:**
`packages/qa/http-conformance/src/hono-meta-item-read-gate.conformance.test.ts`,
7 cases. It boots a real `LiteKernel` and the real `createHonoApp`, and
drives them with `app.request(...)`.
- Non-holder: the doc, the doc's `/published` and the set-gated book
answer `403`, with `error.code` `PERMISSION_DENIED`, `success: false`
and no secret on the wire. The `crm` app answers `200`, pruned to
`[nav_leads]`.
   - The holder control is served all four in full.
- **Why this package and not `packages/adapters/hono`:** that package's
`vitest.config.ts` aliases `@objectstack/runtime` to a stub
(`src/__mocks__/runtime.ts`) for every test. `createHonoApp` imports
`HttpDispatcher` from that specifier, so a host composed there would
compose the stub. `http-conformance` is where the repo already boots the
two for real (`hono-dispatcher-result-response.conformance.test.ts`):
`@objectstack/hono` is aliased to source, and the runtime resolves
through `dist/`, a ledgered pair in `check-test-source-alias`.
- **Per-PR tier:** `@objectstack/http-conformance` is in `turbo ls
--affected` for this diff, because it depends on `@objectstack/runtime`
and `@objectstack/hono`. `Test Core`'s PR shards therefore collect it;
they exclude only `@objectstack/dogfood`.
- **Ablation (through `dist/`).** The mutate leg replaced the gate call
`verdict = await judge(document);` with a serve-as-stored verdict
carrying the marker `ablated20193`, then rebuilt the runtime.
`ablation-dist-preflight` found the marker in `dist/index.js` and
`dist/index.cjs`. Result: **4 failed | 3 passed**, exactly the four
non-holder rows red and the holder controls green. The restore leg put
the file back (blob equals `HEAD`, `git diff HEAD` empty), rebuilt, and
`--absent` found no marker with a clean tree. Result: **7/7**. Both legs
ran at `b58d036ed` and again at `acb99baa9`.

**Verification at `acb99baa9`:**

- runtime local tests: 281 files, 4016 passed, 1 skipped;
- rest local tests: 201 files, 3576 passed, 1 skipped;
- `@objectstack/hono`: 5 files, 122 passed;
`@objectstack/http-conformance`: 7 files, 96 passed;
- both typechecks: exit 0;
- `pnpm lint`: exit 0;
- `node scripts/check-issue-citations.mjs --base origin/main`: exit 0;
- `dispatch-gates --commands` derived 77 commands, all ran, and `--ran`
reconciled 77 of 77 with exit codes (0 NOT-MEASURED).

## Changeset

`.changeset/20193-dispatcher-meta-read-gate.md`:

- **`@objectstack/runtime`: `patch`.** A fix in a released package
(Post-Task Checklist step 3).
- **`@objectstack/rest`: `minor`.** It adds public exports
(`createMetaItemReadGate` and five types), the same bump the changeset
for `repeatedQueryParamMessage`'s publication took. REST's own behaviour
is unchanged.

`Clause-②: yes`: `@objectstack/rest`'s published export surface widens.
Its only export subpath gains `createMetaItemReadGate` and five types,
which are public because the runtime dispatcher consumes this one gate
(precedent objectstack-ai#17672 / PR objectstack-ai#17815). The dispatcher's refusals themselves are
not the widening: they pull a second transport back to the gate the
contract already declares (ADR-0046 §6.7, ADR-0045 §3, ADR-0106,
`apps.mdx`'s `requiredPermissions` row). No accept set widens, and
nothing authorable moves. ADR anchor added:
`scripts/adr-anchors/packages__rest__src__meta-item-read-gate.ts.json`
(ADR-0045, ADR-0046).

## Acceptance notes

- **Same family, outside this card's claimed surface: the dispatcher's
`/meta/:type` LIST read is ungated too.** Measured through `dispatch()`
for the same non-holder at head:
- `GET /meta/doc?include=content` → 200, `crm_admin_runbook` **with its
body**;
  - `GET /meta/book` → 200, `admin_guide` with its description;
- `GET /meta/app` → 200, `payroll` (app-level `requiredPermissions`) and
`crm` with `nav_finance_ledger`.

`RestServer`'s list route answers `[crm_intro]`, `[]` and `[crm]`
pruned. Fixing it needs `RestServer`'s LIST filters extracted the same
way, which is a second seam and not mechanical, so it was filed
separately as objectstack-ai#20237 rather than folded in here. objectstack-ai#20237 remains open.
- The before-table was taken through `dispatch()`. Through a real
`createHonoApp` host, the four rows are now a committed pin
(`hono-meta-item-read-gate.conformance.test.ts`). Its ablation leg, with
the gate call removed and the runtime rebuilt, is the Hono-level
*before* reading: the four non-holder rows go red, and the holder rows
stay green.
- A host whose `protocol` has no `getMetaItems` now answers a doc or
book item read with the fault (fail closed, ADR-0049) instead of the
ungated body. `RestServer` has the same requirement.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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/m tests tooling

Projects

None yet

1 participant