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

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
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 Apackages/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 3PREREQUISITE 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 filedreadRequestedVersion 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 2070a1d865d98cd0f0299441f36d278c45ecdfdfpackageMentionDocs.

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

ACCEPTdomain: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 readRequestedVersion5 ⇒ 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 (:178metadata-service.mdx, :247client-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, toolingdocumentation, size/m, tests, tooling, read back
3 verify both carriers card #17672domain: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

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