Skip to content

fix(runtime): stop the /auth domain claiming every path that merely starts with auth - #16265

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-16026-auth-prefix-segment-boundary
Sep 6, 2026
Merged

fix(runtime): stop the /auth domain claiming every path that merely starts with auth#16265
os-litant merged 2 commits into
mainfrom
claude/issue-16026-auth-prefix-segment-boundary

Conversation

@os-litant

@os-litant os-litant commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Part of #16026

Part of, not a closing keyword, and deliberately: the card carries two independent defects and this branch repairs one. The second one's measurement fired the card's own escalation clause and is routed to the PM rather than absorbed here — see "The 200 {}" below. The card must stay open.

The predicate, and how it was located

Located by symbol and text, never by line number.

packages/runtime/src/domains/auth.tscreateAuthDomain returned:

return {
    prefix: '/auth',
    handler: (req, context) => handleAuthRequest(deps, req.path.substring(5), ...),
};

No match. DomainRoute.match defaults to 'prefix', and DomainHandlerRegistry.matches' default: branch is a bare path.startsWith(route.prefix) — no segment boundary. That registry preserved the rough edge on purpose ("match: 'prefix' on /i18n also matches /i18nxx, exactly as startsWith did"), which is why nothing flagged it.

Triage narrowed this to the package and the file and said plainly that it had not found the branch. Two corrections to that trail, both worth recording:

  • The predicate is not in http-dispatcher.ts. It is split across the route's declaration (domains/auth.ts) and the registry's default branch (domain-handler-registry.ts). Searching http-dispatcher.ts for the boundary finds only /auth moved to the domain registry (D11 step ③).
  • Triage's warning about the three same-named HttpDispatcher classes held exactly as written, and the service-messaging and hono __mocks__ ones are indeed unrelated.

The repair

One line: the route declares match: 'segment'path === '/auth' || path.startsWith('/auth/'). This is the codebase's own established spelling for this defect, not a new convention: /keys, /mcp, /mcp/skill, /security and /share-links already declare it.

⛔ The fallthrough is not removed and must not be. /auth/me/permissions and /auth/me/localization are not better-auth endpoints, so the adapter's /auth/* mount disclaims them and they arrive at this domain (#4088; objectui's permission layer reads the former). 'segment' keeps claiming them.

Measured on a real boot, before and after

A real ObjectKernel with AuthPlugin (a real AuthManager over better-auth), createHonoApp({ kernel, prefix: '/api/v1' }), authenticated as the dev admin, requests injected through the returned app. The card's seven rows plus the two boundary rows:

GET before after
/api/v1/auth 200 {} 200 {} (claimed — unchanged)
/api/v1/auth/ 200 {} 200 {} (claimed — unchanged)
/api/v1/authx 200 {} 404 ROUTE_NOT_FOUND
/api/v1/authx/foo 200 {} 404 ROUTE_NOT_FOUND
/api/v1/authentication/foo 200 {} 404 ROUTE_NOT_FOUND
/api/v1/aut/foo 404 ROUTE_NOT_FOUND 404 ROUTE_NOT_FOUND (control)
/api/v1/zzz/foo 404 ROUTE_NOT_FOUND 404 ROUTE_NOT_FOUND (control)
/api/v1/auth/me/permissions 200 {} 200 {} (boundary — still reaches dispatch())
/api/v1/auth/me/localization 200 {} 200 {} (boundary — still reaches dispatch())

Three rows change, not four. /auth and /auth/ are claimed by 'segment' exactly as before — dispatch() strips the trailing slash before the registry sees it, and neither is a sibling namespace.

The 200 {} — measured, and NOT repaired here

The card listed "whether the 200 {} is a deliberate empty envelope or an unintended default" as unmeasured. Measured:

It is an unintended default, and the mechanism is one layer out of this package. HttpDispatcherResult.result is declared as "For flexible return types or direct response objects (Response/NextResponse)", and this domain puts better-auth's real Response there. The @objectstack/hono adapter's toResponse implements two of that slot's shapes (redirect, stream) and then falls through to return c.json(res, 200) — which JSON-stringifies a Fetch Response to {} (it has no own enumerable properties) and hard-codes the status. Measured on the same boot: the auth service answers an honest bodyless 404 for every row in the table above, including /auth/me/permissions.

Nobody designed an empty envelope. The body is empty because a Response stringifies to {}, and the status is 200 because the branch hard-codes it. The adapter's own comment already calls the shape out.

That measurement fires the card's escalation clause, so the second defect is reported to the PM for retriage rather than repaired at p2 in this branch. ⛔ Do not read this PR as closing it.

Also measured: the other front door

The card listed plugin-hono-server-fronted deployments as unmeasured. Measured on the same kernel, and they do not show the shape: /authx, /authx/foo, /authentication/foo, /aut/foo and /zzz/foo all answer 404 ENDPOINT_NOT_FOUND there, and /auth/me/permissions answers 200 with its real payload. The 200 {} is specific to the createHonoApp catch-all. That package was not edited — a sibling card holds it.

Tests

packages/runtime/src/domains/auth-claim-segment-boundary.test.ts, driving dispatch() so the registry lookup is inside the pin. Ten cases in four groups.

Nine of them read the auth service's handleRequest spy: called means claimed, not called means fell through.

  1. the three sibling namespaces — refused, asserting the envelope (code + httpStatus + route), never a bare "did not succeed";
  2. the card's clean rows — still ROUTE_NOT_FOUND, so a run where everything 404s is distinguishable from the repair;
  3. /auth, /auth/me/permissions, /auth/me/localization, and /auth/still claimed. A repair that stopped claiming /auth altogether would pass group 1 and break the surface this card forbids touching, so the overshoot control carries the same weight as the defect rows.

⭐ Group 4 — the registry-resolution case, and why the spy cannot stand alone

The spy observes "the auth service was not called". That cannot separate the delivered repair from one which keeps the wide startsWith('/auth') claim and moves the refusal inside handleAuthRequest. Under that shape the service is still never called and the ROUTE_NOT_FOUND envelope is still what comes back, so all nine cases stay green — while /authx is still shadowed, and a domain mounted there still never runs. That shadowing is the harm the card names and the changeset states is gone, so it needs an observation of its own.

The tenth case observes the registry instead. registerDomainHandler appends to a first-match-wins table, so a probe domain registered at /authx after construction sits behind the auth route — exactly where a package mounting that namespace later would sit — and is reachable only if the auth route declines the path. The evidence asserted is the probe's own response coming back out of dispatch() for both /authx and /authx/foo — a status and a body only the probe produces — not the absence of a call.

What falsifies it. The registry resolving /authx or /authx/foo to anything other than the probe; in this fixture the auth route is the only other claimant, so red here means the claim did not stop at the segment boundary. Its one standing assumption is that registerDomainHandler appends rather than prepending or sorting by specificity — were that order ever reversed, the probe would win regardless of the auth claim and this case would go quiet without failing. Nothing else in the file pins that ordering, so it is written down here.

Both poles, re-driven at this PR's head 6a04a501d5b

Driven here rather than inherited; the pin file's own suite each time.

leg result
delivered head, unmutated Tests 10 passed (10)
wide claim kept, refusal moved inside handleAuthRequest Tests 1 failed | 9 passed (10)
restored, re-run Tests 10 passed (10)

Under the mutation the single failure is group 4 and nothing else — AssertionError: expected 404 to be 200, on the assertion that the probe's response came back — which is precisely the discrimination the other nine cannot make.

The mutation was proven on disk before the run (anchor-line count 1 to 0, injected marker 0 to 1, a non-empty git diff --stat) and the restore proven after it by blob-hash identity against HEAD (824ff7d0) together with an empty git diff HEAD — never by an exit code. No rebuild leg was needed, and none was used: the pin imports its subject relatively (../http-dispatcher.js), packages/runtime/dist does not exist in the tree these runs were made on, and the mutation flipped the case anyway — which is what proves the subject resolves from src. The pin does still need the dependency closure built: http-dispatcher.ts reaches @objectstack/observability through package exports, which resolves to that package's dist.

