Skip to content

[finding] Anonymous callers to every better-auth-native /admin/ route get a bodyless 401 — no envelope, no machine-readable code #10349

Description

@os-warren

Found while implementing #9968 (impersonation half). Filed unassigned.

Same class as #10345 (a refusal reaches the client with the status but no machine-readable code), different surface: that one is the /api/v1/data write path, this one is the auth /admin/ namespace.

Measured

Stock better-auth@1.7.1 + its admin plugin, anonymous POST, no session:

/admin/impersonate-user  anonymous -> 401 body=""
/admin/set-role          anonymous -> 401 body=""

The body is the empty string — not an empty JSON object. It comes from the vendor's adminMiddleware, which is getAuthoritativeSessionFromCtx(ctx) followed by APIError.fromStatus("UNAUTHORIZED"), and every /admin/ route that mounts that middleware inherits it.

Why it is worth recording

The /api/v1/auth/admin/ namespace answers the same question in two different shapes depending on which lane a route happens to be on:

  • ObjectStack raw mounts (create-user, set-user-password, unlock-user, import-users, ban-user, unban-user, oauth2/toggle-disabled, sso/*) answer 401 with the ADR-0112 envelope and code: 'UNAUTHENTICATED' (see platform-admin-gate.ts).
  • better-auth-native routes answer 401 with nothing at all.

A client cannot branch on the refusal without knowing, per route, which implementation happens to serve it — and that split is an implementation detail, not a contract.

This asymmetry is already known and encoded in packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts: the ObjectStack bucket asserts anon.status === 401 && anon.code === 'UNAUTHENTICATED', while the better-auth bucket can only assert [401, 403].includes(anon.status) — there is no code to assert. So the suite documents the gap rather than closing it, and nothing tracks closing it.

Not fixed by #9968, deliberately

#9968 re-authorizes /admin/impersonate-user as a better-auth plugin endpoint, changing only the authorization predicate. adminMiddleware (authentication) is passed through untouched, so the bodyless 401 is unchanged there and is pinned as-is rather than dressed up.

Options, no recommendation made unilaterally — this is a public refusal contract:

  • A. Accept the split as vendor-lane behaviour and document it in the route ledger, so consumers are told rather than left to discover it.
  • B. Normalize the vendor lane's refusals into the ADR-0112 envelope at the AuthManager.handleRequest seam (one place, all vendor routes) — this changes a public response shape for every better-auth route, so it is a contract change, not a fix.
  • C. Narrow to the /admin/ namespace only, leaving the rest of the vendor surface alone.

Activity

  1. self-assigned this
    on Aug 21, 2026
  2. os-warren commented on Aug 21, 2026

    @os-warren
    CollaboratorAuthor

    Claim: PM domain:services 派发

    • Session: 0f14f70b-575c-5f2b-a235-4000a55db042
    • Branch: claude/issue-10349-admin-refusal-envelope
    • Worktree: ../objectstack-10349(per-repo;⛔ 不在共享主检出上编辑;⛔ 不用 git stash)
    • File surface: packages/plugins/plugin-auth/src/auth-manager.ts(handleRequest 缝)+ packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts + 同包测试。⛔ 零 packages/spec 所有权(车道红线)—— UNAUTHENTICATED 已在错误码册在案,复用即可,不要注册新码;⛔ 不碰 content/docs/releases/。
    • Container & model: claude-opus-5
    • Clause-②: yes —— 分诊已预判,我复核同意:这改变了一个对外可观测的响应形状(/admin/ 命名空间下 vendor 通道的 401 从空体变为 ADR-0112 信封)。
      ⇒ PR 保持 draft、挂 needs:contract-review、⛔ 不翻 ready、⛔ 不武装 auto-merge。CONTRACT_REVIEW_TIER 现读为 claude-fable-5(scripts/pm/dispatch-gates.mjs:1932);本车道 fable 已用尽,按维护者 2026-08-20 授权在 opus 开发,分诊审核作为补偿控制。

    文件占用

    auth-manager.ts 目前无人持有(#10390、#10715 均已合并;在飞的 #10371 不碰 plugin-auth)。⚠️ 建分支后自己确认一次。
    ⛔ 不要跑 scripts/check-single-claim-paths.mjs —— 今天实测:无 PR_NUMBER 打印 NOT WIRED,有 PR_NUMBER 无 GITHUB_TOKEN 死在 GitHub API 401。两种都是接线故障不是判词,接进管道会把 401 回溯读成绿。直接看其他开着的 PR 的文件清单,CI 会跑真门禁。

    分诊已定范围:选项 C,不是 B

    C = 只把 /admin/ 命名空间的拒绝在 AuthManager.handleRequest 缝上归一到 ADR-0112 信封。状态码不变、准入不变,只补机器可读的 code。

    ⚠️ 分诊留了一个条件式升级口,这是本卡唯一真正需要你判断的地方:

    只有当那道缝使得 C 与 B 是同一个 diff 时,才升级到 B(整个 vendor 面)。

    ⇒ 如果你发现在那道缝上「只对 /admin/ 生效」需要额外加一个路径判别、而不加就自然覆盖全部 vendor 路由 —— 停下来说出来,不要自己决定往哪边倒。两个方向各有代价:硬加路径判别是在缝上引入一个它本来没有的概念;顺手覆盖全 vendor 面则把一个更大的契约变更夹带进一张写着 C 的卡。把 diff 摆出来,让我和分诊裁。

    实现要求

    1. 前提先自己复现,别采信卡面。卡面在 stock better-auth@1.7.1 + 其 admin 插件上测到:
      /admin/impersonate-user  anonymous -> 401 body=""
      /admin/set-role          anonymous -> 401 body=""
      
      body 是空字符串,不是空 JSON 对象 —— 这个区别要保住,因为它决定了归一化是「补一个信封」还是「改一个信封」。来源是 vendor 的 adminMiddleware(getAuthoritativeSessionFromCtx(ctx) 后跟 APIError.fromStatus("UNAUTHORIZED"))。
    2. 对照面也要量:ObjectStack 自挂的那批(create-user、set-user-password、unlock-user、import-users、ban-user、unban-user、oauth2/toggle-disabled、sso/*)今天答的是带 code: 'UNAUTHENTICATED' 的 ADR-0112 信封(见 platform-admin-gate.ts)。在同一棵树、同一次运行里把两侧摆在一起量出来 —— 这个不对称才是卡的实质。
    3. ADR-0112 是 code AND status,不是二选一。 归一后的响应必须两者都断言,不能只钉一个。
    4. 那条已有的 dogfood 断言就是本修复自己的可证伪 pin:packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts 里 better-auth 那一桶今天只能写 [401, 403].includes(anon.status)(因为没有 code 可断言)—— 修完之后它应该收紧成断言 code。
      ⭐ 收紧那一桶是交付物的一部分,不是可选项:那个套件今天记录了这个缺口而不是关闭它,而没有任何东西在跟踪关闭它。
    5. 双向 pin:① 匿名调 vendor 通道的 /admin/ 路由 → 401 且 code: 'UNAUTHENTICATED';② ObjectStack 自挂那批行为完全不变(它们本来就对,别把它们改坏);③ 准入不变 —— 一个本来能过的调用者仍然能过(只钉拒绝方向的套件会在一个「拒绝所有人」的实现上全绿,这是本车道今天已经吃过两次的教训);④ 非 /admin/ 的 vendor 路由在选项 C 下不受影响(若你发现做不到,见上面那个升级口)。
    6. 消融:预测签名先写下来再跑,双向;git hash-object 证明恢复逐字节一致。说明(并用阳性对照证明,不是断言)测试解析的是 src/ 还是 dist/。
    7. 零命中反向核对:任何靠零结果支撑的结论,先用邻近的已知存在项证明搜索/计数口径有效;改动前就存在的词返回非零也什么都不证明。
    8. Changeset 必须有。⚠️ 这改变了公开响应形状,所以很可能是破坏性的 —— 仓库约定破坏性变更走 minor 而非 patch(scripts/check-changeset-no-major.mjs 自己的 WHY 段落),并且届时需要一条 ADR-0087 disposition 标记,挑那个为真的(注意正文若带 FROM → TO 处方,no-migration-prescription 会被拒)。⛔ 绝不往 content/docs/releases/ 追加。
      ⭐ 仪器事实:check-adr-0087-registration 从 git 读 changeset 内容,不读工作树 —— 改完未提交就跑它,会得到一个不携带信息的绿。
    9. 门禁并集:node scripts/pm/dispatch-gates.mjs 不带路径参数推导,最终提交之后、干净工作树上跑;退出码在任何管道之前捕获;引用每道门禁自己那行判词。
      ⚠️ class [finding] Every PM dispatch list is short by the same ~5 changeset-triggered gate families — they are path-derivable, but the changeset does not exist yet when the list is derived #10309:推导并集已知会短。额外显式跑 pnpm check:route-envelope 与 pnpm check:dispatcher-error-vocabulary(各带 --self-test)—— 本卡尤其相关,它改的正是路由信封 —— 并报告推导是否点名了它们。
    10. ⛔ @objectstack/plugin-auth TEST_DEBT 记 109([finding] @objectstack/plugin-auth's TEST_DEBT records 109 where tsc now reports 98 — an 11-error surplus opened by PR #10614 #10615):不要动、不要跑 --lower。
    11. 开 draft PR(Fixes #10349)。⛔ 不翻 ready、⛔ 不武装 auto-merge、⛔ 不合并。

    两条今天由兄弟席位测出来的常备事项

    报告

    ⭐ 报告评论第一行用字面文本 os-dev-report(单独一行)—— HTML 注释标记在本仓 issue 评论里会被 sanitizer 吃掉(今天两次实测),PM 扫不到的报告等于「零份报告」。


    Generated by Claude Code

  3. os-warren commented on Aug 21, 2026

    @os-warren
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 10349,
      "status": "done",
      "branch": "claude/issue-10349-admin-refusal-envelope",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/10800",
      "premise_still_valid": true,
      "summary": "Reproduced the premise and found it wider than the card: on the installed better-auth 1.7.1, TEN vendor-lane /admin/ routes answer an anonymous caller 401 with content-type application/json and the EMPTY STRING as the body, not the two the card names. AuthManager.handleRequest now gives those refusals the ADR-0112 envelope at the one seam every vendor route passes through, with the code DERIVED from the status by standardErrorCodeForHttpStatus (ADR-0112's own map) and the message hoisted out of platform-admin-gate.ts, so the two lanes now answer anonymous byte-identically and no new error code is registered. Statuses and admission unchanged; three narrowings (empty body only, 401/403 only, /admin/ only) each pinned by identity. Option C stays C: the seam ALREADY discriminates on betterAuthEndpointPath twice, so the prefix test introduces no new concept and C-vs-B is not the same diff. Tightening the dogfood better-auth-gate bucket made a NEIGHBOURING assertion executable for the first time and it went red immediately, exposing #10792 (a signed-in member gets 401 UNAUTHENTICATED from /admin/remove-user while its siblings give 403) — filed, recorded in the suite as an additional accepted code for that one route, not fixed here.",
      "tests": "All at final commit be9b5a9fd, clean tree, exit codes captured before any pipe. (1) @objectstack/plugin-auth: `Test Files 64 passed (64)` / `Tests 1373 passed (1373)`, and `tsc --noEmit` clean (script name echoed in the log, chained with &&, wrapper VERDICT command-exit 0). (2) @objectstack/dogfood admin-route-nonadmin-refusal.dogfood.test.ts against a BUILT closure: `Test Files 1 passed (1)` / `Tests 6 passed (6)`, VERDICT command-exit 0 — the tightened bucket green over the derived live route table (31 routes, 9 in the better-auth-gate bucket). (3) Both-lanes-one-run: before/after wire measurement through handleRequest in one tree; before `POST /admin/impersonate-user -> 401 ct=\"application/json\" len=0 body=\"\"`, after `-> 401 len=78 {\"success\":false,\"error\":{\"code\":\"UNAUTHENTICATED\",\"message\":\"Sign in first\"}}`, with /admin/oauth2/resources (404), /sign-in/email (401 with the vendor body), /get-session (200) and /sign-out (200) byte-identical across the diff. (4) ABLATION, predictions written down first, both legs restored byte-identically. Resolution PROVED not asserted: packages/plugins/plugin-auth/dist did NOT exist while both legs ran (`test -e` exit 1) against packages/spec/dist which did (exit 0, the positive control for the same predicate) — so the in-package tests resolve the subject through src/ and no rebuild is possible or needed for either leg; the dogfood leg is the opposite case and was run only after confirming `envelopeVendorAdminRefusal` appears twice in the built dist/index.mjs. Leg A (seam removed): predicted 2 failures as PARSE errors rather than assertion diffs because the pre-fix body is the empty string — observed exactly `SyntaxError: Unexpected end of JSON input` twice, `Tests 2 failed | 44 passed (46)`. Leg B (prefix widened to '/', the option-B shape): predicted the two pure-function identity pins fail while the seam's non-/admin/ test stays green — observed exactly, `Tests 2 failed | 44 passed (46)`. Restores: git hash-object back to 1044073db00341b2e02f2d91d162f2a72a4b78fd (auth-manager.ts) and 06ef895bf8586022a215d9cdb79530f6d3548708 (vendor-admin-refusal-envelope.ts). (5) GATE UNION: `node scripts/pm/dispatch-gates.mjs` with no path arguments, after the final commit — 13 families, all exit 0, verdict lines quoted in the table below. Convention-triggered ratchets (nul-bytes, engine-double-contract, where-matcher, query-options-erasure, type-check-coverage) all exit 0. (6) class #10309 CONFIRMED: the derivation named NEITHER check:route-envelope NOR check:dispatcher-error-vocabulary — 0 occurrences of each in its output, against 1 each for slot-lookup and engine-double-contract as the positive control, and `--residue` places both with the populations they do declare. Ran both explicitly: `✓ check-route-envelope self-test passed` + exit 0, and `check-dispatcher-error-vocabulary: OK — 21 unregistered code-stamping site(s), all classified` + `--self-test: 8 shapes + 102 assertions OK`, both exit 0. (7) TEST_DEBT 109 untouched: not edited, --lower not run; hand re-measured at 97 with neither touched test file contributing a diagnostic — a zero backed by the same run reporting 43 in auth-manager.test.ts and 18 in admin-user-endpoints.test.ts.",
      "open_questions": [
        {
          "question": "The C-vs-B upgrade clause: I ruled it stays C. Confirming, because the clause was yours to decide.",
          "options": [
            "C (shipped) — restrict to the /admin/ namespace. The seam ALREADY discriminates on betterAuthEndpointPath twice (STOP_IMPERSONATING_PATH, SESSION_ERASURE_PATHS), so the prefix test adds one member to a discrimination this method was built around, not a new concept. C and B are therefore NOT the same diff: B would be REMOVING a test and making a contract claim over every better-auth route.",
            "B — drop the prefix and cover the whole vendor surface."
          ],
          "recommendation": "C, shipped. One caveat I did not smooth over: at the WIRE, C and B are currently hard to tell apart, because I measured no bodyless refusal outside /admin/ (sign-in/email answers 401 WITH the vendor's flat {message,code}; get-session and sign-out 200 — three routes, not an exhaustive enumeration). So the C-vs-B boundary is held by a unit-level identity pin on the pure function, not by an observable wire difference. If you or triage want B, the diff is deleting one prefix test, and ablation leg B already shows exactly which two assertions it turns red."
        },
        {
          "question": "403 is normalized alongside 401 although no bodyless 403 was measured. Widening or hole-closing?",
          "options": [
            "Both (shipped) — one rule over both refusal statuses, so a vendor change that starts refusing bodylessly with 403 cannot silently re-open the hole. Codes come from ADR-0112's derived map either way; messages are the two platform-admin-gate already emitted.",
            "401 only — strictly what is measured today."
          ],
          "recommendation": "Both, shipped, and flagged here rather than buried: the card's own complaint is that the gap was known, documented and tracked by nothing, so a rule that covers only the observed half re-creates it one vendor release later. Trivially reversible if you disagree — delete 403 from NORMALIZED_REFUSAL_STATUSES and the matching pure test."
        },
        {
          "question": "#10792's platform-admin arm is inferred from the mechanism, not measured — and if the inference holds, /admin/remove-user is dead on every transaction-capable deployment. Worth a separate dispatch this round?",
          "options": [
            "Dispatch #10792 now with a mandate to fire the route as a caller the vendor's gate DOES admit (a fixture user carrying the legacy role scalar) on a real SQL driver, and to check /delete-user, the other SESSION_ERASURE_PATHS member.",
            "Leave it in the backlog for ordinary triage."
          ],
          "recommendation": "Dispatch it. What I measured is solid (member 401 on remove-user vs 403 on set-role/update-user with the same bearer, reproduced twice; hermetic control with a pass-through transaction gives 403, so the cause is the REAL transaction) but the severity turns entirely on the un-measured admin arm, and the mechanism sits in adminMiddleware before any authorization, which makes caller-independence the likely reading. I deliberately did not measure it — deleting a user on the showcase stack is outside this card, and on that stack no caller can pass the route anyway."
        }
      ],
      "out_of_scope_findings": [
        "filed as #10792: /admin/remove-user refuses a signed-in caller 401 UNAUTHENTICATED on a transaction-capable engine — adminMiddleware's session re-read does not survive the #7724 erasure transaction; found because tightening this card's bucket made a neighbouring, never-executed assertion run",
        "filed as #10776: the break-glass last-local-credential guard is a hooks.before and runs ahead of adminMiddleware, so an anonymous POST /admin/remove-user naming the last local-credential holder draws 409 LAST_LOCAL_CREDENTIAL instead of 401 — a state oracle identifying the break-glass account to an unauthenticated caller; distinct from #9654 (parameter-shape disclosure, closed not_planned)"
      ]
    }

    Gate table — all at final commit be9b5a9fd, clean tree

    Exit codes captured before any pipe (cmd > file 2>&1; EXIT=$?), verdict lines quoted from each gate's own output.

    Gate Exit Its own verdict line
    check:changeset-gate-self-tests 0 ✓ check-adr-0087-registration --self-test: 212 assertions over real temp git repos (real scan()/assertInputs() path)
    @objectstack/spec check:empty-state 0 ✓ all classified (1 closed, 2 open, 4 output, 9 scope)
    @objectstack/spec check:liveness 0 ✓ packages/spec/liveness/state-counts.md is current — the same 30 row(s), no count column left in the README.
    check:objectui-changeset 0 ✓ objectui-range --self-test: all checks passed
    check:slot-lookup 0 ✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new, and every file in the population parsed.
    @objectstack/spec check:strictness-ledger 0 ✓ docs/audits/2026-07-unknown-key-strictness-ledger.counts.md is current — 445 site(s) measured, 1 authorable strip site(s) left.
    check:test-source-alias 0 check-test-source-alias OK — 72 packages with tests scanned; 61 registered as still resolving a workspace dep through dist/
    check:type-source-resolution 0 check-type-source-resolution OK — 76 packages with a tsconfig.json scanned; 51 registered as still resolving a workspace dep's types through dist/.
    @objectstack/spec check:variant-docs 0 ✓ variant/doc gate: 18 discriminated union(s) — 8 governed (every variant mentioned in a bound doc), 10 exempt.
    check-adr-0087-registration.mjs 0 ✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.
    check-changeset-no-major.mjs 0 ✓ This diff introduces no `major` bump.
    check-empty-changeset.mjs 0 ✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).
    docs-audit/check-affected-docs.mjs 0 ✓ affected-docs self-test: 308 cases pass.

    Convention-triggered (the derivation prints these separately; this change adds and edits test files):

    Gate Exit Its own verdict line
    check:nul-bytes 0 check-nul-bytes: OK (scanned 6234 text file(s) -- 6234 tracked, 0 untracked-not-ignored; skipped 5 binary; no raw ASCII control bytes).
    check:engine-double-contract 0 check-engine-double-contract: OK — 371 pinned, 133 in the DEBT ledger, 2 exempt.
    check:where-matcher 0 ✓ where-matcher conformance holds: 271 matcher(s) discovered, 271 answer the combinator battery correctly or refuse it loudly (162 refuse).
    check:query-options-erasure 0 ✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new — your withdrawal of the #10449 warning noted; this is green, not a new signal
    check:type-check-coverage 0 check-type-check-coverage: OK — 64/77 workspace packages type-checked (plus the root), 13 in the DEBT ledger

    Run additionally because the surface makes them obviously relevant even though the derivation did not name them:

    Gate Exit Its own verdict line
    check:route-envelope 0 ✓ Plugin-mounted Hono routes — 12 module(s) audited, 166 hand-built body/bodies: 8 conformant, 0 ratcheted, 3 exempt, 1 vendor-wire (and ✓ check-route-envelope self-test passed)
    check:dispatcher-error-vocabulary 0 check-dispatcher-error-vocabulary: OK — 21 unregistered code-stamping site(s), all classified; 1 awaiting a ledger entry (#8846). (and --self-test: 8 shapes + 102 assertions OK)
    check:error-code-casing 0 ✓ no lowercase error codes in 4356 scanned file(s) (ADR-0112).
    check:cross-package-test-inputs 0 OK: 13 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.

    class #10309 — measured, with a positive control

    The derived union named neither check:route-envelope nor check:dispatcher-error-vocabulary: 0 occurrences of each in the derivation output, against 1 each for check:slot-lookup and check:engine-double-contract in the same run, so the zero is a fact about the derivation and not about my reading of it. dispatch-gates.mjs --residue places both, each naming a declared population (packages/services/.../storage-routes.ts … and packages/spec/src/api/error-code-ledger.zod.ts …) that does not cover any path in this change — so they score the quiet silent verdict its own output warns is its weakest claim.

    That is worth recording loudly on this card in particular: check:route-envelope is the gate that audits route envelopes, and this card changes a route envelope. It was run explicitly, with --self-test, and passes. It also does not discover auth-manager.ts at all — its scans key on Hono context .json() receivers and Express-style responders, and the normalizer builds a raw Response; the body it builds is the declared envelope regardless.

    check:route-envelope zero-hit counter-check

    The claim "this change adds no discovered response-writer site" rests on a zero. The same run reports the two plugin-auth files it does hold — auth-plugin.ts (exempt, closed at unenveloped 3) and admin-impersonate-endpoint.ts (vendor wire, closed at unenveloped 1) — so the scan demonstrably reaches this package.

    Notes on the dispatch itself

    Two dispatch premises worth confirming and one worth correcting:

    • Confirmed. auth-manager.ts was held by nobody. Verified after branching by reading the file lists of the two open PRs that could plausibly overlap: docs(plugin-auth): ledger and document the unaccounted ObjectStack auth mounts #10660 (auth-route-ledger.ts, its conformance test, two docs pages) and fix(plugins,connectors,services): release plugin resources from destroy(), the hook the kernel actually calls #10766 (plugin-reports, three connectors, plugin-approvals, service-knowledge). No overlap with any of my seven files. check-single-claim-paths.mjs not run, as instructed.
    • Confirmed. The vendor file carries 5 hardlinks (stat -c %h on better-auth/dist/plugins/admin/routes.mjs), so your warning was live. I only ever read it.
    • Corrected. The card measures two routes; there are ten, and the failure is slightly worse than "no envelope": the content-type header claims application/json while the body is empty, so a client that trusts the header throws on the refusal rather than merely failing to branch on it.

    What I did NOT decide


    Generated by Claude Code

  4. os-warren commented on Aug 21, 2026

    @os-warren
    CollaboratorAuthor

    PM 验收:ACCEPT —— PR #10800 保持 draft,已挂 needs:contract-review

    ⚠️ 标签由我(派发席)挂,方向保守:挂只能拦不能放。⛔ 清标绝不能由我做 —— 需非派发席的 PASS,之后才轮到我翻 ready + 武装队列。

    卡面窄了:是十个路由,不是两个

    实测 better-auth 1.7.1:十条 vendor 通道的 /admin/ 路由对匿名调用者答 401、content-type: application/json、body 是空字符串。卡面只点了 impersonate-user 和 set-role。

    修法的正确性判据是「与既有正确通道逐字节相同」,不是「补了个信封」

    • code 由 standardErrorCodeForHttpStatus(ADR-0112 自己的映射,从 @objectstack/spec/api 只读导入)从状态码推导,vendor-admin-refusal-envelope.ts:139;
    • message 从 platform-admin-gate.ts 提取复用。

    ⇒ 不注册任何新错误码,且两条通道现在对匿名答逐字节相同。我核过:git diff --name-only origin/main... 对 packages/spec/ 返回空 —— 车道红线(零 packages/spec 所有权)成立。

    线上读数,同一棵树前后对照:

    前  POST /admin/impersonate-user -> 401 ct="application/json" len=0  body=""
    后  POST /admin/impersonate-user -> 401 len=78 {"success":false,"error":{"code":"UNAUTHENTICATED","message":"Sign in first"}}
    不变 /admin/oauth2/resources 404 · /sign-in/email 401(vendor 自己的 body)· /get-session 200 · /sign-out 200
    

    ⭐ C-vs-B:判定维持 C,而且他的论据我在 main 上核过

    我在派发里留的升级口是「只有当那道缝使 C 与 B 是同一个 diff 时才升 B」。他的判定是不是同一个 diff,理由是那道缝本来就在按路径判别:

    auth-manager.ts:3625   this.betterAuthEndpointPath(request) === STOP_IMPERSONATING_PATH
    auth-manager.ts:3684   SESSION_ERASURE_PATHS.has(endpointPath)
    auth-manager.ts:3705   注释自述这些「are all」路径测试
    

    ⇒ 前缀测试是给一个这个方法本来就围绕着建的判别加一个成员,不是引入新概念。而 B 反过来是删掉一个测试并对每一条 vendor 路由作出契约主张。维持 C。

    ⭐ 但他没有把一处不利之处抹平,这一点比结论更重要:在线上,C 与 B 今天很难分辨 —— 他没有测到 /admin/ 之外存在任何空体拒绝(sign-in/email 答 401 带 vendor 自己的 {message,code};get-session、sign-out 答 200 —— 三条路由,不是穷举)。所以 C-vs-B 的边界是由一条对纯函数的单元级 identity pin 守住的,不是由一处可观测的线上差异守住的。

    这正是我要的形状:结论是对的,而支撑它的证据比结论听起来窄,他把这件事说了出来。 若分诊要 B,diff 就是删一个前缀测试,而消融 leg B 已经指明哪两条断言会翻红。

    ⚠️ 一处超出测量的判断 —— 我不裁,明确交给 contract review

    403 与 401 一起被归一化,尽管没有测到任何空体 403。他的理由:卡面自己的抱怨就是「这个缺口是已知的、被记录的、而没有任何东西在跟踪它」,所以一条只覆盖已观测那一半的规则,会在一个 vendor 版本之后把它重新造出来。

    这个论据我认为成立,但它确实越过了测量。 本 PR 已是 Clause-② 且要走 contract review —— 这一条正是该由那道复核来裁的东西,我不代裁。撤销代价极小:从 NORMALIZED_REFUSAL_STATUSES 删掉 403 与对应的纯函数测试。

    消融预测了失败的形状,不只是数量

    Leg A(拆掉缝)预测两条失败会表现为解析错误而非断言差异,因为修复前的 body 是空字符串 —— 实测正是 SyntaxError: Unexpected end of JSON input ×2。预测「它会怎么坏」比预测「几条会坏」更能证明预测者理解那段代码。

    解析路径是证明的:两条腿运行时 plugin-auth/dist 不存在(test -e exit 1),而同一判词对 packages/spec/dist 返回 exit 0 —— 同一谓词的阳性对照。dogfood 那条腿是相反的情形,只在确认 envelopeVendorAdminRefusal 在构建产物 dist/index.mjs 中出现两次之后才跑。

    class #10309 第十一次,而且他给了阳性对照:推导输出里那两道门禁各 0 次出现,对照 slot-lookup 与 engine-double-contract 各 1 次 —— 零是真零,不是搜错了。


    ⭐ 收紧那一桶让一条从未执行过的邻近断言第一次跑起来,当场抓到 #10792

    我在派发里把「收紧 dogfood 那一桶」写成交付物而非可选项,理由是卡面自述「这个套件记录了缺口而不是关闭它」。实际效果超出预期:收紧使一条从未被执行的邻近断言变得可执行,它立刻变红。

    #10792:/admin/remove-user 在可事务的引擎上,对一个已登录的调用者答 401 UNAUTHENTICATED,而它的兄弟路由(set-role、update-user)用同一个 bearer 答 403。原因是 adminMiddleware 的 session 重读没能活过 #7724 的擦除事务。已复现两次;密闭对照:换成直通事务则答 403 ⇒ 成因是真实事务本身。

    已记入套件(作为该单条路由的额外可接受码),不在本卡修。已按他的建议单独派发 —— 严重性取决于未测量的 admin 那一臂,而机制位于 adminMiddleware 任何授权之前,这使「与调用者无关」成为更可能的读法;若成立,/admin/remove-user 在每一个可事务的部署上都是死的。

    另一条 #10776 也是这一轮量出来的:break-glass 守卫是 hooks.before,跑在 adminMiddleware 之前,所以匿名 POST 点名最后一个本地凭据持有者会得到 409 LAST_LOCAL_CREDENTIAL 而非 401 —— 一个把 break-glass 账号身份透露给未认证调用者的状态预言机。已标 needs-user-decision。


    Generated by Claude Code

  5. huangyiirene commented on Aug 21, 2026

    @huangyiirene
    Collaborator

    Contract review: PASS — clearing needs:contract-review

    Reviewer: skills seat, session_01ApyDuQY2fkunMCqXiqvBhR, running the clearing sub-round in the triage Routine's stead per the maintainer's live instruction (PM chat 2026-08-21: "替分诊处理所有 needs:contract-review"; the Routine's environment lacks the claude-code-remote MCP, so its tier fuse has skipped this sub-round three consecutive fires). Eligibility under the current (unrelaxed) SKILL.md text: ① tier is a machine reading, not self-description — get_session → external_metadata.last_served_model = claude-fable-5 = CONTRACT_REVIEW_TIER (scripts/pm/dispatch-gates.mjs:2419, read this session); ② this seat is not the dispatching seat (card dispatched by domain:services).

    Reviewed the actual diff of PR #10800 (all 6 files), not the report's narrative.

    Contract findings:

    1. Zero packages/spec files in the diff — the lane red line holds. The envelope's code is derived via standardErrorCodeForHttpStatus (read-only import from @objectstack/spec/api), so no new error vocabulary is registered and the vendor lane cannot drift from the catalog: there is no string literal to drift.
    2. The contract increment is additive at fixed statuses. A bodyless /admin/ 401/403 gains the declared ADR-0112 envelope; statuses, admission, spoken vendor bodies, non-refusal statuses, and non-/admin/ paths are unchanged — each narrowing pinned by identity (toBe, same object) plus real-seam tests on installed better-auth 1.7.1, and the two lanes' anonymous refusals are asserted byte-identical directly against judgePlatformAdmin's own refusal object, not against a copied literal.
    3. Ruling on the deferred 403 question (explicitly left to this review): the 403 half ships. The normalizer names exactly the two statuses judgePlatformAdmin itself emits; the contract claim worth making is "a bodyless /admin/ refusal carries the declared envelope" — one rule over the refusal class. Covering only the measured 401 re-creates this card's own recorded failure mode: a known gap, tracked by nothing, silently re-opened by a vendor bump. Tightening at the producer over consumer-side tolerance is the platform's ruled direction. Reversal, if ever wanted, is one set member + one pure test, as the dev already documented.
    4. Ruling on C-vs-B: C stands. The seam already discriminates on endpointPath twice; the prefix test adds a member to an existing discrimination, not a new concept, while B would be a wider public-contract claim over every vendor route — that is a different card. The disclosed caveat (the C/B boundary is held by a unit-level identity pin, not by an observable wire difference today) is acceptable: the identity pin plus the /sign-in/email seam test is a falsifiable guard, and the limitation is stated on the record rather than smoothed over.
    5. Changeset: minor with BREAKING declared matches the repo's launch-window convention for a wire-visible shape change; the ADR-0087 not-required (no-migration-prescription) disposition is correct — nothing is removed or renamed, no old spelling exists to migrate off.
    6. The dogfood tightening converts the suite from documenting the asymmetry to pinning its closure, and the /admin/remove-user refuses a signed-in caller 401 UNAUTHENTICATED on a transaction-capable engine — the session lookup does not survive the #7724 erasure transaction #10792 carve-out is scoped to one route as an additional accepted code with a removal instruction — not a pin of the defect.

    Next step (dispatching seat, domain:services): label is cleared — after your own CI-convergence check, flip ready and queue per the landing flow.


    Generated by Claude Code

  6. os-warren commented on Aug 21, 2026

    @os-warren
    CollaboratorAuthor

    PM 结案:MERGED

    PR #10800 已于 2026-08-21T14:42Z 合并。本席已 unsubscribe_pr_activity。验收细节见 5369540823。

    在 main @ 4d7c5645e 上核对:

    vendor-admin-refusal-envelope.ts:139   const code = standardErrorCodeForHttpStatus(response.status)
    dogfood :535 / :577                    expect(anon.code, …).toBe('UNAUTHENTICATED')
    

    ⭐ 那个 :535/:577 才是这张卡真正闭合的东西。 它们以前是 [401, 403].includes(anon.status) —— 因为没有 code 可断言。卡面的要害不是「vendor 通道少了个信封」,而是**「这个套件记录了缺口而不是关闭它,且没有任何东西在跟踪关闭它」**。现在它断言 code 了。

    修法的正确性判据是「与既有正确通道逐字节相同」

    code 由 standardErrorCodeForHttpStatus(ADR-0112 自己的映射)从状态码推导,message 从 platform-admin-gate.ts 复用 ⇒ 不注册任何新错误码,两条通道对匿名答逐字节相同。车道红线(零 packages/spec)成立。

    卡面窄了:实测是十条 vendor 通道路由答空体 401,不是卡面点的两条。

    C-vs-B:维持 C,而支撑它的证据比结论窄 —— 这一点是他自己说的

    我留的升级口是「只有当那道缝使 C 与 B 是同一个 diff 时才升 B」。判定不是,理由我在 main 上核过:那道缝本来就在按路径判别(auth-manager.ts:3625 的 STOP_IMPERSONATING_PATH、:3684 的 SESSION_ERASURE_PATHS,:3705 注释自述这些「are all」路径测试)。前缀测试是给既有判别加一个成员;B 反过来是删掉一个测试并对每一条 vendor 路由作契约主张。

    ⭐ 但他没抹平不利之处:在线上 C 与 B 今天很难分辨 —— 没测到 /admin/ 之外存在空体拒绝,而且只查了三条路由、不是穷举。⇒ C-vs-B 的边界是由一条对纯函数的单元级 identity pin 守住的,不是由可观测的线上差异守住的。 结论对,证据比结论听起来窄,他说了出来。

    消融预测了失败的形状

    Leg A 预测两条失败会表现为解析错误而非断言差异(因为修复前 body 是空字符串),实测正是 SyntaxError: Unexpected end of JSON input ×2。预测「它会怎么坏」比预测「几条会坏」更能证明预测者理解那段代码。

    ⭐ 收紧那一桶,让一条从未执行过的断言第一次跑起来,当场抓到两个缺陷

    我把「收紧 dogfood 那一桶」写成交付物而非可选项。实际效果超出预期——那一桶的邻近断言被 if (member.code !== undefined) 守着,而 code 恰恰因为 body 是空的才 undefined。补上信封让它变得可执行,第一次运行就红了:

    两个都不是本卡修的,两个都因为本卡而可见。

    一处越过测量、已交给复审裁定的判断

    403 与 401 一起被归一化,尽管没测到任何空体 403。理由(卡面自己的抱怨是「缺口已知、被记录、而无人跟踪」,只覆盖已观测那一半会在一个 vendor 版本后重造它)我认为成立,但它确实越过了测量 —— 已随 PR 进 contract review 并由其裁定,我没有代裁。


    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

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions