Repository navigation
[Decision] Fail-close isAuthGateAllowlisted's "no path ⇒ exempt" default (Option 2 of #7432) #7898
Description
Activity
- added and removed
on Aug 12, 2026 Maintainer ruling — 2026-08-12
Ruled: Option B — defer. Option 1 (PR #7836's call-site guard) stands as the shipped protection;
isAuthGateAllowlisted's "no path ⇒ exempt" default stays for now. Decision made, answer is "not now" ⇒pm:on-hold(out of the decision box, not dispatched, not nagged).Provenance: maintainer, triage PM session chat (
session_01NKGoRBFZELVKivDkiAswdz), 2026-08-12, verbatim: 「其他两张按照你的建议」 — adopting the seat's recommendation on this card (as presented: lenses ①/③ favor A, ②/④ favor B; zero current pull — census shows no caller depends on the exempt default and the hono adapter always populatespath— makes defer the scope-disciplined choice).Hold record (2026-08-12):
- Reason: purely preventive hardening; no reachable exposure today (A2's empty-path case reaches only gate-exempt discovery or 404).
- Restart conditions — any one fires this back to the queue as the Option-A implementation card (fail-close at source +
'' → '/'normalization, contract-first split:packages/corefirst,packages/runtimeafter):- A second transport adapter appears (any dispatcher-request producer beyond
adapters/hono). - Any synthetic-request caller reaches the auth gate without a populated
path. - Any measured empty-path request reaching a non-discovery route.
- A second transport adapter appears (any dispatcher-request producer beyond
- Trigger files (any PR touching these must check this hold — for the lane seats' 派发前必查 sections):
packages/core/src/security/auth-gate.ts·packages/runtime/src/http-dispatcher.ts·packages/adapters/**(new adapter = trigger 1 by definition).
Generated by Claude Code
Half-state repair: removed
needs-user-decision(keptpm:on-hold+domain:cli). Provenance: the 2026-08-12 08:15Z maintainer ruling on this card (Option B — defer) states "Decision made, answer is 'not now' ⇒pm:on-hold(out of the decision box)"; the box label was never removed, leaving the card in both states at once. No new decision is made here — this completes the recorded one. Hold record and restart conditions in the 08:15Z comment stand unchanged.本评论来自分诊座位(scheduled session
session_01DLzE5XVRASxFotEU1h9cjH),不构成认领。
Generated by Claude Code
⚠️ Restart-whenFIRED — recording it now, discharge in progressdomain:cliexecution PM seat (#6024) · sessionsession_01UngCYXF98BVpYA9hfz6NYkThis card's exit predicate, verbatim from its own first line:
Restart-when: any PR touches
packages/core/src/security/auth-gate.ts,packages/runtime/src/http-dispatcher.tsor adds an adapter underpackages/adapters/**PR #13763 (#13408) modifies
packages/runtime/src/http-dispatcher.ts— +189 / −21. ⇒ The predicate fires, literally and unambiguously, on a file this card names by path.Recording the firing before the answer is known, on purpose: a predicate that fires and lives only in the acting seat's head is indistinguishable from a predicate that never fired. The whole point of writing an exit condition down is that someone else can audit whether it was honoured.
⛔ Firing is NOT promotion — and the distinction is in this card's own text
The implementing dev reported the firing accurately and correctly declined to act (acting on a hold is not a dev's call). It then compared the firing against the hold's promotion triggers — a second transport adapter, a synthetic-request caller — and found neither fires. True, but that is the wrong comparison, and the difference matters:
- Promotion triggers ask "has the appetite changed?"
- This file-touch clause asks "is the premise still true?"
Option B is defensible today only because of a specific measured claim about that exact file:
The one thing a flip must price in (A2):
http-dispatcher.tscomputescleanPath = path.replace(/\/$/, ''), so${prefix}/yieldscleanPath === '', reachable on a shipped transport (adapters/hono). Today that reaches only the discovery payload (gate-exempt by design) or a 404 — not a bypass.⇒ ANY touch of that file wakes this card because a 189-line edit to it can quietly invalidate that sentence. "The promotion triggers didn't fire" does not answer "does A2 still hold".
What I have asked for — a reading, not an opinion
The implementing dev has been sent back to run this card's own re-check command on both its branch head and its merge base:
git grep -n "replace(/\\/$/" -- packages/runtime/src/http-dispatcher.tsplus three questions: does
cleanPathstill yield''for${prefix}/; can any of the 189 added lines be reached with an emptycleanPath(i.e. is the new/readycode before or after the auth-gate seam, and is/readygate-exempt by construction); and were anyenforceAuthGate/isAuthGateAllowlistedcalls added, moved or bypassed.⇒ If A2's premise survives, I re-arm the hold with the measurement attached, and this comment becomes the audit record that the predicate was honoured rather than ignored. ⭐ That is worth more than the hold's status either way: the next firing will have a worked example of what discharging one looks like.
⇒ If it moved, the card is genuinely restarted and goes back to the maintainer with the reading — ⛔ and this seat still does not decide it. The card is an appetite call between A and B with lenses 1/3 against 2/4; that was escalated deliberately and nothing here changes it.
⛔ Nothing has been decided, re-graded, or dispatched on this card.
pm:on-holdstands until the reading is in.
Generated by Claude Code
✅ Predicate DISCHARGED — A2's premise re-measured and INTACT. Hold re-armed, unchanged.
domain:cliexecution PM seat (#6024) · sessionsession_01UngCYXF98BVpYA9hfz6NYkFollowing my comment
5476861538: PR #13763 fired this card's file-touch clause by editingpackages/runtime/src/http-dispatcher.ts(+189/−21). I said a reading was coming rather than an opinion. Here it is, measured by that PR's implementer on both its branch head and its merge base.1. The premise sentence itself — byte-identical
This card's own re-check command, run on both refs:
git grep -n "replace(/\/$/" -- packages/runtime/src/http-dispatcher.tsReturns a byte-identical statement on both —
sha256 94417612249abecb. Only the line number moved. ⇒cleanPath = path.replace(/\/$/, '')is unchanged, and${prefix}/still yieldscleanPath === ''.2. ⭐ The new code is strictly downstream of the gate — measured by call order, not asserted
dispatch()'s order on the branch head:step line cleanPathcomputed2162 resolveRequestScope2174 enforceAuthGate2181 domainRegistry.resolve2210 ⇒ The
/readyhandler resolves at 2210, after the gate at 2181. None of the 189 added lines sits betweencleanPathand the gate.3. The empty-
cleanPathpath cannot reach the new code at allThe
/readyroute is declaredmatch: 'exact', and'' === '/ready'is false. ⇒ On the bare-root${prefix}/request this card is about, the added lines are unreachable — the case the card prices in does not touch them.4.
/readyis gate-exempt by construction, and that did not move eitherVia
ALLOW_SUFFIXESatauth-gate.ts:63— identical on both refs. So the new handler is not relying on anything the diff introduced.5. No seam was added, moved, or bypassed
The
enforceAuthGate/isAuthGateAllowlistedcall-site set diffs to nothing across the change, andpackages/coreandpackages/adapters/**are untouched by PR #13763.
⇒ Verdict: the hold's premise SURVIVES.
pm:on-holdstands, unchanged.A2 still reads exactly as this card records it:
${prefix}/yields an emptycleanPath, reachable on the shippedhonoadapter, and it still reaches only the gate-exempt discovery payload or a 404 — not a bypass.⛔ Nothing decided, re-graded, promoted or dispatched. The A-versus-B appetite call is untouched and remains the maintainer's: lenses 1/3 for A, lenses 2/4 for B, exactly as escalated. ⛔ The substantive promotion triggers (a second transport adapter, a synthetic-request caller) did not fire — PR #13763 adds no adapter.
⭐ Why this comment exists at all
The file-touch clause is a premise-staleness trigger, not a promotion trigger, and the two are easy to conflate — the implementing dev initially compared the firing against the promotion triggers, found neither fired, and concluded nothing was owed. That reasoning was reasonable and reached the right outcome by the wrong route: it would have held even if the diff had rewritten
cleanPath.⇒ The discharge is what makes the difference visible. This card now carries a worked example: a fired predicate is discharged by re-measuring the premise it protects, and the reading is recorded whether it survives or not. A predicate that fires, is judged in someone's head, and leaves no trace is indistinguishable from one that never fired — which is the whole reason it was written down.
⚠️ Note for the next firing: the line numbers in this card's Premises section have moved (they moved again in PR #13763). ⛔ Re-derive with the card's own grep; do not quote its line numbers.
Generated by Claude Code
⚠️ This card's own re-check command is UNRUNNABLE as written — it returns a FALSE ZEROdomain:cliexecution PM seat (#6024) · sessionsession_01UngCYXF98BVpYA9hfz6NYkSecond discharge of this card's file-touch predicate today (PR #13811, #13623,
packages/runtime/src/http-dispatcher.ts). The premise is again intact — reading below — but the implementer hit something that matters more than this firing:The defect
The command this card prescribes for its own re-check:
git grep -n "replace(/\/$/" -- packages/runtime/src/http-dispatcher.ts⛔ returns zero through a shell, because
git grepreads the pattern as a regex —(,/,$are not literals there. The next author who discharges this hold runs the card's own command and is told "the premise sentence is gone", which is the single most alarming thing this card could falsely report: A2's premise vanishing is exactly what would force a restart.The runnable form — fixed-string, and never without a positive control beside it:
git grep -nF "replace(/\/$/" -- packages/runtime/src/http-dispatcher.ts⚠️ ⛔ A zero from the broken form is an instrument failure, not a reading. Anyone discharging this hold must pair it with a control term known present in the same file, or the zero is uninterpretable.⛔ And a correction to my own earlier comment on this card
In comment
5477733964I recorded the premise sentence assha256 94417612249abecb.⚠️ Do not treat that as a canonical fingerprint. This discharge computedsha256 933a841baa6ffde75e190613bdf298e8d330e95da960351e9337a5e9a5926b02over the trimmed line — a different normalisation, and the earlier value was truncated to 16 characters. ⇒ The two are not comparable, and neither is authoritative. Anyone comparing refs should state the normalisation and compute both sides themselves rather than diffing against a number in a comment.⭐ Both discharges independently found the sentence unchanged, which is the substantive point; ⛔ but "two hashes that do not match because they measure different things" is exactly the shape that panics the next reader, so it is corrected here rather than left to be discovered.
The discharge itself — premise INTACT, re-measured independently
Taken on merged
maineb717a12a8and branch head4ecc0d47f7, ⛔ citing no number from my dispatch (the tree had moved when #13763 merged):- premise sentence byte-identical on both refs, still at line 2162, unmoved;
dispatch()order:cleanPath2162 →resolveRequestScope2174 →enforceAuthGate2181 →enforceProjectMembership2190 →domainRegistry.resolve2210 — and this diff lands at ~2324, strictly after the gate;/readystill gate-exempt viaALLOW_SUFFIXESatauth-gate.ts:63;- auth-gate call-site set diffs to nothing between the refs.
All three restart conditions checked live; NONE fires:
packages/adapters/holds exactly one entry (hono); this PR touches neitherpackages/adaptersnorpackages/core— 0 files.- ⭐ The condition triage singled out — a census of all 38 production
dispatcher.dispatch(call sites shows every one passes a non-empty path, including the two computed ones (aiSubPathfloors at the literal/ai;mountPackagesRoute's empty sub-path is concatenated onto/packages). This PR adds no production caller. DomainHandlerRegistry.matchesis exact / segment /startsWith, and the empty string satisfies no registered prefix — so an emptycleanPathstill reaches only the discovery payload orrouteNotFound.
⇒
pm:on-holdstands, unchanged. ⛔ Nothing decided, re-graded, promoted or dispatched. The A-versus-B appetite call remains the maintainer's (lenses 1/3 for A, 2/4 for B).⛔ The body edit is not mine to make
The prescribed command lives in this card's body, and this is an on-hold decision card — its body is the decision record. ⇒ I am recording the correction as a comment rather than editing it, and flagging that the body still carries the broken command. Whether it is amended is the maintainer's call; until then this comment is what a discharging author needs to read first.
⭐ Worth noting the implementer explicitly declined to edit it too, for the same reason, and declined to file a card proposing the edit — routing it here instead. That is the correct boundary in both directions.
Generated by Claude Code
os-project-manager commented
on Sep 10, 2026 CollaboratorMore actionsThis card's
Restart-when:fired on 2026-08-13 and has fired ~11 times since — nobody swept itdomain:cliexecution PM seat (#6024) · state audit of all 10pm:on-holdcards in the lane, at the maintainer's instruction. State transition:pm:on-hold→needs-user-decision(onereplace;domain:cliuntouched).The predicate, and the measurement
This card's hold reads, verbatim from the first line of the body:
Restart-when: any PR touches packages/core/src/security/auth-gate.ts, packages/runtime/src/http-dispatcher.ts or adds an adapter under packages/adapters/** (a second transport adapter is restart condition 1 by definition)
Measured on
origin/main,git log --since=2026-08-12 -- <the two named files>— 11 commits, newest first:commit subject cf791820b9fix(core): the auth-gate allow-list matches at a mount boundary (#17284) ce8bfc9d65fix(runtime): domain claims and the membership skip list stop at a segment boundary (#16842) 68f8f77205fix(runtime): the classified lookup keeps the scope it was handed (#16789) 3e560da59cfix(runtime): carve liveness out of the identity step so a config fault cannot restart a pod (#16561) de75e407e5fix(runtime): the /keys mint and install-wide activation gates classify a tenancy resolution failure instead of reading it as "no wall" (#16385) 6491463893fix(discovery): stop advertising a realtime service with no mounted surface, and define a subscribable channel once (#15978) 401e50a4a5fix(runtime): the tenancy posture seam tells "never registered" from "registered and failed" at the runtime door (#15909) da1cffb755fix(runtime): the dispatcher's scope strip matches /environments/, the prefix its own hint parser reads (#15859)c54d4d3d7dfix(runtime): carry the producer's userMessage at the dispatcher's PERMISSION_DENIED door (#13811) 878aa2ed31fix(runtime,objectql): /api/v1/ready drains only on the PRIMARY datasource's failure; a secondary is reported, not drained (#13763) d2b69fcd91test(showcase): separate a silent ablation from a dead instrument in the vitest teardown-race pin (#11990) ⭐ The newest is a literal hit on the sharper half: #17284 changed
packages/core/src/security/auth-gate.tsitself — the file whoseif (!rawPath) return trueis this card's subject.Control, so the count is a reading rather than the command's default answer. The predicate's other limb has not fired:
git ls-tree origin/main packages/adapters/still lists exactly one entry,packages/adapters/hono. No second transport adapter exists. So the same audit discriminates, and the 11 above are the file-touch limb firing on its own.Why nobody swept it — and this is the part worth fixing
⛔ Not a seat's lapse.
Blocked-by: #Nhas an unlock sweep (scripts/pm/check-half-states.mjs, H4/H19);Restart-when:has no sweep at all. A hold keyed on it is only ever released by a human happening to re-read the card. This card is the first proven instance of that miss; the class is filed separately as of this audit.⛔ What this comment does NOT do
- ⛔ It does not decide A/B/C. The four-lens analysis, the caller census (comment
5257880748) and the recommendation (A, with the appetite caveat) are already in the body and are unchanged by anything above. - ⛔ It does not claim a live bypass. The body's own reading still holds: the hono adapter populates
path, and A2's empty-path case reaches only the gate-exempt discovery payload or a 404. The pull remains preventive. ⚠️ It does note that lens 2 ("measured business pull — zero today") was written when nobody was editing this code. Eleven commits later, including one reshaping the allow-list's matching rule, the "a future transport author re-opens the hole" scenario in lens 3 is less hypothetical than it read in August.
⇒ Over to the maintainer for the A/B/C call, which is where this card was always headed.
Generated by Claude Code
- ⛔ It does not decide A/B/C. The four-lens analysis, the caller census (comment
os-project-manager commented
on Sep 10, 2026 CollaboratorMore actions⛔ Correction to my comment above — the claim about the sweep was wrong, and the real finding is worse
I wrote: "
Blocked-by: #Nhas an unlock sweep …Restart-when:has no sweep at all." That is false.Restart-when: closed <owner/repo>#Nis fired by the same unlock scan asBlocked-by:, over both channels since #10403;check-half-states.mjsgauges hold exits at H9 (:2047) and indexes hold trigger files at H17 (:3085).⭐ And this card is not outside that index — it is inside it. H17 anchors a line when it contains any of
['trigger file', 'opportunistic', 'restart condition'](H17_TRIGGER_ANCHOR_TERMS,:3171; matched as a substring at:3284). This card'sRestart-when:line ends "a second transport restart condition 1 by definition" — wait, precisely: "(a second transport adapter is restart condition 1 by definition)". The substring is present, the line anchors, and both tracked paths extract.⇒ So the correct reading of the 11 commits above is sharper than the one I gave: this was not a hold invisible to the machinery. It was a row in the trigger-file index, whose named files were touched 11 times, with the rider carried zero times — #10034's measured 0-for-19 recurring after the fix for it shipped.
⛔ Nothing about the state transition changes:
pm:on-hold→needs-user-decisionstands, for the reason given (the predicate fired). The A/B/C call is untouched. Only my diagnosis of why nobody swept it was wrong. Filed as its own finding with the corrected analysis: #17377.
Generated by Claude Code
决策分析 —— 总监席第 21 场 · 批 #114 · 2/3(
session_01QVMnxyWBx8cAQMsV6akDV9,2026-09-10T11:1xZ)Governing text:ADR-0069(auth gate);
packages/core/src/security/auth-gate.tsif (!rawPath) return true(fail-open 默认);卡面前提 re-check 命令(⚠️ 用git grep -nF,os-steve 5479201259 实测-n形式返回假零)。不改协议(网关行为在 core,不在 spec);触安全 / 权限边界地板 ⇒ 恒交维护者。
前提鲜度:cli 席 5616785446 实测Restart-when:自 08-13 起命中 11 次(含 #17284 直接改auth-gate.ts),另一肢(第二个 transport adapter)未命中;os-steve 08-31 两次 discharge 读到 fail-open 那一行仍在、A2 的cleanPath ''只到达 discovery / 404。一句话问题:认证网关有一条「没有路径就当作放行」的默认;今天没有调用方依赖它(四处生产调用点普查为零),但任何未来的传输适配器只要忘了传
path,网关就静默关掉。选项 做什么 客户看到的结果 代价 A 源头改 fail-close:空路径不再豁免(只给真正按 body 路由的 seam 留显式豁免),并把 http-dispatcher.ts的cleanPath ''归一为'/'无可见变化(无人依赖旧默认);未来漏传 path的适配器得到 403 而不是放行两个车道(core 先、runtime 后),一个小 diff + pin B 推迟:只留 #7836 的单点守卫 无变化 陷阱留在源头,靠每个未来调用方记得加守卫; Restart-when已命中 11 次说明「等触发」在实践中不成立C 改 fail-close 但不归一: GET ${prefix}/对受门会话变 403裸根发现请求行为改变 diff 更小,但改了可观察行为 业务直译:A=「门默认关着,没报路径的当外人」;B=「门默认开着,靠每个人记得锁」;C=「门关着,连大门口那块牌子也锁」。
四轴:① A 让一个 helper 只有一种含义(真正的放行判断),去掉「没路径复用放行答案」这个隐含第二义;② 拉动为零(适配器总传
path),是预防性加固;③ A 最强:未来适配器漏传path变成行为信号而不是静默 fail-open;④ A 是两车道小改动,B 零面。①③ 指 A,②④ 指 B,卡面自陈是 appetite 之争。推荐 A(安全默认 fail-close 是 declared = enforced 方向;普查证明无迁移风险;
Restart-when的 11 次命中说明「等第二个适配器再改」永远不会被人看见)。回退 B(把Restart-when改成Blocked-by:能被机器唤醒的形状,否则再躺一个月)。⛔ 不推荐 C。置信缺口:#17284 改auth-gate.ts后 fail-open 行是否仍是:67,本席未重读(按符号找);A2 归一化对 discovery payload 的影响未实测。
裁后执行:裁 A ⇒ engine 席一张(core 的 helper 拆分 + pin,Clause-②: no),cli 席一张阻塞其后(runtime 归一化);裁 B ⇒pm:on-hold,Restart-when改写为可唤醒形状。os-decision-facets
- ① 项目长远合理性:A 让 helper 只剩一种含义。
- ② 实际业务拉动:零,预防性。
- ③ 防 AI 犯错:A 让漏传 path 成为信号;B 靠记忆。
- ④ 创业阶段不扩散:A 两车道小改动;B 零面。
推荐 A;置信缺口:fix(core): the auth-gate allow-list matches at a mount boundary #17284 后的行号与 A2 归一化影响未实测。
Generated by Claude Code
裁决落卡 —— 批 #114 第 2 项:A —— 源头 fail-close + 归一(总监席第 21 场
session_01QVMnxyWBx8cAQMsV6akDV9,2026-09-11T04:3xZ)出处:维护者本场对话逐字:「17392 关; 其他同意,然后你可以下班了」(2026-09-11T04:3xZ,对批 #114 的回批)(「其他同意」覆盖本卡推荐 A)。鲜度门:本卡在分析 5617657765 之后无他人评论。
裁定:
isAuthGateAllowlisted及其 seam 改 fail-close——空 / 缺失路径不豁免;只给真正按 body 路由的 seam(B1shouldDenyAnonymous的契约)留显式豁免;http-dispatcher.ts把cleanPath ''归一为'/',使GET ${prefix}/照常到达 discovery 而不是空串。契约先行、分两步落地:- core 先(本卡,
packages/core/src/security/auth-gate.ts,domain:engine):helper 拆分 + 拒绝 pin(空路径 ⇒ 不豁免;控制:现有四处生产调用点行为逐字节不变,普查 5257880748 重跑)。Clause-②: no(收窄,不新码;若为此新造错误码则按台账规则yes)。 - runtime 后(新卡 runtime: normalise the dispatcher's bare-root
cleanPath ''to'/'once the auth-gate helper is fail-closed (runtime half of #7898 ruling A) #17625,domain:cli,Blocked-by: #7898):cleanPath归一化 + discovery 负载不变的 pin。
状态:
needs-user-decision→pm:queue;domain:cli→domain:engine(落点packages/core,按锚定规则;维护者直派通道,指令原文见上);正文首行的Restart-when:已删除(本卡不再是 hold,留着会被 H17 触发文件索引继续当 hold 读)。⛔ 不裁 B / C。
Generated by Claude Code
- core 先(本卡,
Splitting the Option 2 half of #7432 into its own decision card so it stays in the maintainer inbox after #7432 closes on PR #7836 (Option 1, the instance fix). The evidence is the dev's caller census on #7432, comment
5257880748.Background
isAuthGateAllowlisted(undefined)returnstrue— it treats "no path" as allow-listed (packages/core/src/security/auth-gate.ts:67,if (!rawPath) return true). That is a fail-open default: any caller that reaches the ADR-0069 gate with an absent/empty path silently exempts the request. Option 1 (#7836) guards the one REST call site that could reach it by omission. Option 2 is the question of whether to remove the class at its source rather than guarding each call site.Premises (each with a re-check command — run before acting)
git show origin/main:packages/core/src/security/auth-gate.ts | sed -n '60,70p'→if (!rawPath) return true.no path ⇒ exempt: 4 production call sites, bucketed by intent (comment5257880748). Re-derive:git grep -n "isAuthGateAllowlisted" origin/main -- 'packages/**/*.ts'(excluding tests).packages/restenforceAuth— path-bearing; now guarded by fix(rest): exempt a request from the ADR-0069 gate only when it carries a real path (#7432) #7836.packages/runtime/src/http-dispatcher.tsenforceAuthGate— path-bearing (cleanPath: stringrequired).packages/core/src/security/auth-gate.tsevaluateAuthGate— path required, non-nullable.packages/core/src/security/anonymous-deny.tsshouldDenyAnonymous— genuinely pathless, already guards the falsy branch before the helper sees it.http-dispatcher.tscomputescleanPath = path.replace(/\/$/, ''), so${prefix}/yieldscleanPath === '', reachable on a shipped transport (adapters/hono). Today that reaches only the discovery payload (gate-exempt by design) or a 404 — not a bypass. Re-check:git grep -n "replace(/\\\\/\$/" origin/main -- packages/runtime/src/http-dispatcher.ts.The concrete question
Do we flip
isAuthGateAllowlisted(and its seams) fail-closed — a falsy/empty path is not exempt — removing the fail-open class at source, and if so, how do we handle the A2 bare-root${prefix}/case?Options
false(not exempt), keeping an explicit exemption only for the genuinely-body-routed seams (B1's contract). NormalizecleanPath '' → '/'inhttp-dispatcher.tsso${prefix}/resolves to root rather than the empty string. Removes the fail-open default; the per-call-site guards become belt-and-suspenders. Cross-domain: the helper ispackages/core(domain:engine-core), the normalization ispackages/runtime(domain:cli) ⇒ if chosen, contract-first split (core first, runtime after).no path ⇒ exempt. The remaining seams are safe by construction today (the hono adapter populatespath; A2's empty-path reaches only discovery/404). Re-open if a second transport adapter or a synthetic-request caller appears — the promotion trigger already recorded on finding: REST'senforceAuthpassesreq.pathtoisAuthGateAllowlistedunguarded — a request with no path silently disables the ADR-0069 gate, the exact trap the sibling seam documents #7432.GET ${prefix}/becomes 403 for a gated session. Smaller diff, but changes the observable behaviour of the bare-root discovery request for gated sessions.Recommendation: A, with an honest appetite caveat
Fail-closed is the declared-=-enforced direction and the census removes the migration risk (zero current dependency on the old behaviour). But nobody hits this today, so the pull is preventive, not user-facing — which is exactly why it is an appetite call rather than a restore-invariant I would just dispatch. If the appetite is not there now, B is defensible: the seams are safe today and the promotion trigger is already recorded.
Four-lens analysis
path, and A2's empty-path case reaches only a gate-exempt discovery payload. The pull is preventing a future transport author from re-opening the hole, not a user-visible defect. This axis lowers urgency and is the strongest argument for B/defer.path; the failure becomes a compile/behaviour signal instead of a silent fail-open. B relies on every future caller remembering the guard fix(rest): exempt a request from the ADR-0069 gate only when it carries a real path (#7432) #7836 adds at one site.Lenses 1/3 argue for A, lenses 2/4 argue for B — a genuine appetite trade-off, which is why it is escalated rather than decided from the seat.
Related: #7432 (parent finding, closes via #7836), census comment
5257880748, sibling seamanonymous-deny.ts.Generated by Claude Code