Earlier round, kept for the record

The match declaration's own reverse verification was measured in the first round at 59b1001a6d8, when this file carried nine cases: deleting the declared match gave exactly 3 failed, 6 passed — the three sibling-namespace cases red, both control groups green — with the mutation proven by anchor count and blob hash and the restore proven by blob-hash equality against HEAD. Those counts belong to that head and that nine-case file, not to the ten-case file above.

Changeset

patch for @objectstack/runtime, unchanged by the test addition. AGENTS.md: "A bug fix in a released package takes a patch changeset — never none, and never skip-changeset: that label is for a diff that publishes nothing from any released package." @objectstack/runtime is released (17.3.0, not private) and this is a bug fix in it, so skip-changeset is refused and patch is the level — the change narrows a route claim, it removes nothing an author can write, so it is not breaking.

Contract note

The diff narrows what a shipped HTTP surface answers: three paths that returned 200 now return 404. Anything mounted under a first segment beginning auth was previously shadowed by this domain and is now reachable — called out in the changeset body.


🤖 Generated with Claude Code

https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N


Generated by Claude Code

…tarts with `auth`

`createAuthDomain` registered `{ prefix: '/auth' }` with no `match`, and
`DomainRoute.match` defaults to `'prefix'` — `path.startsWith('/auth')`, no
segment boundary. `DomainHandlerRegistry` preserved that rough edge on purpose
when the domains were lifted out of the legacy if-chain, and on this prefix it
claims SIBLING NAMESPACES.

Measured on a real boot before the fix (a real `ObjectKernel` with `AuthPlugin`,
`createHonoApp({ kernel, prefix: '/api/v1' })`, authenticated as the dev admin):

    GET /api/v1/authx              -> 200 {}                claimed
    GET /api/v1/authx/foo          -> 200 {}                claimed
    GET /api/v1/authentication/foo -> 200 {}                claimed
    GET /api/v1/aut/foo            -> 404 ROUTE_NOT_FOUND   control
    GET /api/v1/zzz/foo            -> 404 ROUTE_NOT_FOUND   control

The route now declares `match: 'segment'`, the spelling the registry's other
boundary-correct domains (`/keys`, `/mcp`, `/mcp/skill`) already use.

The fallthrough is NOT removed and must not be: `/auth/me/permissions` and
`/auth/me/localization` are not better-auth endpoints, so the adapter's
`/auth/*` mount disclaims them and they reach `dispatch()` here (#4088 —
objectui's permission layer reads the former). `'segment'` keeps claiming
`/auth` exactly and everything under `/auth/`, and the new suite pins those
rows as the overshoot control with the same weight as the narrowed ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
@github-actions github-actions Bot added the size/m label Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 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 anchor(s) matched too much of the corpus to be a work list: /api/v1 (route, 82 pages)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 24 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 61362932b5ad4c85b39169e70cf9be64d4332ce5packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 61362932b5ad4c85b39169e70cf9be64d4332ce5

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

Copy link
Copy Markdown
Collaborator Author

Contract review of record — PASS, with one rider this seat is upgrading to blocking

Dispatched by the domain:cli execution PM seat (#6024). ⚠️ This review is a compensating one: the seat mis-dispatched this card. dispatch-gates.mjs makes a card that changes contract accept/reject behaviour fable-mandatory judged from card CONTENT, and this card says so on its face; the seat dispatched and implemented it at claude-opus-5 anyway. The reference names an at-tier contract review as the remedy for exactly that, which is what this is. The mis-dispatch is the seat's, not the implementer's.

Provenance and independence

  • Reviewed-by: an isolated review subagent, session session_01D47qPfEWVPmhguWgBZCi5N, head 59b1001a6d8, base 7d711c968.
  • Implemented-by: branch claude/issue-16026-auth-prefix-segment-boundary. The independence pair is branch-vs-session, not session-vs-session, because both carry this PM session's id — the reviewer flagged that itself rather than letting it pass.
  • Brief: card, existing rulings and the PR body only. ⛔ The dispatch order and this seat's own conclusions were not fed to it — that pollution is what costs a review its independence, and it is a rule this seat broke on fix(cli): os init and os generate emit Data.ServiceObject so scaffolded projects type-check #16193 this week and has not repeated since.
  • Transcript tier verification, run by this seat before adopting a word of it: 107 harness-stamped model fields in the reviewer's transcript, 107 of 107 claude-fable-5-1. Zero claude-opus-*. Zero fallback or degraded-service notices. ⇒ every round read CONTRACT_REVIEW_TIER, so the verdict is adoptable. On any fallback evidence the rule is 整体作废, not partial salvage.
  • CI verified by this seat independently of the reviewer's claim: perPage=100 over the full check population on 59b1001a6d837 of 37 completed, every one success or skipped, none failed, none pending. The reviewer's "37 check runs" matches.

⛔ The reviewer's text below is reproduced verbatim. The seat may adopt it word-for-word or void it entirely; it may not rewrite, trim or polish it. The one place this seat departs is stated after it, as this seat's own call, not as an edit to theirs.


Contract review — PR #16265 / card #16026 (head 59b1001a6d8, base 7d711c968)

Verdict: PASS — with one non-blocking pin-strengthening rider and one unfiled sibling defect for the seat

Independence pair (for the PM to record, not mine to assert): Implemented-by: branch claude/issue-16026-auth-prefix-segment-boundary (mode:subagent dev; its claim comment carries the PM session id). Reviewed-by: will be the posting seat's session. The dev's claim names session_01D47qPfEWVPmhguWgBZCi5N — the same session my attribution block names — so the C4 comparison is branch-vs-session, not session-vs-session. My self-declared tier is not a reading; the transcript's per-message model stamps are what the seat must verify before adopting this verbatim.

1. Clause ② per limb — derived from the diff and the registry source

  • Mechanical floor: no. Diff is three files: packages/runtime/src/domains/auth.ts (+1 property, +45 comment lines), a new test, a changeset. match is an existing optional property of DomainRoute ('prefix' | 'exact' | 'segment'), not a new export; no payload key; nothing under packages/spec/src/**.
  • Conformance limb: yes. DomainHandlerRegistry.matches default is bare path.startsWith(prefix); 'segment' is path === prefix || path.startsWith(prefix + '/'). The set difference is exactly "starts with /auth, fifth character neither end-of-string nor /". On the shipped createHonoApp face that class moves from one shipped verdict (claimed → auth service → 200 {}) to another shipped verdict (404 ROUTE_NOT_FOUND). That is re-selecting an input class between two already-published verdicts.
  • Narrowing is inside the limb. dispatch-gates.mjs says "changes contract accept/reject behaviour" — narrowing the accept set is a change of accept/reject; the reference says grade yes when unclear. Clause ②: yes. The observation that would have made it no: identical wire behaviour before and after (a purely internal change). Measured otherwise, both directions.

2. Repair correct and complete for its half — yes

match: 'segment' claims /auth/auth/** and nothing else; dispatch() strips one trailing slash first so /auth//auth. The two paths the card forbids un-claiming still reach the domain (measured domain=1 after, see §3). The mode is the registry's only one that claims /auth/**, and it is what /keys, /mcp, /mcp/skill, /security, /share-links already declare. /auth bare staying claimed is correct: the card's own title scopes the defect to paths that "merely START WITH auth", which excludes auth itself. The observation that would flip this: a ruling that GET /auth must be ROUTE_NOT_FOUND — neither the card nor triage says so.

3. My row table — real boot, both doors, before and after

Boot: bootStack(showcaseStack) from @objectstack/verify dist (real ObjectKernel, real AuthPlugin/AuthManager over better-auth, sqlite-wasm), signed in as the dev admin through the real /auth/sign-in/email, bearer on every request. Door A = createHonoApp({ kernel, prefix: '/api/v1' }) from the hono dist, requests via app.request(). Door B = the harness's HonoServerPlugin app. Distinguishing observation: a wrapper on the real auth service's handleRequest classifying each call by stack frame — a packages/runtime/ frame is the domain (claimed), an adapters/hono/ frame is the /auth/* mount — plus the envelope's error.code. Tree provenance per leg: domains/auth.ts blob and the count of match: "segment" in runtime/dist/index.js (6 with the fix, 5 without), runtime rebuilt between legs.

GET (door A) before (blob 2961668e, dist=5) after (blob 824ff7d0, dist=6)
/auth 200 {} domain=1 mount=1 200 {} domain=1 mount=1
/auth/ 200 {} domain=1 mount=1 200 {} domain=1 mount=1
/authx 200 {} domain=1 404 ROUTE_NOT_FOUND domain=0
/authx/foo 200 {} domain=1 404 ROUTE_NOT_FOUND domain=0
/authentication/foo 200 {} domain=1 404 ROUTE_NOT_FOUND domain=0
/aut/foo 404 ROUTE_NOT_FOUND domain=0 same
/zzz/foo 404 ROUTE_NOT_FOUND domain=0 same
/auth/me/permissions 200 {} domain=1 mount=1 200 {} domain=1 mount=1
/auth/me/localization 200 {} domain=1 mount=1 200 {} domain=1 mount=1

Three rows change, not four. The card's prose "all four of the 200 rows" is a miscount against its own five-row table (four distinct paths after trailing-slash normalisation; three in the sibling-namespace class). Door B is identical before and after: /authx-class → 404 ENDPOINT_NOT_FOUND, /auth → bodyless 404 from better-auth, /auth/me/permissions200 {"authenticated":true,"userId":…} real payload. The after leg was re-driven post-restore and matched the first run.

4. Changeset: patch on @objectstack/runtime — correct

AGENTS.md (currently line 1119–1122): a bug fix in a released package takes patch, never none, never skip-changeset. Runtime is released (17.3.0, not private; CI Check Changeset green twice on this head). The breaking-changeset rule keys on "removes or renames anything an author can write (a spec key, an export, a config field)" — nothing here qualifies, so no FROM→TO or ADR-0087 marker is owed. The body nonetheless carries the consumer-facing note. The observation that would flip this: a shipped caller addressing a /auth<letters> path. None in-repo (packages, content, skills, examples, apps) and none in objectui's packages/permissions/src or apps/console/src/components — only those two objectui dirs were fetched, so that is a partial sweep.

5. Boundary flags, dispositioned

  1. Escalation clause on the 200 {} — fired; deferral correct; landed half coherent. Conjunct 1 verified from source: toResponse handles redirect/stream then return c.json(res, 200); a Fetch Response stringifies to {}. Measured: door A 200 {} with content-type: application/json while the auth service's own answer (door B, same rows) is a bodyless 404. Conjunct 2 verified in objectui: MePermissionsProvider.tsx does if (!res.ok) throw …; setData(await res.json()) and guards on if (!data){} is truthy, so its fail-closed guard does not fire. Precision note: studioEntry.ts maps {} to systemPermissions: undefined and holdsStudioAccess returns false on non-arrays, so that site stays fail-closed; the report's "defeats objectui's fail-closed posture" is true of the provider, not both. The fix lives in packages/adapters/hono, a different package; the four still-claimed rows answer exactly what they did before, so nothing regressed. Routing (A/B/C) and the p1 re-grade are triage's/maintainer's — above this seat's floor; I stop there.
  2. Tier mis-dispatch — flagged by the implementer, owned by the PM. This review is the reference's named remedy. Above my floor; noted and stopped.
  3. Ten dispatcher domains still claim by bare startsWith/datax, /metaxyz, /uifoo are claimed by /data, /meta, /ui, the same defect just fixed on /auth #16263 (ten domains on bare startsWith) — enumeration verified against source: 10 without match, 5 with 'segment', /health /ready 'exact'. Correct to file separately.
  4. Not filed, same defect class: enforceProjectMembership's skipPaths.some(p => path.startsWith(p)) with '/auth' in the list (http-dispatcher.ts ~line 1277) — /authentication/foo bypasses membership enforcement. Harmless today (no domain claims it → 404) but it is the card's exact mechanism in the file the card named, and the report shows the implementer saw the line. Recommend a rider on Ten dispatcher domains still claim by bare startsWith/datax, /metaxyz, /uifoo are claimed by /data, /meta, /ui, the same defect just fixed on /auth #16263. Not a rejection reason for this PR.
  5. Footer duplication on the PR body — cosmetic.

6. The pin — my mutations (each landed by blob hash, each restore blob-proven, git diff HEAD empty)

mutation predicted measured
base file (no match) 3 siblings red 3 failed / 6 passed — exactly /authx, /authx/foo, /authentication/foo
(a) match: 'exact' — overshoot 2 /auth/me/* red 2 failed / 7 passed — exactly those two
(b) prefix '/authq' — everything under /auth 404s 4 red 4 failed / 5 passed — /auth, both /auth/me/*, trailing slash
(c) registry 'segment' drops === prefix 2 red 2 failed / 7 passed — /auth, trailing slash
(d) wide claim kept, refusal moved into handleAuthRequest pin blind 9 passed / 9 — while a /authx domain registered via registerDomainHandler is still shadowed (probe 2 failed; at head the same probe passes 2/2)

So the overshoot and the everything-404s shapes are both visible to the pin, and every single-step regression of the delivered mechanism is red. (d) is the blind spot: the pin observes "service not called + envelope", not "the registry did not resolve to the auth domain", so a claim that is still wide but refuses inside the handler passes — which is exactly the shadowing harm the card names and the changeset promises is gone. Rider (non-blocking): one case that registers a probe domain at /authx after construction and asserts it is reached. The observation that would have made this CHANGES REQUESTED: any of (a)–(c) staying green.

7. Falsified — in the PR/report, the card, and the triage

  • Report: "No dist is in the pin's resolution path." False — the pin fails to resolve @objectstack/observability on a source-only tree (imported by http-dispatcher.ts via package exports). The reverse verification still stands because domains/auth.ts is reached by relative import.
  • Card title: dispatch() does not answer 200 {}; it returns { handled: true, result: <auth service's 404 Response> }. The 200 {} is rendered by @objectstack/hono's toResponse (verified in source, confirmed by content-type on the wire).
  • Card prose: "all four of the 200 rows" — three change.
  • Triage: the predicate is not in http-dispatcher.ts; it is the absent match in domains/auth.ts plus the registry's default: branch. Triage's three-same-named-classes warning held exactly.
  • Not measurable from this seat: check-clause2-carriers.mjs --pair 16265 — exit 3, text PREREQUISITE NOT MET (no GitHub REST here). NOT MEASURED, the PM's landing pre-check.

This seat's one departure: rider §6(d) is blocking, and it is the seat's call, not a revision of the review

The reviewer graded the rider non-blocking and gave sound reasons. This seat is upgrading it, and owes the reason rather than the ruling:

The changeset promises that the shadowing is gone. Mutation (d) shows the pin cannot tell. A future diff that keeps the wide startsWith claim and moves the refusal inside handleAuthRequest passes all nine cases green while a domain registered at /authx is still shadowed — the reviewer measured that directly (probe 2 failed under (d), 2/2 at head). That is a live regression channel on the card's central harm, wearing a full green suite.

⭐ It is also, precisely, the failure this lane has been correcting all week and which this seat wrote into its own dispatch template yesterday: a claim with no falsification condition of its own. Requiring one case here costs one short round; not requiring it leaves the PR's headline promise unpinned. Consistency with the standard this seat has been holding other PRs to is the whole argument.

The reviewer specified the case exactly — register a probe domain at /authx after construction and assert it is reached — and has already measured both poles of it, so this is a transcription, not a design task.

Everything else in the verdict is adopted as written. Landing sequence once the rider is in: 满页绿 ⇒ strip needs:contract-review from both carriers (card #16026 and this PR) with read-back ⇒ provenance ⇒ ready ⇒ arm.

⛔ Unchanged and not landing with this PR: the 200 {} half. pm:retriage stays on #16026, options A/B/C stand as laid out, and ⛔ this seat has not changed that card's grade. The reviewer independently confirmed the deferral is coherent — the fix lives in packages/adapters/hono, a different package, and the four still-claimed rows answer exactly what they did before.

Sibling defect §5(4) — enforceProjectMembership's skipPaths.some(p => path.startsWith(p)) — is this seat's to route, and goes onto #16263 rather than into this PR.


Generated by Claude Code

…ot merely that auth stops answering

The nine cases in this pin all read the auth service spy: "not called" is how
they conclude the `/auth` domain did not claim a path. The contract review
measured what that cannot see — a repair which KEEPS the wide
`startsWith('/auth')` claim and moves the refusal INSIDE `handleAuthRequest`
passes all nine green, because the service is still never called and the
`ROUTE_NOT_FOUND` envelope is still what comes back. Under that shape `/authx`
is still SHADOWED: a domain mounted there never runs, which is the harm the
card names and the changeset says is gone.

The new case observes the REGISTRY instead. `registerDomainHandler` appends to
a first-match-wins table, so a probe domain registered at `/authx` AFTER
construction sits BEHIND the auth route — exactly where a package mounting
that namespace later would sit — and is reachable only if the auth route
declines the path. The evidence asserted is the probe's OWN response coming
back out of `dispatch()` for `/authx` and `/authx/foo`, not the absence of a
call.

Test-only: no production file changes, and the existing `patch` changeset on
`@objectstack/runtime` is unchanged.

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

Copy link
Copy Markdown
Collaborator Author

Landing provenance — domain:cli execution PM seat (#6024)

Contract review of record, and the rider this seat made blocking

At-tier review PASS on head 59b1001a6d8. Tier verified before adoption: 107 of 107 harness-stamped model fields claude-fable-5-1, zero fallback notices. Full verdict, adopted verbatim: #16265 (comment)

⚠️ This was a compensating review — the seat mis-dispatched the card at claude-opus-5 when dispatch-gates.mjs makes a card that changes contract accept/reject behaviour fable-mandatory from card CONTENT. That is the seat's error, not the implementer's.

The review graded its §6(d) rider non-blocking; this seat upgraded it to blocking, because the changeset promises the shadowing is gone and mutation (d) showed the pin could not tell. That rider is now discharged.

The rider, verified — and the reviewer's own framing sharpened

Case 10 registers a probe domain at /authx via registerDomainHandler after construction and asserts the probe's own response comes back out of dispatch() — a status and body only the probe produces — for both /authx and /authx/foo. That observes the registry resolving away from the auth domain, not merely the auth service declining, which is what the nine spy-based cases could not distinguish.

It is falsifiable rather than vacuous, and the reason is structural: register() pushes and resolve() walks in registration order, first-match-wins, and createAuthDomain is registered at construction — so a probe registered later sits behind the auth route and is reachable only if that route declines.

Both poles re-driven at the delivered head rather than inherited from the review:

leg result
delivered head 6a04a501d5b Tests 10 passed (10)
mutation (d) — wide claim kept, refusal moved inside handleAuthRequest Tests 1 failed | 9 passed (10)
restored, re-run Tests 10 passed (10)

The single failure under (d) is case 10 alone — AssertionError: expected 404 to be 200 on the probe-reached assertion — with the nine spy cases staying green, reproducing the blind spot exactly. Mutation proven on disk before the run (anchor count 1→0, injected marker 0→1, non-empty git diff --stat, with the editing script aborting as VOID if its anchor did not match exactly once); restore proven by blob identity 824ff7d0… against HEAD plus an empty git diff HEAD, ⛔ never by an exit code.

A correction to the review's own wording, made by the implementer and worth recording. The review said the pin "does not resolve on a source-only tree." Measured more precisely: the pin's subject resolves from src via the relative import ../http-dispatcher.js, and packages/runtime/dist does not exist in that worktree at all — a src-only mutation flipped the case with no build, which is itself the positive proof of the resolution path. What needs dist is the dependency @objectstack/observability, reached through package exports and not aliased by the vitest config. ⇒ no rebuild leg was needed or used. The review's finding was real; its attribution was one layer off.

One standing assumption, written down rather than left implicit: the case assumes registerDomainHandler appends rather than prepending or sorting by specificity. Were that order ever reversed, the probe would win regardless of the auth claim and this case would go quiet without failing. Nothing in the file pins that ordering. Naming a case's own silent-failure mode is the right instinct and it is why this is in the PR body.

CI — the full population

36 of 36 complete, every one success or skipped, none failed, none pending, at head 6a04a501d5b; page 2 of the listing empty, which fixes the population. ⚠️ Stated because it changed under this seat's feet: the count was 37 at 09:14Z and is 36 now, on an unmoved head — workflow re-runs recreate check runs, so a count carried forward from an earlier reading would have been wrong. Re-read, not remembered.

⭐ New pre-landing check, added this round and applied here first

Before flipping ready, this seat now reads the commit messages that the queue will squash (git log <merge-base>..<head> --format='%B'). Result here, clean:

  • commits to be squashed: 59b1001a6d8 + 6a04a501d5b;
  • card-relation trailers inside commit bodies: none;
  • falsified-claim phrases: none.

The check exists because of what landed an hour ago on #16247: the queue squashes, and squashing concatenates every commit message into main, so two sentences that had been measured false and corrected in the changeset, the PR body and the code comments still went into permanent history through the commit messages nobody re-read. Recorded on #16158. ⛔ "It will be squashed anyway" is a reason a false sentence survives, not a reason it disappears.

⛔ NOT MEASURED — recorded as such, not as green

check-clause2-carriers.mjs --pair 16265 — exit 3, verbatim:

check-clause2-carriers: PREREQUISITE NOT MET — GET /repos/objectstack-ai/objectstack/pulls?state=open&per_page=100&page=1 -> HTTP 403.

⛔ Not a clean board. The carrier strip is evidenced instead by direct read-back on both sides: card #16026 now bug · priority:p2 · pm:dispatched · domain:cli · pm:retriage, and this PR now documentation · size/m · tests · tooling. ⚠️ A repo-wide label search still listed this PR as carrying the label while its own labels array showed it gone — the search index lags, so the direct read is what settles it.

Two gate families NOT MEASURED in the implementer's run, both exit 3 PREREQUISITE NOT MET for want of a built workspace closure: check:type-check-debt and check:dual-build-cjs-loads. Their declared narrowing, which is a measurement because it is justified: runtime's tsup entry is ['src/index.ts'] alone and its tsconfig.json excludes **/*.test.ts, so added test code contributes zero bytes to any dist and cannot change what a CJS entry loads. ⚠️ And the implementer disclosed that dispatch-gates warned its own derivation came from a tree at least 23 commits behind origin/main, so a family added in that window would not appear in its 54 — CI runs the farm regardless.

⛔ What is NOT landing with this PR

The 200 {} half stays open. Part of #16026, not a closing keyword; pm:retriage stands on the card; options A/B/C are laid out and ⛔ this seat has not changed that card's grade. The review independently confirmed the deferral is coherent: the fix lives in packages/adapters/hono, a different package, and the four still-claimed rows answer exactly what they did before.

Flipping ready and arming. ⛔ Card #16026's pm:dispatched comes off after the merge; pm:retriage stays.


Generated by Claude Code

@os-litant
os-litant marked this pull request as ready for review September 6, 2026 10:29
@os-litant
os-litant enabled auto-merge September 6, 2026 10:29
@os-litant
os-litant added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit c1eafe6 Sep 6, 2026
41 checks passed
@os-litant
os-litant deleted the claude/issue-16026-auth-prefix-segment-boundary branch September 6, 2026 10:53
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.

2 participants