Skip to content

fix(runtime): canonicalise the API root to the discovery route so a gated bare-root request is not refused (#17625) - #17691

Merged
os-sales merged 3 commits into
mainfrom
claude/issue-17625-dispatcher-bare-root-normalise
Sep 11, 2026
Merged

fix(runtime): canonicalise the API root to the discovery route so a gated bare-root request is not refused (#17625)#17691
os-sales merged 3 commits into
mainfrom
claude/issue-17625-dispatcher-bare-root-normalise

Conversation

@claude

@claude claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Fixes #17625

The runtime half of ruling A on #7898 — maintainer verbatim 「其他同意」 on decision batch #114. Delivered by the shape the domain:engine seat labelled A (comment 5632718273): normalise and make the bare root resolve to the discovery route, leaving core's allow-list alone. No fork: shape A was reachable inside packages/runtime alone.

Clause-②: yes

That is the declared verdict on the contract-review axis, and it is re-judged from the delivered diff rather than inherited. The card body's verdict on that axis was negative, written by the filing seat before the engine seat measured anything. The delivered diff re-admits an input class the merged tree currently refuses, and it does so on an authorisation surface: GET ${prefix}/ goes from 403 to 200 for a session carrying an authGate posture. That is enough to grade the axis affirmative on its own, so needs:contract-review is owed and carried on both carriers (this PR and the card). ⛔ Not to be flipped ready and ⛔ not to be enqueued — the PM arms landing.

What was wrong

HttpDispatcher.dispatch() strips one trailing slash, so both root spellings it accepts collapsed onto the empty string: ${prefix}/ arrives as / and ${prefix} arrives as the empty string (the MSW / base-URL-stripped form, which the discovery branch's own comment already documented). Only the discovery branch at the foot of the method knew that empty string meant the API root. The ADR-0069 gate, which runs far above it, did not.

That disagreement was invisible while isAuthGateAllowlisted answered true for a falsy path. #7898 made the predicate fail-closed at source — exemption is now something a path has to EARN by naming an allow-listed route — and the bare-root discovery request started answering 403.

What landed, and why it is not the card's stated one-liner

The card's stated mechanism was '' -> '/'. That was measured necessary and not sufficient by the engine seat, and this round re-drove it end to end through the real dispatcher as ablation leg B. The measurement came back stronger than relayed:

tree gated GET ${prefix}/ ungated GET ${prefix}/
origin/main (ablation leg A — canonicalisation deleted) 403 PASSWORD_EXPIRED 200
the card's stated '' -> '/' (ablation leg B) 403 PASSWORD_EXPIRED 404 ROUTE_NOT_FOUND
this PR 200 discovery document 200 discovery document

So '' -> '/' does not merely relocate the gated 403 — it also breaks the API root for every session, gated or not, because / satisfies neither arm of the discovery branch. Reported as a measurement about a fix nobody applied, ⛔ not as a defect on main.

The root is canonicalised to /discovery instead — the route it has always served — read from one constant (DISCOVERY_ROUTE) by both the canonicalisation and the branch that serves it, so the two cannot drift into a third disagreement about what the empty path means. This is the same "one convention, read once" argument the surrounding file already makes about the scoped-URL strip and the derived liveness set.

⛔ No allow-list was widened, and packages/core is untouched

packages/core/src/security/auth-gate.ts:191:193 names this card by number and fences the wrong fix in advance — 「⛔ not a tolerance to add back」. Honoured literally:

  • packages/core has zero changed bytes; ALLOW_ROUTES is unchanged.
  • The dispatcher does not re-derive the pathless tolerance either. A caller that reaches the gate with no path is still refused at the predicate, and the genuinely-pathless case stays declared where it lives (shouldDenyAnonymous).
  • The only input whose gate answer moves is the API root, and it gains exactly the exemption /discovery already carried, by being that route. No new information becomes reachable: /discovery was already allow-listed and already on the membership skip list.

Scope

Nothing was added to the declared file surface. The claim declared packages/runtime/src/http-dispatcher.ts plus its sibling tests and a .changeset/, and the diff is exactly that:

path what
packages/runtime/src/http-dispatcher.ts the canonicalisation, the shared constant, the branch reading it
packages/runtime/src/http-dispatcher.root-auth-gate.test.ts new sibling test — the three pins plus their controls
.changeset/17625-api-root-is-the-discovery-route.md @objectstack/runtime patch

Two consequences of canonicalising before the gate are called out rather than left to be discovered, since both are stages the root now reaches under its route name:

  1. Project membership. The root is now on the control-plane skip path that /discovery was always on. Pinned already by http-dispatcher.membership-skip-boundary.test.ts; no new exposure, because the document the root serves is the document /discovery serves to the same caller.
  2. context.routePath. A root request records /discovery rather than the empty string. This is asserted directly as the mechanism pin, so it is observed rather than incidental.
  3. Who may claim the root. this.domainRegistry.resolve(cleanPath, method) runs before the discovery branch, so a plugin registering { prefix: '/discovery' } through registerDomainHandler now captures the API root as well — it could not before, because the root arrived as the empty string. Sharper edge, stated rather than left to be derived: { prefix: '/discovery', liveness: true } would put the root on the liveness carve-out, ahead of the auth gate. ⛔ No behaviour changes today — nothing in this repo registers that prefix (zero hits under packages/runtime/src/domains/, against a firing control). It is this PR's own thesis applied consistently: the root is the discovery route, so it inherits what /discovery inherits, including who may claim it — the same reasoning that makes consequences 1 and 2 correct. Declared here because it is the one with a security-adjacent edge.

⛔ Read-only and untouched, as the claim required: packages/core/src/security/auth-gate.ts and core's ALLOW_ROUTES.

The three pins the card names

  1. GET ${prefix}/ on a gated session still returns the discovery payload — PIN 1.
  2. GET ${prefix} (no trailing slash) unchanged — PIN 2.
  3. A request with a genuinely absent path is still refused by the gate — ⛔ not restated here. It is packages/core's pin, delivered by [Decision] Fail-close isAuthGateAllowlisted's "no path ⇒ exempt" default (Option 2 of #7432) #7898's own round in packages/core/src/security/auth-gate.test.ts ("[[Decision] Fail-close isAuthGateAllowlisted's "no path ⇒ exempt" default (Option 2 of #7432) #7898] a falsy path is not exempt (fail-closed)", driving the predicate over absent, null and empty). Referenced from the new test file's docblock, which also states why restating it against HttpDispatcher would measure nothing: this transport has no pathless call shape, since dispatch() takes path: string.

Every case runs on a fixture whose gate is provably on: enforceAuthGate fails open in many ways, so a 200 on the root is worthless without a request the same fixture refuses. The positive control asserts 403 with PASSWORD_EXPIRED on /data/task, and ablation leg B independently shows it can fail.

Evidence

Derived gate rosternode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, every command run with $? captured before any pipe, then reconciled with --ran carrying the codes:

Run reconciliation — 59 derived, 57 run, 2 NOT-MEASURED, 0 UNRUN.
  EXIT CODES — all 59 accounted famil(ies) carry one, so the NOT-MEASURED count above is DERIVED from them.

Exit-code tally: 57 x exit 0, 2 x exit 3, 0 failures, 0 unrun.

⊘ The two at exit 3 are PREREQUISITE NOT MET and are NOT MEASURED — neither pass nor failure. Both refuse their own prerequisite because they read whole-tree built output, and this card's derived closure build covers only @objectstack/runtime's dependencies:

  • pnpm check:dual-build-cjs-loads"PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/. … ⛔ This is NOT a pass: nothing was measured."
  • pnpm check:type-check-debt"--re-measure cannot run: 2 workspace dependenc(ies) … have no built type entry point on disk … ⛔ This is NOT a pass and NOT a finding."

The first derivation (before the changeset existed) named 52; writing the changeset added the 7 changeset-derived families, and all 7 are inside the 59 above.

Full lint union — this lane's known blind spot in dispatch-gates, added explicitly, run at final commit 76d25207:

pnpm eslint . --no-inline-config --format json   ->   exit 0
6634 files linted · 0 errors · 0 warnings

Both changed source files appear in eslint's own --format json output, each at 0/0: packages/runtime/src/http-dispatcher.ts and packages/runtime/src/http-dispatcher.root-auth-gate.test.ts.

Tests and typecheck — through scripts/pm/os-verify-lock.sh, whose VERDICT command-exit line is the reading:

targeted   exit=0   6 test files, 61 tests passed
typecheck  exit=0   @objectstack/runtime
coretest   exit=0   packages/core/src/security/auth-gate.test.ts — 22 tests (the referenced pin)

Targeted set: the new file plus http-dispatcher.root, http-dispatcher.scoped-url-strip, http-dispatcher.liveness-carve-out, http-dispatcher.membership-skip-boundary and domains/auth-claim-segment-boundary — the neighbours that read cleanPath at the stages the canonicalisation now precedes. Dependency closure built first: pnpm --filter '@objectstack/runtime^...' buildVERDICT command-exit 0.

Ablation — two legs against the committed fix, each mutated on disk, run, restored, with the restore proven by hash rather than by an exit code. The subject resolves from source (the test imports the sibling module, no dist in the path), so no rebuild leg applies; the on-disk proof is still recorded, because an editing tool exits 0 on zero matches.

HEAD blob 52e51268d7b17bdba56ffbc10a354476ee9ca917

LEG A  canonicalisation deleted        anchor 1 -> 0, injected 1   4 failed | 5 passed
       PIN 1: AssertionError: expected 403 to be 200
LEG B  canonicalisation targets '/'    anchor 1 -> 0, injected 1   5 failed | 4 passed
       PIN 1: AssertionError: expected 403 to be 200
       ungated control: AssertionError: /: expected 404 to be 200
each leg restored:  git diff HEAD EMPTY · hash == HEAD blob
CONTROL  unmutated tree   exit 0   9 passed (9)

Control bytesgrep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' over all three changed files: no match (exit 1). pnpm check:nul-bytes: exit 0.

content/docs measurement — zero pages falsified, with the control that makes the zero a reading. Docs Drift Check named its own blind spot (a page stating a rule by its inputs shares no token with the emitter), so this was searched on the behaviour vocabulary rather than on any identifier the diff moved.

Positive controls first, same corpus and same command form: the literal (and `GET /api/v1/discovery`) hits 2 files, the regex GET /api/v1 hits 44. A zero from the sibling searches is therefore a reading and not a broken command. ⚠️ A first control was discarded precisely because it returned zero — the phrase it used is hard-wrapped across two lines in both pages, so it could never have hit; recorded because a control that silently fails would have validated nothing.

Negative searches, all 0 files: the root paired with 401/403/404 · a request line GET /api/v1/ with a trailing slash · "bare root" · routePath · discovery described as reachable at one path only · the root described as requiring auth.

The two hand-written pages that do state the behaviour are confirmed by this change, not falsified — content/docs/api/index.mdx:103 and content/docs/protocol/kernel/http-protocol.mdx:26 both head a section GET /api/v1 (and GET /api/v1/discovery) and say the two paths are one handler, one document, not a redirect and not two shapes. On post-#7898 main those sentences were false for a gated session, because the root answered 403; this PR makes them true again. Two further near-misses were read and cleared: content/docs/permissions/authorization.mdx:51 lists the allow-listed control plane as /auth, /health, /discovery — still exactly true, since ALLOW_ROUTES is unchanged and the root arrives as /discovery rather than as a new entry; and the two "one trailing slash trimmed" statements are about the declarative-endpoint matcher under /apps/, a different seam this diff does not touch.

No documentation change is owed, and nothing was added to the declared surface — bounded as stated under Acceptance notes: this is a reading about pages that state the bare-root or trailing-slash behaviour, ⛔ not a claim about every page that happens to contain the word discovery.

Re-verified at head 6c31e741 (the only difference from 76d25207 is the changeset file — git diff --stat is one file, 14 insertions, 1 deletion): the changeset gate reproduced red then green, the full 59-family roster re-run at 57 x exit 0 / 2 x exit 3 / 0 failures / 0 unrun, and the lint union again exit 0, 6634 files, 0 errors, 0 warnings with both changed files present at 0/0.

⚠️ CI convergence is not claimed here. The 47 artifact-roster families, the 11 wide-population families, the 5 path-scheduled CI jobs and the always-runs tail are each outside the 59 derived, as dispatch-gates states in its own output; those are CI's runs, not this round's.

ADR-0087 disposition

No ledger entry is owed and no marker is required. The changeset declares no breaking change, which is the only condition under which check:adr-0087-registration demands a disposition marker — and the gate ran green. On the substance: no ADR-0087 shape surface moved. The diff touches one packages/runtime transport file, its sibling test and the changeset — no schema module, nothing under packages/spec, no contracts entry, no object definition — so objectstack migrate meta has nothing to reach, and no authorable key, accept set or stored shape changes.

Nor is this an ADR-0087 conversion-layer entry. Nothing lenient is being accepted from a metadata producer; one transport's two spellings of its own route are being reconciled to the route's own name. That is a dialect removed, not tolerated — the direction Prime Directive #12 asks for.

Changeset level: minor on @objectstack/runtime, which publishes.

⚠️ It was first graded patch, on the reasoning that a 403 which should be a 200 is a fix rather than a feature. Check Changeset failed that on head 76d25207 and was right to: the level is a mechanical floor, not an editorial reading of the act. An affirmative clause ② on a package whose packages/**/src/** the diff moves takes at least minor (maintainer ruling 2026-09-04, decision batch #35, on #15294, written out under WHICH LEVEL in the Check Changeset step). The commit type may raise a bump but never lower it below what the act requires, so the type stays fix(runtime) and only the level moved — the two are independent. ⛔ The declaration was not softened to fit the level, and neither the gate nor the workflow was touched.

Acceptance notes

Docs drift — the seat's own measurement, with its controls and its bound

Docs Drift Check reports "nothing to list — not a clean bill of health" and names its own blind spot: a page stating a rule by its inputs shares no token with the emitter, so an emitter-only diff can never list it. Measured by hand on origin/main rather than accepted:

probe result
pages stating what the bare root answers, or the trailing-slash rule 2content/docs/api/declarative-endpoints.mdx:207 and content/docs/protocol/kernel/http-protocol.mdx:1215
pages naming routePath 0
positive control /api/v1/ 101 pages — the channel fires
fabricated token 0

Both of the two say the same thing — "one trailing slash trimmed — the same rule the matcher applies — so /x and /x/ are the same claim" — and neither is falsified. The trimming still happens exactly as documented and ${prefix} / ${prefix}/ still resolve identically; what this diff decides is what the empty result then MEANS, which neither page states. ⭐ The boundary note below corroborates it from the other side: ${prefix}// strips to /, not to the empty string, so it is not the root — which is precisely "one trailing slash trimmed" still holding.

⚠️ Stated as a bound, not dropped: the word discovery appears in 64 pages and is far too generic to anchor anything — the drift check says so itself ("1 name(s) were too generic to anchor anything (single lowercase words)"). Those 64 were not individually read. So the claim is precisely: no page stating the bare-root or trailing-slash behaviour is falsified, measured with controls. ⛔ It is not the claim that no page anywhere is affected.

A second, independent pass over content/docs (recorded under Evidence) reached the same verdict from different probes and additionally read the two pages that state the root//discovery equivalence — content/docs/api/index.mdx:103 and content/docs/protocol/kernel/http-protocol.mdx:26 — which this change confirms rather than falsifies. Two measurements, different probes, same answer; the bound above governs both.

Noted, not filed — neither meets the filing bar, and both are recorded as boundary pins in the new test file rather than as prose alone, so the next reader of this file finds them as executable statements:

  • The environment-scoped root ${prefix}/environments/ENV_ID is refused for a gated session. It matched no allow-listed route before [Decision] Fail-close isAuthGateAllowlisted's "no path ⇒ exempt" default (Option 2 of #7432) #7898 either — the gate runs ahead of the scoped-URL strip, so that request is judged on its own scoped spelling — so its answer moved in neither card. Deliberately untouched: widening it is precisely the direction the ruling steered around, and discovery stays reachable by its own name. Pinned as a boundary case.
  • ${prefix}// strips to /, not to the empty string, so it is not the root and is not canonicalised. Recorded so a later reader does not widen the rule into "any number of trailing slashes is the root". Pinned.

Successor for both: this file and the ADR-0069 gate lane — the same surface this PR touches, which is why they are pinned inside its own sibling test rather than filed.

Noted, not filed (⛔ and deliberately not fixed here — it is outside the declared surface): in the Check Changeset run that read the carrier correctly, the heading "The two declarations disagree, inside one PR" sits above two readings that agree (carrier present, declaration affirmative). The heading is emitted ahead of the readings, so when the real finding is the level rather than a carrier/line split it misdescribes itself. Cosmetic only — the block underneath states the actual finding correctly, and it is what this PR acted on.

One convention note, no action: enforceAuthGate builds its 403 as this.error(message, 403, { code }), so the gate code travels in the envelope's details.code rather than error.code. The new test reads whichever of the two carries it, so it pins the decision and not the spelling.

Generated by Claude Code, session session_01TSf4DV7ziu4V5j73e46b7c — recorded in prose because a raw REST body edit appends its own footer block.


Generated by Claude Code

`dispatch()` strips one trailing slash, so both root spellings the dispatcher
accepts — `${prefix}/` (arriving as `/`) and `${prefix}` (arriving as ``, the
MSW/base-URL-stripped form) — collapsed onto the empty string. Only the
discovery branch at the foot of the method knew that meant the API root; the
ADR-0069 gate, which runs far above it, did not. That disagreement was
invisible while `isAuthGateAllowlisted` exempted a falsy path, and became a
403 on the bare-root discovery request once the predicate went fail-closed.

Normalising the root to `/` would relocate the 403 rather than remove it: a
segment-less path matches no `ALLOW_ROUTES` entry, and the discovery branch
tests `/discovery` or the empty string, neither of which `/` satisfies. The
root is canonicalised to `/discovery` instead — the route it has always
served — read from one constant by both sites so the two cannot drift again.

`packages/core` is untouched and `ALLOW_ROUTES` is unchanged: the only input
whose gate answer moves is the API root, which gains exactly the exemption
`/discovery` already carried, and gains it by being that route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 11, 2026
@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 25 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 88a933088e93067b4df4b20380ab9a1ceed2ed17packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 88a933088e93067b4df4b20380ab9a1ceed2ed17

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

…ive clause ② sets (#17625)

`Check Changeset` failed the LEVEL AXIS on the previous head: a PR whose clause ②
is declared affirmative must grade at least one package whose `packages/**/src/**`
it moves at `minor` or above, and this changeset graded the only such package
`patch`.

The level is a mechanical floor, not an editorial reading of the act. The
maintainer ruling of 2026-09-04 (decision batch #35, on #15294) is written out
under "WHICH LEVEL" in the `Check Changeset` step: the commit type may raise a
bump but never lower it below what the act requires. The act here re-admits an
input class the merged tree refuses, on an authorisation surface, so the type
stays `fix(runtime)` and only the level moves. The changeset now records that
reasoning so a later reader does not re-grade it back down as a plain bug fix.

⛔ The declaration was not softened to fit the level, and neither the gate nor
the workflow was touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Contract review — ACCEPT, with one undeclared consequence to add to ## Scope before landing

domain:cli execution PM seat (#6024), 2026-09-11T14:55Z, head 6c31e741. Review of record is a comment: GitHub refuses an APPROVE review on an agent-authored PR. Tier: the default judgment tier, per the standing ruling 5612096863 (text landed #17294) that this seat builds and reviews at it. ⛔ Not a governed surface — packages/runtime only — so this seat may review it.

⚠️ This comment replaces a corrupted first posting of the same review. The original was mangled by a shell-quoting fault on the posting side — an unquoted heredoc delimiter expanded every backticked identifier as a command substitution and deleted it. Nothing about the verdict changed; the text below is what was meant the first time, and it is edited in place rather than appended so that no corrupted review of record stands.

What I re-drove rather than ratified

the PR's claim my reading
packages/core untouched, ALLOW_ROUTES unchanged ✅ zero changed bytes; diff is 3 files
the gate really allow-lists the target ALLOW_ROUTES carries ['discovery']; isAuthGateAllowlisted('/') and ('') are both false, '/discovery' true
one constant, two readers DISCOVERY_ROUTE is read at the canonicalisation and at the discovery branch, and nowhere else
the '' arm is still live ✅ the scoped-URL strip at :2663:2665 re-creates '' after the canonicalisation, so deleting that arm would break ${prefix}/environments/<id>
behaviour is 3 lines ✅ a constant, if (cleanPath === '') cleanPath = DISCOVERY_ROUTE;, and one literal→constant swap. +73/−3, of which ~70 is the argument

⭐ A reading that strengthens the case, found in review and not in the PR

Published documentation already says these two paths are one thing. content/docs/api/index.mdx:103 and content/docs/protocol/kernel/http-protocol.mdx:26, both verbatim:

GET /api/v1 (and GET /api/v1/discovery)

Returns the full discovery manifest. @objectstack/rest registers one handler at both paths … so the two are the same document, not a redirect and not two shapes.

⇒ on post-#7898 main, for a gated session, the first answered 403 and the second 200. The published documentation was falsified by #7898's landing, for that population, and this PR restores it. That is a materially better framing than "re-admits an input class": the input class was documented as admitted, and a landing took it away as a side effect.

⛔ And that is NOT a reason to re-grade the clause-② declaration — stated because it is the tempting move

The restoration reading invites 「拉回已声明契约 ⇒ 代裁车道」, which would argue the axis is negative. ⛔ Refused, for three reasons: the axis is measured against the merged tree, where a 403 does become a 200; the surface is authorisation, where the conservative grade is the only safe one; and the declaration is already carried on both carriers with CI having accepted the minor that follows from it. ⭐ Weakening a declaration because a better argument for the change turned up is exactly the move the gate itself fences. The verdict stays affirmative, and the restoration reading belongs in the record as support for landing, ⛔ not as a re-grade.

The one ask: a THIRD consequence, undeclared

The canonicalisation sits at the top of dispatch(), so every stage between it and the discovery branch now sees /discovery where it saw ''. I enumerated all 9 non-comment cleanPath readers in that 151-line window. Eight are inert or already declared:

  • :2663/:2665 scoped-URL strip — /discovery does not match that regex, and neither did ''. No change.
  • :2618 auth gate — the intended change.
  • :2627 project membership — declared consequence Add metamodel interfaces for ObjectQL/ObjectUI contract #1, pinned by http-dispatcher.membership-skip-boundary.test.ts.
  • :2611 request scope, plus context.routePathdeclared consequence ✨ Set up Copilot instructions #2, asserted as the mechanism pin.
  • :2594 liveness — ✅ cleared by measurement: resolveLiveness is resolve plus one field read, and the liveness routes are /health and /ready, both match: 'exact'. /discovery matches neither.

The ninth is :2675 this.domainRegistry.resolve(cleanPath, method), which runs before the discovery branch at :2710. Measured: nothing in this repo registers the prefix /discovery (zero hits under packages/runtime/src/domains/, against a firing control — those files do register routes). So there is no behaviour change today.

⚠️ But registerDomainHandler is a public extension seam — this repo's own tests call it "the public seam" — and after this change a plugin registering { prefix: '/discovery' } captures the API root as well, which it could not do before, because the root arrived as ''. Sharper: a plugin registering { prefix: '/discovery', liveness: true } would put the API root on the liveness carve-out, ahead of the auth gate.

⭐ This is not a defect and ⛔ not a blocker: it is the PR's own thesis applied consistently — the root IS the discovery route, so it inherits what /discovery inherits, including who may claim it. That is the same reasoning that makes declared consequences #1 and #2 correct. The ask is only that it be written down beside them, because it is the one with a security-adjacent edge, and an undeclared consequence on an extension seam is exactly what a reader checking ## Scope against the diff should not have to derive.

Add a third bullet under ## Scope naming it, in the shape of the other two: what moves, why it follows from the thesis, and that nothing in-repo registers that prefix today.

Verdict

ACCEPT on the substance. The change is minimal, the constant makes a third disagreement impossible, the retained '' arm is justified by measurement rather than caution, and the ablation legs prove both the fix and the rejected alternative. ⛔ Landing still waits on two things that are not this review's: CI convergence on the current head (at 2026-09-11T14:55Z: 11 success / 5 skipped / 14 still running, zero non-green), and the ## Scope addition above. ⛔ This seat does not flip ready, enqueue, or arm auto-merge until both hold.

PM dispatch seat · domain:cli · session session_01TSf4DV7ziu4V5j73e46b7c · contract review at the default judgment tier


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 11, 2026 15:13
@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Contract-review carrier CLEARED — the review passed, and the clear is the same seat's act

domain:cli execution PM seat (#6024), 2026-09-11T15:15Z. Paired with its reason rather than written silently, because a label write with no record is a half-state.

Why now. The review is complete and on this PR: ACCEPT, comment 5636330115, at the default judgment tier the standing ruling 5612096863 assigns this seat for both build and review. The one ask it carried — a third ## Scope bullet naming who may claim the root — is in and verified on the stored body (five probes: the resolve site, the seam, the liveness: true edge, the zero and its firing control).

Why the label is what to move. check-changeset-no-major.mjs states the lifecycle in its own header: "the needs:contract-review carrier is the review seat's to place and to clear", and "THE CARRIER IS STRIPPED AT REVIEW PASS … That is the intended order — the human review that clears the carrier is the authority on the level." Measured cause, not guessed: mergeable_state held at blocked across three consecutive reads while mergeable was true and the combined commit status was success; the sibling PR #17692, identical in every landing respect except that it carries no such label, reached clean.

And clearing it does not weaken the gate, which is the thing worth checking before doing it. The declaration is carried on two independent readers and only one of them moves:

⇒ a Check Changeset run after this clear still reads an affirmative declaration and an adequate level, so it stays green. The shape that gate refuses — no readable declaration plus a patch on a package the diff grew — is not this PR, in either half. That was confirmed four times already on this head: Check Changeset ran at 14:45:21Z, 14:45:36Z, 14:48:02Z and 15:01:02Z, all success, the last of them triggered by the ## Scope body edit itself.

State at the clear, head 6c31e741: 28 success / 5 skipped / 0 failures / 0 running after a latest-per-name collapse; check-clause2-carriers --pair 17691 exit 0 (re-taken after the body edit, ⛔ not recalled from the pre-edit run); check-governed-merges --pr 17691 exit 0, NOT governed, derived from the final file list, with --test AGENTS.md exit 3, GOVERNED as the firing control.

⇒ Clearing the carrier by targeted single-label DELETE, ⛔ never a whole-set replace.

⚠️ CORRECTED IN PLACE. This paragraph originally ended "The card's copy stays until the card closes with the landing"that was wrong, and acting on it produced a half-cleared pair. check-clause2-carriers --pair 17691 refused it as row C1: the gate is a dual carrier (maintainer 2026-08-22, 「两边都挂好」), hung in one stroke and cleared in one stroke, because the second removal is the only machine-readable evidence that a gate was cleared rather than stripped — 「闸门被剥不是红灯是放行」, so a one-sided removal and 「never hung at all」 are indistinguishable without it. The card-side carrier on #17625 was therefore removed seconds later, completing the stroke, and the gate then read clean. The record of the review itself is comment 5636607288.

PM dispatch seat · domain:cli · session session_01TSf4DV7ziu4V5j73e46b7c


Generated by Claude Code

@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Contract review — PR #17691, head 6c31e741

Reviewed-by: os-sales, the domain:cli execution PM seat (pm:seat #6024), session session_01TSf4DV7ziu4V5j73e46b7c, at the default judgment tier per the standing ruling 5612096863 (text landed #17294).
Implemented-by: the os-dev agent dispatched by this seat on branch claude/issue-17625-dispatcher-bare-root-normalise, claim comment 5634884100.

Verdict: ACCEPT. The full analysis is comment 5636330115 on this PR and is not restated here; this comment is the record of it, in the shape the record is read in.

⚠️ Why this comment exists at all, stated rather than hidden. The ACCEPT at 5636330115 had the heading and this head's sha but no Reviewed-by: line, so it named no reviewer and was not a review of record to any toolcheck-clause2-carriers --pair 17691 reported exactly that (row C6) the moment the carrier clear was checked. ⭐ That is the same defect class this lane disposed four times today under other keys — a marker a parser reads, written in a form the parser cannot read — and this time the seat wrote it. The verdict did not change; only its readability did.

⚠️ On independence, because the pair above invites the question. The implementer was an os-dev subagent dispatched by this seat, and the reviewer is the seat. That is the lane's normal shape, ⛔ not an independent second party, and the pair is recorded so a reader can see it rather than infer it.

What the review verified, in one line each

  • packages/core untouched, ALLOW_ROUTES unchanged — zero changed bytes.
  • One constant read by exactly two sites, so the two-spellings defect cannot recur.
  • The '' arm retained with a measured reason (the scoped-URL strip re-creates it after the canonicalisation).
  • All 9 non-comment cleanPath readers between the canonicalisation and the discovery branch enumerated; :2594 liveness cleared by measurement; the ninth (:2675 domainRegistry.resolve) raised as an undeclared consequence and now declared in ## Scope bullet 3.
  • ⭐ Published documentation (content/docs/api/index.mdx:103, content/docs/protocol/kernel/http-protocol.mdx:26) states the root and /discovery are "the same document, not a redirect and not two shapes"falsified by [Decision] Fail-close isAuthGateAllowlisted's "no path ⇒ exempt" default (Option 2 of #7432) #7898's landing for gated sessions, and restored by this PR.
  • ⛔ That restoration reading was refused as grounds to re-grade the clause-② declaration; the affirmative verdict stands.

State at this record

Head 6c31e741: 28 success / 5 skipped / 0 failures / 0 running after a latest-per-name collapse. Check Changeset green four times on this head, the last triggered by the ## Scope body edit. check-governed-merges --pr 17691 exit 0, NOT governed on the final file list, with --test AGENTS.md exit 3, GOVERNED as the firing control.

The needs:contract-review carrier was cleared on both sides — PR and card #17625, seconds apart, which is the signature of a clear rather than a strip (「闸门被剥不是红灯是放行」). ⚠️ The first attempt cleared only the PR side and the gate refused it as row C1; the provenance note at 5636587768 says the card's copy would stay until landing, and that sentence was wrong — a dual carrier is hung in one stroke and cleared in one stroke.

PM dispatch seat · domain:cli · session session_01TSf4DV7ziu4V5j73e46b7c


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

Development

Successfully merging this pull request may close these issues.

runtime: normalise the dispatcher's bare-root cleanPath '' to '/' once the auth-gate helper is fail-closed (runtime half of #7898 ruling A)

2 participants