fix(core): remove ResolvedAuthzContext.authRefusal, a published member nothing ever read - #16442
Conversation
…ber with zero readers Maintainer ruling A (2026-09-02, ADR-0049 enforce-or-remove), re-affirmed as A1 (2026-09-03) with the carriers a published-type narrowing owes. The two posture-conditional API-key refusals keep firing and keep failing closed; both transports keep answering the generic anonymous 401. The refusal reason stays observable on the server-side `warnApiKeyRefusal` line (#15256 / 2A), where the relocated pins now read it. Prose that named the field is repaired, not erased: the rationale block in `authz-store-unavailable.ts` now cites the measurement that was acted on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift CheckThis PR changes 2 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 30 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0809e0b90c677c6155ee89b479a08f6a4fdc5112 && git checkout 0809e0b90c677c6155ee89b479a08f6a4fdc5112
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c14ebfeb29d8d3bc0ae9b148132a78b86dec94bc 5ecf881fc6ac9b1a31fec37fda75b81058f21fc0 && git checkout -B drift-repro c14ebfeb29d8d3bc0ae9b148132a78b86dec94bc && git merge --no-ff 5ecf881fc6ac9b1a31fec37fda75b81058f21fc0
node scripts/docs-audit/affected-docs.mjs --json c14ebfeb29d8d3bc0ae9b148132a78b86dec94bc
|
Contract review PASSED. Release authorised at
|
| # | condition | reading |
|---|---|---|
| ① | contract-review PASS bound to the head that will land | PASS at 5ecf881fc6ac9b1a31fec37fda75b81058f21fc0; head re-read after the verdict and unmoved |
| ② | check-clause2-carriers --pair 16442 |
REAL_EXIT=0 (redirect-then-read) — "the clause-② declaration is readable in the fixed spelling and both carriers agree". Checker blob 751b4a6e4 == origin/main's |
| ③ | completed == total AND failing == 0 |
37/37, 0 failing, mergeable_state=clean — ⛔ not a required-checks subset |
needs:contract-review cleared from both carriers, comparative read-back proving before − label exactly: PR [documentation, size/m, tests, tooling], card #14273 [security, pm:dispatched, domain:engine, priority:p3].
⭐ What the review established, because a PASS is only worth its measurements
- Limb 1 fires, at member level only. Both legs built with
tsupdirectly (no turbo), rebuild proven by mtimes and by a byte-identical head rebuild across all 6 artefacts.dist/index.d.tsanddist/index.d.ctseach lose exactlyauthRefusal?: { reason: ApiKeyRefusalReason; message: string; }frominterface ResolvedAuthzContext(members 15 → 11). ⭐ The exported name set is 260 → 260 — blind to this by construction, which is precisely why it is never the instrument. Consumer compile probe:c.authRefusalis TS2339 against the head.d.tsand compiles against the base.d.ts, with a control member (c.posture) compiling on both. - Limb 2 does not fire — and this is the limb a
.d.tsdiff cannot see, so it was answered by running the door:resolveAuthzContextdriven from the builtdist/index.json both legs across six scenarios. Warn-line reasons,userId,tenantIdand every other envelope key identical; the only delta is theauthRefusalown-property. The emitted runtime diff is exactly the two writer removals and nothing else. - ⭐ The ablation leg the round declared undone was run. The
restcross-package suite against the base-built coredistis 27/27 — so the relocated pins read a signal the field's removal does not touch, which is exactly why the two own-property pins incoreare what go red under the reverse mutation (2 failed | 86 passed, reproduced, mutation and restore blob-proven). - The ADR-0087 category was checked, not assumed.
runtime-interface-onlyis right: ADR-0087 D7 (docs/adr/0087…:603) defines it for "a published runtime TS interface … compiler-carried", and gate ADR-0087's changeset disposition has no category for published runtime TS interfaces with no metadata surface — the #8277 exemption argument is correct, unverifiable, and will be re-litigated #8299's predicates 1–4 were verified positively, not merely by exit 0. The precedent this PR was measured against (d8024f0,no-migration-prescription) predates ADR-0087's changeset disposition has no category for published runtime TS interfaces with no metadata surface — the #8277 exemption argument is correct, unverifiable, and will be re-litigated #8299, which the gate's own docblock records as the older catch-all held by a detector miss. ⇒ choosing the narrower category is correct, not a deviation. - Governing-text divergence, stated rather than smoothed over.
AGENTS.md:1021-1022asks for an arrow-formFROM → TOmapping; for this category the gate atcheck-adr-0087-registration.mjs:3378-3390would refuse exactly that form. The changeset states the fix in prose instead. Where the two differ the gate docblock is the operative policy — the same lesson that cost feat(platform-objects): sys_job.timezone and sys_report_schedule.timezone are validated against the IANA domain #16296 a remediation PR.
⚠️ One correction this comment owes, and one declared blind spot
Correction to the PR body. It says "no published artefact of plugin-auth moves — comments and a test file". Measured by ablation, that is imprecise: @objectstack/plugin-auth (published, files: ["dist"]) does move — dist/index.d.ts and dist/index.d.mts differ by one JSDoc-only hunk (0 non-comment lines; the edited docblock belongs to the exported MEMBERSHIP_ENDED_REVOKE_REASON), plus two sourcemaps. It is not a surface change and owes no changeset — but "no published artefact moves" and "no published surface moves" are different claims, and only the second is true. Recorded here rather than by editing the body, because a PATCH to a PR body drops the server-appended footer.
Declared blind spot, now with a reason instead of a shrug. The review could not measure an out-of-repo cloud consumer of ctx.authRefusal. This seat checked why: the account's reachable repository set is objectstack, objectui, objectos — a listing whose control fires (an unfiltered list returns those three; a cloud filter returns zero). ⇒ there is no cloud repository reachable from here; the gap is unmeasurable rather than unmeasured. The sibling contract this repo actually states is .objectui-sha, and objectui was measured at pin a472b0716: 0 files naming authRefusal, against firing controls of 6 (resolveAuthzContext) and 14 (@objectstack/core).
⚠️ Why it is still a draft
update_pull_request draft:false is refused with "API rate limit already exceeded for user ID 19182527" — a user-scoped budget, disjoint from REST's (core reads ~14600/15000 at this moment). ⛔ REST PATCH is not a substitute: re-measured on the sibling PR minutes ago, it returns 200 and changes nothing. Nothing about this PR is holding it; the un-draft and auto-merge are re-attempted on the next check-in.
Generated by Claude Code
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 34086147182 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Dequeued at 05:42:22Z on a queue failure that is not this PR's — measured. Re-queue is owed and is itself blocked on a rate limit.
What failedQueue CI run 34086147182 on the queue branch The other three queue workflows on that branch — Why it is not this PR's, by content
The triage rule this repo states applies directly: 「超时( ⭐ Second instance of #16434, in a different package#16434 was filed hours ago from the same failure mode on a different suite: a test with no budget of its own running under vitest's default 5000 ms. It holds here too, measured on
⇒ two packages, two queue-only timeouts, same root shape. Recorded on #16434 rather than filed again. ⛔ Not fixed here — this PR is a published-type narrowing in What happens nextA re-queue is owed — that is the single legitimate re-run for this class, and it is unspent. Nothing about this PR has changed: head Generated by Claude Code |
Correction to the comment above: the re-queue is withdrawn, not merely delayed — and the cause is now diagnosed
What I got wrongThe previous comment said "A re-queue is owed — that is the single legitimate re-run for this class, and it is unspent", and treated the rate limit as the only thing standing between this PR and the queue.
and the checklist directly below it, item 2:
⇒ the "one legitimate re-run" rule is for a first, unexplained failure. This is the second independent ejection by the same test file in 24 hours, and the repo's own guidance says a re-queue before the fix just burns another full-queue round — at the cost of every PR behind it. ⛔ So this PR has not been re-queued, and will not be on this basis. And the cause is no longer a guessMeasured and posted on #16506 (diagnosis): the failing test is a 650× outlier inside its own file — 648 ms while its 18 siblings run at 0–1 ms — and the whole cost is one assertion, a deep-equality walk over a 256 KiB Buffer inside ⇒ this is a removable cost, not a budget question, and the fix is one line in a package this PR does not touch (8 changed files, 0 under Where that leaves this PRUnchanged and correct: head ⛔ What this seat will not do: skip, disable or quarantine that test; raise Generated by Claude Code |
Fixes #14273
Developer round for the
domain:enginelane, sessionsession_01ARYe3yQTQCUFm5qPYNgKaJ, dispatched by the PM seat in card comment 5563509663 on the A1 ruling (5522741447; A in 5507414114; cross-reference 5537709877). Contract tier: this diff is a published-type narrowing and its claim comment declaresClause-②: yes.The act, as ruled
ResolvedAuthzContext.authRefusalis removed — the declaration and both writers inpackages/core/src/security/resolve-authz-context.ts— under ADR-0049 enforce-or-remove. The ruling's sentence this PR is checked against: "the refusals themselves keep firing and keep failing closed exactly as today." They do. ⛔ No transport change: both wires keep answering the generic anonymous401 UNAUTHENTICATED; no status code, body or header moves; a holder of someone else's key learns nothing. ⛔ The twowarnApiKeyRefusallines (#15256 / 2A, the ruled operator exit, already onmainat the admission and post-grant decision points) and the session mirror are untouched — they belong to that card's ruling and are what makes this removal cost nothing.What changed, file by file
packages/core/src/security/resolve-authz-context.ts— the member and its docblock are gone (−20 lines); the admission writer (−1) and the post-grant writer with its inlineorganization_membership_endedmessage text (−6) are gone. ThewarnApiKeyRefusaldocblock's cross-reference ("[finding]ResolvedAuthzContext.authRefusalhas zero transport readers — both #8287 refusal reasons collapse to the generic anonymous 401 on every wire #14273's A1 ruling REMOVES the field in its own PR — not removed here") would read false the moment this lands, so it now states the history in the past tense and names the line as the one place the refusal REASON is observable.packages/core/src/security/authz-store-unavailable.ts— the rationale block A1 named (whyAuthzStoreUnavailableErroris a throw and not a field). It is a recorded rejection on a MEASUREMENT and is kept, rewritten so it cites the measurement that was acted on ("from [finding] API keys carry no organization — under the isolated posture a minted key reads no org data at all (no leak, but the key surface is inert) #8287 until [finding]ResolvedAuthzContext.authRefusalhas zero transport readers — both #8287 refusal reasons collapse to the generic anonymous 401 on every wire #14273 removed it,authRefusalhad zero consumers … a reading [finding]ResolvedAuthzContext.authRefusalhas zero transport readers — both #8287 refusal reasons collapse to the generic anonymous 401 on every wire #14273 acted on by deleting the field") instead of a shape that no longer exists. Same line count.packages/core/src/security/resolve-authz-context.test.ts— the 8authRefusalassertions are relocated, not retired: two file-level helpers read theconsole.warnline, and everyctx.authRefusal?.reasonpin becomesapiKeyRefusalReasons(warnSpy)equal to exactly['organization_membership_ended']or['organization_required'], everyauthRefusalundefined pin becomes zero refusal lines. The distinction between the two refusal reasons is therefore still pinned — now on the surface the operator reads. Plus two new pins per the ruling ("one pin that the field is gone"): on both refusal paths the envelope has noauthRefusalown-property.packages/rest/src/execctx-authz-input-seam-reachability.test.ts— A1 named it. The §2 mechanism positive control now reads the warn line (API key refused (organization_membership_ended), exactly one), and the measured-permissive leg pins zero refusal lines; the wire assertions beside them are unchanged.packages/plugins/plugin-auth/src/membership-ended-session.ts— a docblock citedauthRefusal.reason: 'organization_membership_ended'; it now cites theApiKeyRefusalReasonvalue the resolver still mints for the warn line. Comment only, same line count.packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts— two comments anchorresolve-authz-context.ts:815; the removals above that line move it to:788, so the anchors are repointed. Comment only..changeset/membership-ended-session-revoke.md— a pending changeset's parenthetical namedauthRefusal.reason; one token, nowApiKeyRefusalReason, so the release note stays true in the release that ships both..changeset/core-authrefusal-field-removed.md— new, see Carriers.Not touched, by fence:
packages/core/package.json,packages/core/examples/**,packages/rest/src/rest-server.ts,rest-server-meta-read-org-scope.test.ts,api-key.ts(ApiKeyRefusalReasonandApiKeyAdmissionstay — A1 says so, andApiKeyAdmission.refused.messageis a member of a different published type this card does not rule on).Clause ② — measured, not recalled
@objectstack/corepublishesfiles: ["dist", "README.md", "CHANGELOG.md"], rootexports["."].types = ./dist/index.d.ts(and./logger). Instrument: both legs built withtsupper package (no turbo on the path) under the shared verify lock, every declaration file underdist/diffed, hunks classified.ac76425f05ecf881fcdist/index.d.tssha256 (16)5803ef6e6fcfd22accfe43db0cf6a985dist/index.d.ctssha256 (16)12896688eacb1e44f9b4133c5a920903dist/logger.d.ts/.d.ctsae61dbc638be17f5ae61dbc638be17f5(identical)dist/index.d.tsmtimeindex.d.tsinterface ResolvedAuthzContextmember lines (comments stripped)interface ResolveAuthzInputblockHunks in
index.d.ts(and byte-for-byte the same two inindex.d.cts): (1)@@ -2094,13 +2094,13 @@— JSDoc only, theauthz-store-unavailable.tsrationale prose; not surface. (2)@@ -2271,29 +2271,6 @@— the declaration textauthRefusal?: { reason: ApiKeyRefusalReason; message: string; };and its docblock removed fromResolvedAuthzContext; surface, a narrowing. The exported name set is blind to it by construction (a member of an already-exported type), which is why the member-level reading is the one that answers. Sourcemaps differ (they embedsourcesContent; comment edits show there) and are not a contract face.packages/spec/api-surface/**is not an instrument here (core is not spec).⇒
Clause-②: yes— limb 1, a published declaration narrowed. Limb 2 (what a door accepts or refuses) does not move: the refusals fire identically on both legs (the relocated pins are green on both, see the reverse check), and no wire answer changes.Sibling pin: objectui at
.objectui-shaa472b0716:authRefusal0 files; controlsresolveAuthzContext6 files,@objectstack/core14 files.Reverse verification (committed tree, absolute-path trap, blob-proven)
Mutation leg:
git restore --source=ac76425f0 -- packages/core/src/security/resolve-authz-context.ts(writes the tree only) re-added the member and both writers on top of the committed head. Proven on disk before measuring: whole-line anchorsauthRefusal?: { reason: ApiKeyRefusalReason; message: string };0 → 1 andctx.authRefusal = { reason: admission.reason, message: admission.message };0 → 1; blobe4f015df7(HEAD) →9928f2cfc(== the BASE blob). Predicted direction: exactly the two[#14273]own-property pins red, everything else green — because the warn line the relocated pins read is independent of the field.Observed:
Tests 2 failed | 86 passed (88)— the two failures are[#14273] a post-grant refusal answers an envelope with no authRefusaland[#14273] an admission refusal answers an envelope with no authRefusal either; all six relocated reason pins stayed green with the field present (VERDICT command-exit 1, the expected red). Restore leg:git checkout HEAD -- ABSOLUTE_PATHfrom anEXIT INT TERMtrap; proven bygit hash-object==HEADblobe4f015df7,git diff HEAD --statempty,git status --porcelainempty, field anchor back to 0. No rebuild was needed for this leg: the core suite imports the resolver fromsrc; the rest leg readsdist/and was not re-driven under the mutation (declared).The PM's Zone-2 assumptions, re-measured on this tree
git grep authRefusalover all tracked files at base: 18 lines in 6 files; firing controlresolveAuthzContext= 139 files underpackages/. Writers 2, declaration 1, runtime readers 0; the rest is prose and test assertions. Structural readers (destructuring, index access, spread,Pick/Omitin another package) would still have to spell the token — and none of the 139 consumer files does; the objectui sibling has 0 with firing controls..changeset/membership-ended-session-revoke.md:21. Per site:resolve-authz-context.ts:184rewritten (would read false);authz-store-unavailable.ts:49-50kept as load-bearing history, re-cited to the measurement acted on (A1's own instruction);membership-ended-session.ts:97re-cited toApiKeyRefusalReason; the changeset parenthetical re-cited the same way.resolve-authz-context.test.ts×8 — the PM's ×7 missed the#15409session-arm pin at old:1666— plus rest ×2). None skipped, disabled or quarantined; all relocated onto the warn line; nothing genuinely retired, because every one of them pinned behaviour (which reason, or that no refusal fired) and not only the field..d.ts— held, with the caveat measured above: visible at member level only; the name set reads 242→242.packages/coreand its tests changes — falsified, narrowly. Two comment-only citations inpackages/plugins/plugin-authand one token in a pending changeset were made false by the removal and are repaired (no published artefact of plugin-auth moves — comments and a test file). No consumer code changes: typecheck of the direct consumers whose closures were built locally —@objectstack/rest,@objectstack/plugin-sharing,@objectstack/service-datasource— exit 0 against the rebuilt core declarations; the workspace-wide compile is CI'sTypeScript Type Checkand was not re-run here.Carriers
.changeset/core-authrefusal-field-removed.md:@objectstack/coreminor— the act is a breaking narrowing, andscripts/check-changeset-no-major.mjs:55-66says the level is not the carrier during the launch window; the carriers are the**BREAKING**banner in the body and the ADR-0087 disposition, both present. Disposition:not-required (runtime-interface-only packages/core/src/security/resolve-authz-context.ts#ResolvedAuthzContext)— the ADR-0087's changeset disposition has no category for published runtime TS interfaces with no metadata surface — the #8277 exemption argument is correct, unverifiable, and will be re-litigated #8299 category built for exactly this shape (a published runtime interface lost a member; no schema, spec declaration or stored representation; the compiler is the channel). The gate verified it:check-adr-0087-registration --base ac76425f0→ exit 0, "verified: … #ResolvedAuthzContext (interface)".check-changeset-no-majorexit 0;check-empty-changesetexit 0. Modelled ond8024f0:.changeset/core-plugin-type-closed-set.md; that precedent'sno-migration-prescriptioncategory predatesruntime-interface-only, whose predicate 4 is the positive, re-runnable reading this body prefers.needs:contract-reviewhung on the card and on this PR, additively, the moment this PR exists (5536347794 removed the premature one).Verification, all at
5ecf881fc(clean tree)pnpm --filter @objectstack/core exec vitest run --maxWorkers=2 src/security/resolve-authz-context.test.ts src/security/authz-store-unavailable.test.ts src/security/api-key.test.ts→ Test Files 3 passed, Tests 145 passed; lock VERDICT command-exit 0.pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/execctx-authz-input-seam-reachability.test.ts→ 1 file, 27 passed, VERDICT command-exit 0 (imports@objectstack/corethrough its rebuiltdist/, so this leg drove the published artefact).pnpm --filter @objectstack/core typecheckexit 0 (src + examples +check:test-typecheck: 4 files / 4 errors / 4 pinned, unchanged ledger — the edited test file is not in it, so it is compiled clean).@objectstack/rest,@objectstack/plugin-sharing,@objectstack/service-datasourcetypecheckexit 0..tsfiles,--format json→ 6 files, 0 errors, 0 warnings; all 6 inside the config's population (--print-configresolves);eslint.config.mjshas noparserOptions.project(no type-aware rules), so this diff cannot move any untouched file's verdict — a measured narrowing ofpnpm lint, whose full sweep is CI's.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(8 paths vs merge baseac76425f0): 25 node-script gates exit 0 (includingcheck-system-context-census,check-tenant-audit-census,docs-audit/*,check-closing-keyword-parity); 24pnpm check:*families exit 0 (authz-resolver,cross-package-test-inputs,dts-closure— 25 built packages swept, 94/94, core in the set —engine-double-contract,nul-bytes,published-files,test-source-alias,type-check-coverage,type-source-resolution, …). Not measured locally:check:dual-build-cjs-loadsandcheck:type-check-debtboth exit 3 (their own "PREREQUISITE NOT MET — nothing was measured": they need the whole workspace built); CI's full-build job carries them.Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
Generated by Claude Code