Skip to content

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

Description

@os-litant

Filed by the director seat (summon #21, session_01QVMnxyWBx8cAQMsV6akDV9) as the runtime half of ruling A on #7898 — maintainer verbatim 「其他同意」 on decision batch #114 (director chat, 2026-09-11T04:3xZ). Routed domain:cli by the anchoring rule (packages/runtime); pm:blocked on the core half.

Blocked-by: #7898

What lands here, after #7898's core half

packages/runtime/src/http-dispatcher.ts computes cleanPath = path.replace(/\/$/, ''), so a request to ${prefix}/ yields cleanPath === ''. Once the auth-gate helper is fail-closed at source (#7898 step 1), an empty path is no longer exempt, and the bare-root discovery request must keep resolving to root rather than being refused. Normalise '' → '/' at that site (or the equivalent the dispatcher's own hint parser reads), and pin: GET ${prefix}/ on a gated session still returns the discovery payload; GET ${prefix} (no slash) unchanged; a request with a genuinely absent path is refused by the gate.

Re-check before acting (fixed-string, with a control — os-steve 5479201259 measured the regex form returning a false zero):

git grep -nF "replace(/\/$/" origin/main -- packages/runtime/src/http-dispatcher.ts   # subject
git grep -nF "enforceAuthGate" origin/main -- packages/runtime/src/http-dispatcher.ts   # control, must hit

Clause-②: no. Unlock: #7898's core PR merged (Blocked-by cleared by the unlock scan). Refs: #7898 (ruling 5617657765 and the card's caller census 5257880748), #7432 / PR #7836 (the instance fix), ADR-0069.


Generated by Claude Code

Activity

  1. os-sales commented on Sep 11, 2026

    @os-sales
    Collaborator

    UNBLOCKED — back to pm:queue. domain:cli execution seat, 2026-09-11T11:56Z.

    Surfaced by the half-state patrol as H19: "pm:blocked while 1 of 1 Blocked-by: target(s) is CLOSED (#7898, closed 2026-09-11T10:40:27Z): the block has outlived its blocker."

    Reading 1 — the blocker really is closed

    #7898   state=closed   reason=completed   closed_at=2026-09-11T10:40:27Z   labels=domain:engine
            [Decision] Fail-close isAuthGateAllowlisted's "no path ⇒ exempt" default (Option 2 of #7432)
    #17625  state=open     labels=domain:cli, pm:blocked
            body: Blocked-by: #7898
    

    ⭐ Reading 2 — the premise re-verified on the merged ref, which is the part that is not optional

    The unlock scan's third duty is to re-verify the premise on the merged ref, ⛔ never to return a card on the strength of a closed blocker alone. That mattered here, because #7898 is a [Decision] card: a decision being made is not the implementation landing, and this card's premise is conditional — "…once the auth-gate helper is fail-closed". A closed decision with no landed change would have left the premise unmet and this card wrongly dispatchable.

    Measured on origin/main after a fresh fetch, in packages/core/src/security/auth-gate.ts:

    :173 ## FAIL-CLOSED on an absent or empty path (#7898)
    :175 A falsy path is not exempt. This used to answer true — a fail-OPEN [default] …

    ⇒ the implementation landed, not merely the decision. And packages/core/src/security/anonymous-deny.ts:168 carries the same reading from the consumer side: "isAuthGateAllowlisted(undefined) is itself fail-closed."

    ⭐ The landed code names this card as its own successor, at auth-gate.ts:191–:193:

    … (slash), which was exempt via the fail-open default and is not exempt now.
    as #17625 (domain:cli, Blocked-by: #7898) — ⛔ not a tolerance to add back

    ⇒ so this is not merely "no longer blocked": the thing that unblocked it points here by number, and states in advance that the remaining work is a normalisation and ⛔ not a re-widening of the exemption. Whoever takes this card should read that paragraph first — it fences the wrong fix before anyone proposes it.

    The write

    pm:blocked removed and pm:queue added by two targeted single-label calls (HTTP 200 each), ⛔ never a whole-set replace. Read back against the union: pm:queue, domain:cli — pm:blocked gone, pm:queue present, domain:cli kept. The Blocked-by: #7898 line stays in the body as the provenance of the wait; ⛔ it is not deleted, since it is the reverse index that recorded why this card waited and for how long.

    ⚠️ Not claimed and not dispatched here. It re-enters the pool and takes its place in this lane's total order like any other pm:queue card; ⛔ the unlock is not a dispatch.

    domain:cli 执行席 · pm:seat #6024 · session session_01TSf4DV7ziu4V5j73e46b7c · 2026-09-11T11:56Z


    Generated by Claude Code

  2. claude commented on Sep 11, 2026

    @claude
    Contributor

    Claim: #17625 by the domain:cli execution PM seat (pm:seat #6024), session session_01TSf4DV7ziu4V5j73e46b7c, R73, 2026-09-11T13:07Z.
    Branch: claude/issue-17625-dispatcher-bare-root-normalise
    Thread-read: body + 2 comments, read to the last one (5634068846, this seat's own unblock record, 2026-09-11T11:56:28Z) through REST, 2026-09-11T13:07Z.
    Clause-②: yes

    Declared file surface: packages/runtime/src/http-dispatcher.ts plus its sibling tests and a .changeset/. ⛔ Read-only and not to be edited: packages/core/src/security/auth-gate.ts and core's ALLOW_ROUTES — that is this lane's upstream, it was just narrowed by #7898, and the ruling did not authorise widening it.

    ⚠️ Why the axis verdict above is yes, against the verdict in the card body. The body's verdict on that axis was written by the filing seat on 2026-09-11T04:36Z; the domain:engine seat then measured (5632718273) that on the post-#7898 tree a gated GET ${prefix}/ is refused, and that the card's ruled pin requires it to keep returning the discovery payload. ⇒ delivering the ruled outcome re-admits an input class the merged tree currently refuses, on an authorisation surface. The claim comment is the carrier the enqueue gate's content limb reads, so this verdict is the operative one; graded the conservative way because the axis is a permission boundary.

    ⭐ The premise, re-verified rather than inherited — the blocker being closed is not the premise. Measured on origin/main at 11:56Z and unchanged since: packages/core/src/security/auth-gate.ts:173 carries 「FAIL-CLOSED on an absent or empty path (#7898)」, so the implementation landed and not merely the decision. ⭐ And :191–:193 of that same landed file names this card by number as its successor and fences the wrong fix in advance — 「⛔ not a tolerance to add back」. Read that paragraph before writing anything.

    ⛔ The hard stop, and it is the whole reason this is dispatched rather than sized as a one-liner. The card's stated mechanism — normalise cleanPath '' → '/' — was measured necessary and not sufficient: isAuthGateAllowlisted('/') is false, and the dispatcher's discovery arm tests cleanPath === '/discovery' || cleanPath === '', which '/' satisfies neither. ⇒ normalising alone relocates the 403, it does not remove it, and the ruled pin would fail.

    So, in order:

    1. Re-drive both legs first, with the fixed-string probes the card supplies and their named control (a regex form already produced a false zero once — 5479201259). A measurement that contradicts the above is a finding to report, ⛔ not a reason to improvise.
    2. Deliver the ruled outcome the way the engine seat's shape A describes: the bare root resolves to the discovery route, leaving core's allow-list alone.
    3. ⛔ STOP and report a fork if reaching the ruled pin requires adding the bare root to core's ALLOW_ROUTES (shape B), any other edit under packages/core, or accepting that GET ${prefix}/ 403s on a gated session (shape C). All three move a question the maintainer's ruling steered around, and C contradicts the card's own acceptance criterion. ⭐ A fork with evidence is a full delivery, ⛔ never rework.
    4. Pin all three rows the card names: GET ${prefix}/ on a gated session still returns the discovery payload · GET ${prefix} (no slash) unchanged · a genuinely absent path still refused. The auth-gate half is already pinned by [Decision] Fail-close isAuthGateAllowlisted's "no path ⇒ exempt" default (Option 2 of #7432) #7898's round in packages/core/src/security/auth-gate.test.ts — ⛔ do not restate it in packages/runtime, reference it.

    Serial-resource readings at claim time. Heavy-verify lock: lock is free, queue: empty ⇒ arrival depth 1, under LOCK_DEPTH_HOLD (2). Hot-file matrix at 13:01Z: packages/runtime/src/http-dispatcher.ts is FREE across all 11 open PRs. ⚠️ It is, however, in hold #7898's trigger-file index — that hold is now closed completed, so the rider is discharged rather than owed, and this is recorded so a reader checking the index does not read a live fence where there is none.

    PM dispatch seat · domain:cli · R73 · 2026-09-11T13:07Z


    Generated by Claude Code

  3. claude commented on Sep 11, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 17625,
      "status": "done",
      "branch": "claude/issue-17625-dispatcher-bare-root-normalise",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/17691",
      "premise_still_valid": true,
      "summary": "Delivered the ruled outcome by the engine seat's shape A, inside packages/runtime alone — NO FORK was needed. dispatch() strips one trailing slash, so both root spellings (${prefix}/ arriving as '/' and ${prefix} arriving as '') collapsed onto '' — a path only the discovery branch understood, while the ADR-0069 gate far above it did not. The root is now canonicalised to '/discovery' (the route it has always served) at the single cleanPath site, read from one shared DISCOVERY_ROUTE constant by both the canonicalisation and the branch that serves it. packages/core has ZERO changed bytes and ALLOW_ROUTES is unchanged: the only input whose gate answer moves is the API root, which gains exactly the exemption /discovery already carried, by BEING that route. The pathless tolerance is NOT re-derived at this seam. PREMISE RE-DRIVEN: both fixed-string probes hit (subject http-dispatcher.ts:2499, control enforceAuthGate at :1302 and :2558), isAuthGateAllowlisted('/') is false and isAuthGateAllowlisted('') is false while '/discovery' is true — all three already pinned upstream in packages/core/src/security/auth-gate.test.ts. The engine seat's measurement came back STRONGER than relayed: normalising '' to '/' does not merely relocate the gated 403, it also 404s the API root for EVERY session, gated or not, because '/' satisfies neither arm of the discovery branch — measured as ablation leg B, reported as a measurement about a fix nobody applied, not as a defect on main. Two consequences of canonicalising ahead of the gate are declared under the PR's ## Scope heading rather than left to be discovered: the root now takes the control-plane membership skip path /discovery always had (already pinned by http-dispatcher.membership-skip-boundary.test.ts; no new exposure, same document to the same caller), and context.routePath records /discovery, which is asserted directly as the mechanism pin. Nothing was added to the declared file surface. Assignee was os-sales (the PM's) at pickup and was never written by this round; the three extra PR labels (documentation/tests/tooling) were set by another actor and were deliberately not corrected.",
      "clause_2_declaration": "yes — re-judged from the delivered diff, agreeing with the PM's claim-comment verdict and against the card body's earlier one. The diff re-admits an input class the merged tree currently refuses (403 to 200) on an AUTHORISATION surface. Declared in the PR body in the fixed spelling, exactly once, undecorated, on its own line. needs:contract-review carried on BOTH carriers per the 2026-08-22 ruling: PR #17691 and card #17625, each written with the additive single-label endpoint (HTTP 200) and read back against the union — target present, nothing stripped on either. node scripts/pm/check-clause2-carriers.mjs --pair 17691 :: exit 0 — 'the clause-② declaration is readable in the fixed spelling and both carriers agree'. PR left as a DRAFT and NOT enqueued.",
      "tests": "All heavy runs through scripts/pm/os-verify-lock.sh (slot os-dev-17625); every verdict read from its own printed 'VERDICT command-exit' line, and every gate exit code captured BEFORE any pipe. Measured at final commit 76d25207. (1) Dependency closure: pnpm --filter '@objectstack/runtime^...' build --concurrency=2 :: VERDICT command-exit 0 (held 401s, waited 513s). (2) Targeted tests :: exit 0 — 6 test files / 61 tests passed: the new http-dispatcher.root-auth-gate.test.ts plus the neighbours that read cleanPath at the stages the canonicalisation now precedes (http-dispatcher.root, http-dispatcher.scoped-url-strip, http-dispatcher.liveness-carve-out, http-dispatcher.membership-skip-boundary, domains/auth-claim-segment-boundary). (3) pnpm --filter @objectstack/runtime typecheck :: exit 0 (check:test-typecheck OK, 27 files / 191 errors / 69 pinned signatures held). (4) The REFERENCED core pin re-run, not restated: packages/core/src/security/auth-gate.test.ts :: exit 0, 22 tests. GATE ROSTER — node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived after the changeset existed (52 before, 59 after; the 7 added are the changeset-derived families). Reconciled with --ran carrying exit codes: '59 derived, 57 run, 2 NOT-MEASURED, 0 UNRUN' — tally 57 x exit 0, 2 x exit 3, zero failures. The 2 at exit 3 are PREREQUISITE NOT MET and are NOT MEASURED, neither pass nor failure: pnpm check:dual-build-cjs-loads ('this gate reads built output, and some package has no dist/ ... This is NOT a pass: nothing was measured') and pnpm check:type-check-debt ('--re-measure cannot run ... NOT a pass and NOT a finding'). Both want a whole-tree build; this card's derived closure covers only @objectstack/runtime's dependencies. LINT UNION (this lane's known dispatch-gates blind spot, added explicitly): 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 at 0/0 — packages/runtime/src/http-dispatcher.ts and packages/runtime/src/http-dispatcher.root-auth-gate.test.ts. ABLATION — two legs against the COMMITTED fix, each mutate-run-restore, with the subject resolving 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, and both legs ran under a trap on EXIT INT TERM calling an absolute-path restore with an absolute repo-root path. HEAD blob 52e51268d7b17bdba56ffbc10a354476ee9ca917. LEG A (canonicalisation deleted = main's behaviour): anchor count 1 to 0, injected text 1 — 4 failed | 5 passed; PIN 1 'AssertionError: expected 403 to be 200'. LEG B (the card's stated '' to '/'): anchor 1 to 0, injected 1 — 5 failed | 4 passed; PIN 1 'expected 403 to be 200' AND the ungated control '/: expected 404 to be 200'. Each leg restored via git checkout HEAD -- ABSOLUTE_PATH, proven by 'git diff HEAD' EMPTY and hash == HEAD blob, not by an exit code. CONTROL on the unmutated tree: exit 0, 9 passed (9). CONTROL BYTES: grep -naP over all three changed files — no match (exit 1); pnpm check:nul-bytes :: exit 0. NOT MEASURED and owed to CI, stated rather than implied: 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 says in its own output. CI convergence is NOT claimed; this report is delivered at the end of local verification.",
      "changeset_level": "patch on @objectstack/runtime (.changeset/17625-api-root-is-the-discovery-route.md). @objectstack/runtime publishes, so a changeset is owed rather than skip-changeset: a 403 that should be a 200 on a shipped HTTP surface is a fix, and no published API shape moves. ADR-0087 DISPOSITION: no ledger entry owed and no marker required — the changeset declares no breaking change, which is the only condition under which check:adr-0087-registration demands a marker, and that gate ran green (exit 0). On the substance no ADR-0087 shape surface moved: 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 a conversion-layer entry: nothing lenient is accepted from a metadata producer; one transport's two spellings of its own route are reconciled to the route's own name, which is a dialect REMOVED, not tolerated.",
      "open_questions": [],
      "mcp_calls": "0 — every GitHub read and write went through repo-scoped REST (probed first, HTTP 200) and git push; no MCP GitHub call was made",
      "out_of_scope_findings": [
        "noted, not filed: the environment-scoped root ${prefix}/environments/ENV_ID is refused for a gated session — it matched no allow-listed route BEFORE #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; widening it is precisely the direction the ruling steered around and discovery stays reachable by its own name. Deliberately untouched and PINNED as a boundary case in the new test file. Successor: this file and the ADR-0069 gate lane.",
        "noted, not filed: ${prefix}// strips to '/', not to '', so it is not the root and is not canonicalised — recorded and PINNED so a later reader does not widen the rule into 'any number of trailing slashes is the root'. Successor: this file.",
        "noted, not filed: enforceAuthGate builds its refusal as this.error(message, 403, { code }), so the gate code travels in the envelope's details.code rather than error.code. No action taken; the new test reads whichever of the two carries it, so it pins the decision and not the spelling."
      ]
    }

    Generated by Claude Code

  4. claude commented on Sep 11, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 17625,
      "status": "done",
      "branch": "claude/issue-17625-dispatcher-bare-root-normalise",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/17691",
      "premise_still_valid": true,
      "summary": "AMENDED at head 6c31e741 after CI. The implementation is unchanged; the only change since the first report is the changeset LEVEL. Delivered the ruled outcome by the engine seat's shape A, inside packages/runtime alone — NO FORK was needed. dispatch() strips one trailing slash, so both root spellings (${prefix}/ arriving as '/' and ${prefix} arriving as '') collapsed onto '' — a path only the discovery branch understood, while the ADR-0069 gate far above it did not. The root is now canonicalised to '/discovery' (the route it has always served) at the single cleanPath site, read from one shared DISCOVERY_ROUTE constant by both the canonicalisation and the branch that serves it. packages/core has ZERO changed bytes and ALLOW_ROUTES is unchanged: the only input whose gate answer moves is the API root, which gains exactly the exemption /discovery already carried, by BEING that route. The pathless tolerance is NOT re-derived at this seam. PREMISE RE-DRIVEN: both fixed-string probes hit (subject http-dispatcher.ts:2499, control enforceAuthGate at :1302 and :2558), isAuthGateAllowlisted('/') is false and isAuthGateAllowlisted('') is false while '/discovery' is true — all three already pinned upstream in packages/core/src/security/auth-gate.test.ts. The engine seat's measurement came back STRONGER than relayed: normalising '' to '/' does not merely relocate the gated 403, it also 404s the API root for EVERY session, gated or not, because '/' satisfies neither arm of the discovery branch — measured as ablation leg B, reported as a measurement about a fix nobody applied, not as a defect on main. THREE consequences of canonicalising ahead of the gate are declared under the PR's ## Scope heading rather than left to be discovered: the root now takes the control-plane membership skip path /discovery always had (already pinned by http-dispatcher.membership-skip-boundary.test.ts; no new exposure, same document to the same caller), context.routePath records /discovery, which is asserted directly as the mechanism pin; and — added after the contract review enumerated all 9 cleanPath readers in the window and cleared 8 — WHO MAY CLAIM THE ROOT: domainRegistry.resolve runs before the discovery branch, so a plugin registering prefix '/discovery' through the public registerDomainHandler seam now captures the API root too (and with liveness:true would put it on the 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 — and it is the thesis applied consistently: the root IS the discovery route, so it inherits what /discovery inherits, including who may claim it. Nothing was added to the declared file surface. Assignee was os-sales (the PM's) at pickup and was never written by this round; the three extra PR labels (documentation/tests/tooling) were set by another actor and were deliberately not corrected.",
      "clause_2_declaration": "yes — UNCHANGED and NOT softened. CI's Check Changeset red was the LEVEL axis, not the declaration, and the gate fences the route that would fix the level by weakening the declaration ('Do not add a tolerance here to route around a declaration that says something its author did not mean'); way 1 was taken instead. Re-confirmed after the push: node scripts/pm/check-clause2-carriers.mjs --pair 17691 :: exit 0 — 'the clause-② declaration is readable in the fixed spelling and both carriers agree'. The gate's own run now prints both readings directly: 'carrier: needs:contract-review IS on this PR' and a declaration line it reads as affirmative. Declared once in the PR body, undecorated, on its own line; needs:contract-review still on BOTH carriers (PR #17691 and card #17625). PR still a DRAFT, NOT enqueued, auto-merge NOT armed.",
      "tests": "AMENDED — re-verified at head 6c31e741. The ONLY difference from the previously-verified 76d25207 is the changeset file (git diff --stat 76d25207 6c31e741 = 1 file, 14 insertions, 1 deletion), so no implementation input moved. CHANGESET GATE, reproduce-then-pass, the one check whose verdict this edit could move: exit 1 before the edit, exit 0 after (verbatim readings in changeset_level). check-clause2-carriers --pair 17691 :: exit 0 after the push. ⚠️ The seat later narrowed what was owed for this push to those two checks only; that narrowing arrived AFTER the following had already run, so they are reported as completed measurements, not as work done against the instruction: full 59-family roster re-derived at the new head (identical roster — the level change added no family) and re-run: 57 x exit 0, 2 x exit 3, 0 failures, 0 unrun, reconciled with --ran carrying exit codes. The 2 at exit 3 are the same two whole-tree-build gates as before (check:dual-build-cjs-loads, check:type-check-debt), PREREQUISITE NOT MET = NOT MEASURED, neither pass nor failure. ⚠️ An honest artifact of tearing the worktree down after the first report: the recreated worktree had no dist, so check:dts-closure, check:lean-entry-closure and check:sourcemap-no-sources-content first read exit 3 too; the dependency closure was rebuilt (VERDICT command-exit 0, held 446s) and all three then read exit 0, restoring the same 57/2 split as the first sweep. LINT UNION at the new head: pnpm eslint . --no-inline-config --format json :: exit 0, 6634 files, 0 errors, 0 warnings, both changed source files present at 0/0. NOT re-run, because none of their inputs moved and the seat said so explicitly: the targeted test suite, the typecheck, the referenced core pin and both ablation legs — all green at 76d25207, whose packages/** bytes are identical to this head's. FIRST-ROUND READINGS, unchanged and still standing: All heavy runs through scripts/pm/os-verify-lock.sh (slot os-dev-17625); every verdict read from its own printed 'VERDICT command-exit' line, and every gate exit code captured BEFORE any pipe. Measured at final commit 76d25207. (1) Dependency closure: pnpm --filter '@objectstack/runtime^...' build --concurrency=2 :: VERDICT command-exit 0 (held 401s, waited 513s). (2) Targeted tests :: exit 0 — 6 test files / 61 tests passed: the new http-dispatcher.root-auth-gate.test.ts plus the neighbours that read cleanPath at the stages the canonicalisation now precedes (http-dispatcher.root, http-dispatcher.scoped-url-strip, http-dispatcher.liveness-carve-out, http-dispatcher.membership-skip-boundary, domains/auth-claim-segment-boundary). (3) pnpm --filter @objectstack/runtime typecheck :: exit 0 (check:test-typecheck OK, 27 files / 191 errors / 69 pinned signatures held). (4) The REFERENCED core pin re-run, not restated: packages/core/src/security/auth-gate.test.ts :: exit 0, 22 tests. GATE ROSTER — node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived after the changeset existed (52 before, 59 after; the 7 added are the changeset-derived families). Reconciled with --ran carrying exit codes: '59 derived, 57 run, 2 NOT-MEASURED, 0 UNRUN' — tally 57 x exit 0, 2 x exit 3, zero failures. The 2 at exit 3 are PREREQUISITE NOT MET and are NOT MEASURED, neither pass nor failure: pnpm check:dual-build-cjs-loads ('this gate reads built output, and some package has no dist/ ... This is NOT a pass: nothing was measured') and pnpm check:type-check-debt ('--re-measure cannot run ... NOT a pass and NOT a finding'). Both want a whole-tree build; this card's derived closure covers only @objectstack/runtime's dependencies. LINT UNION (this lane's known dispatch-gates blind spot, added explicitly): 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 at 0/0 — packages/runtime/src/http-dispatcher.ts and packages/runtime/src/http-dispatcher.root-auth-gate.test.ts. ABLATION — two legs against the COMMITTED fix, each mutate-run-restore, with the subject resolving 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, and both legs ran under a trap on EXIT INT TERM calling an absolute-path restore with an absolute repo-root path. HEAD blob 52e51268d7b17bdba56ffbc10a354476ee9ca917. LEG A (canonicalisation deleted = main's behaviour): anchor count 1 to 0, injected text 1 — 4 failed | 5 passed; PIN 1 'AssertionError: expected 403 to be 200'. LEG B (the card's stated '' to '/'): anchor 1 to 0, injected 1 — 5 failed | 4 passed; PIN 1 'expected 403 to be 200' AND the ungated control '/: expected 404 to be 200'. Each leg restored via git checkout HEAD -- ABSOLUTE_PATH, proven by 'git diff HEAD' EMPTY and hash == HEAD blob, not by an exit code. CONTROL on the unmutated tree: exit 0, 9 passed (9). CONTROL BYTES: grep -naP over all three changed files — no match (exit 1); pnpm check:nul-bytes :: exit 0. NOT MEASURED and owed to CI, stated rather than implied: 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 says in its own output. CI convergence is NOT claimed; this report is delivered at the end of local verification.",
      "changeset_level": "minor on @objectstack/runtime — RAISED from patch after CI. The first grade was wrong and the gate was right. REPRODUCED locally before editing: GITHUB_EVENT_NAME=pull_request node scripts/check-changeset-no-major.mjs --base 49cd71548 --event EVENT_PAYLOAD :: exit 1, '⛔ This PR declares clause-② YES, and it grades NO package whose packages/**/src/** it moves at minor or above'. (A bare local run cannot show this: it prints 'LEVEL AXIS: NOT APPLICABLE — this run has no pull_request to read a declaration from', so the event payload was built from the live PR to reproduce faithfully.) AFTER the edit, same command :: exit 0, '✓ LEVEL AXIS: this PR declares clause-② yes, and no package whose packages/**/src/** it moves is graded patch'. The level is a MECHANICAL FLOOR, not an editorial reading: an affirmative clause ② on a package whose 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. My earlier 'a 403 that should be a 200 is a fix, not a feature' was an argument about INTENT and does not reach the level; the changeset now records that reasoning so a later reader does not re-grade it back down. ⛔ Neither scripts/check-changeset-no-major.mjs nor pr-automation.yml was touched. ADR-0087 disposition is unchanged and still requires no marker: minor is not a declared breaking change, and check-adr-0087-registration is green at the new head.",
      "open_questions": [],
      "mcp_calls": "0 — every GitHub read and write went through repo-scoped REST (probed first, HTTP 200) and git push; no MCP GitHub call was made",
      "out_of_scope_findings": [
        "noted, not filed: the environment-scoped root ${prefix}/environments/ENV_ID is refused for a gated session — it matched no allow-listed route BEFORE #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; widening it is precisely the direction the ruling steered around and discovery stays reachable by its own name. Deliberately untouched and PINNED as a boundary case in the new test file. Successor: this file and the ADR-0069 gate lane.",
        "noted, not filed: ${prefix}// strips to '/', not to '', so it is not the root and is not canonicalised — recorded and PINNED so a later reader does not widen the rule into 'any number of trailing slashes is the root'. Successor: this file.",
        "noted, not filed: enforceAuthGate builds its refusal as this.error(message, 403, { code }), so the gate code travels in the envelope's details.code rather than error.code. No action taken; the new test reads whichever of the two carries it, so it pins the decision and not the spelling.",
        "noted, not filed, and ⛔ deliberately not fixed here (outside the declared surface): in the Check Changeset run that reads 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; the block underneath states the actual finding correctly and is what this PR acted on. Successor: the check-changeset-no-major.mjs lane.",
        "noted, not filed: the earlier-suspected stale label read in Check Changeset is NOT a defect — the seat re-measured and the payload snapshot self-corrects exactly as that script's own header documents, because pull_request also triggers on labeled/unlabeled, so hanging the carrier fires a run that sees it. Recorded so the retracted diagnosis is not re-derived by the next reader."
      ],
      "head_sha": "6c31e741c9f3c856b078b729b5c27c3e65dfb574",
      "docs_measurement": "ZERO pages falsified — measured TWICE, independently, with controls, and BOUNDED. The bound is part of the claim: the word 'discovery' appears in 64 pages and is too generic to anchor anything (the drift check says so itself), and those 64 were NOT individually read. ⇒ the claim is precisely 'no page stating the bare-root or trailing-slash behaviour is falsified', ⛔ NOT 'no page anywhere is affected'. SEAT'S PASS (probes: bare-root/trailing-slash prose = 2 pages — content/docs/api/declarative-endpoints.mdx:207 and content/docs/protocol/kernel/http-protocol.mdx:1215; routePath = 0; positive control '/api/v1/' = 101 pages, so the channel fires; fabricated token = 0). Both of the two say 'one trailing slash trimmed ... so /x and /x/ are the same claim' — not falsified, because the trimming still happens exactly as documented; what this diff decides is what the EMPTY result then means, which neither page states. THIS ROUND'S PASS, different probes, same verdict: positive controls first — the literal '(and `GET /api/v1/discovery`)' = 2 files, the regex 'GET /api/v1' = 44 files — then negatives all 0: 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. ⚠️ A FIRST CONTROL RETURNED ZERO AND WAS DISCARDED: 'one handler at both paths' is hard-wrapped across two lines in both pages, so a fixed-string search could never hit it; one malformed regex in the same batch was rerun as valid. Reported because a control that silently fails validates nothing. This pass additionally read the two pages that state the root equivalence — content/docs/api/index.mdx:103 and content/docs/protocol/kernel/http-protocol.mdx:26, both heading 'GET /api/v1 (and GET /api/v1/discovery)' and saying the two paths are one handler and one document, 'not a redirect and not two shapes'. Those sentences were FALSE for a gated session on post-#7898 main and are true again with this PR, so the change CONFIRMS them. Also read and cleared: content/docs/permissions/authorization.mdx:51 lists the allow-listed control plane as /auth, /health, /discovery — still exactly true, because ALLOW_ROUTES is unchanged and the root arrives AS /discovery rather than as a new entry. ⇒ No documentation change is owed; nothing was added to the declared file surface. The seat's table and the bound are recorded in the PR body under ## Acceptance notes so the next reader sees that 'nothing to list' was measured rather than accepted.",
      "ci_snapshot": "One snapshot read at delivery time, ⛔ not a poll and ⛔ not a convergence claim: head 6c31e741, latest-per-name — 6 success, 5 skipped, 19 in flight, 0 failures. Check Changeset was in_progress. CI convergence is the PM's to judge; this report is delivered at the end of local verification, as the standing rule requires.",
      "contract_review": "ACCEPT, posted on PR #17691 (comment 5636330115). The declaration was NOT re-graded: the review found a stronger framing — content/docs/api/index.mdx:103 and content/docs/protocol/kernel/http-protocol.mdx:26 publish that the root and /discovery are 'the same document, not a redirect and not two shapes', so #7898's landing FALSIFIED published documentation for gated sessions and this PR restores it — and explicitly refused to let that argue the axis negative, because the axis is measured against the merged tree, the surface is authorisation, and weakening a declaration when a better argument turns up is what the gate fences. The one ask was a third ## Scope bullet; it is in, body-only, head unchanged at 6c31e741, PR still a draft. ⛔ Flipping ready and enqueuing remain the PM's."
    }

    Generated by Claude Code

  5. claude commented on Sep 11, 2026

    @claude
    Contributor

    LANDED — PR #17691 squashed to 2b6a2075, verified by two readings on origin/main

    domain:cli execution PM seat (#6024), fetched and measured 2026-09-11T15:47Z. Card auto-closed completed; pm:dispatched stripped by targeted single-label DELETE (read back: domain:cli alone).

    Reading 1 — the landing shape, from the command

    $ git rev-list --parents -n 1 2b6a2075
    2b6a207542b85c39216c5014d3fac97b4e0bf75a 3d262ade872754746ffc16e6b75c08c6cec8e7c9
    

    2 fields ⇒ single-parent squash. 3 files, +349 / −3 — exactly the declared surface.

    Reading 2 — the content, probed on origin/main

    what was reviewed on origin/main
    the shared constant DISCOVERY_ROUTE = '/discovery' at :265
    read by exactly two sites, so a third disagreement cannot arise the canonicalisation :2559, the discovery branch :2710 — the other two hits are its own comments
    the '' arm retained, not deleted as dead cleanPath === '' — 2 occurrences
    the new sibling test present, 3 PIN cases
    ⛔ packages/core untouched 0 files under it in the squash
    ALLOW_ROUTES unchanged still carries 'discovery'
    the level the gate required '@objectstack/runtime': minor

    Fabricated negative control: DISCOVERY_ROUTE_ZZZ → 0 hits on the same file.

    What shipped

    ${prefix} and ${prefix}/ are canonicalised to the route they have always served, so the API root reaches the ADR-0069 gate under a name the allow-list carries instead of as an empty path only the discovery branch understood. A gated session's GET ${prefix}/ answers the discovery document again.

    ⭐ And it restores published documentation. content/docs/api/index.mdx:103 and content/docs/protocol/kernel/http-protocol.mdx:26 both state the root and /discovery are "the same document, not a redirect and not two shapes" — falsified by #7898's landing for gated sessions, true again now. That framing came out of review, ⛔ and was refused as grounds to re-grade the clause-② declaration: the axis is measured against the merged tree, the surface is authorisation, and weakening a declaration because a better argument appears is what the gate itself fences.

    ⚠️ Carried forward — three consequences, all declared, none closed by landing

    1. Project membership — the root now takes the control-plane skip path /discovery always had. Pinned by http-dispatcher.membership-skip-boundary.test.ts; same document to the same caller, so no new exposure.
    2. context.routePath records /discovery for a root request, asserted directly as the mechanism pin.
    3. ⭐ Who may claim the root — raised in review, absent from the original scope, and now declared: domainRegistry.resolve runs before the discovery branch, so a plugin registering { prefix: '/discovery' } through the public registerDomainHandler seam captures the API root too, and with liveness: true would put it on the carve-out ahead of the auth gate. ⛔ No behaviour changes today — nothing in-repo registers that prefix, measured against a firing control. It is this PR's own thesis applied consistently, which is why it belongs in the record rather than in a fix.

    ⚠️ Also carried: ${prefix}/environments/<id> stays refused for a gated session — it matched no allow-listed route before #7898 either, so its answer moved in neither card, and widening it is the direction the ruling steered around. Pinned as a boundary case. And ${prefix}// strips to /, not to the empty string, so it is not the root — pinned so nobody widens the rule into "any number of trailing slashes".

    PM dispatch seat · domain:cli · session session_01TSf4DV7ziu4V5j73e46b7c · landing record


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions