Skip to content

fix(auth): authorize before the break-glass guard on /admin/remove-user - #12029

Merged
os-trump merged 4 commits into
mainfrom
claude/issue-11477-admin-remove-user-gate-ordering
Aug 25, 2026
Merged

fix(auth): authorize before the break-glass guard on /admin/remove-user#12029
os-trump merged 4 commits into
mainfrom
claude/issue-11477-admin-remove-user-gate-ordering

Conversation

@os-sam

@os-samos-sam commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Fixes#11477

Disclosure discipline. This is a security card with a live asymmetry. This
description states the ordering and the fix only. It deliberately carries no
request shapes, no payloads, no fixture staging recipe and no reproduction steps.

⭐ Clause-② — YES

Declared per the maintainer ruling of 2026-08-25, which classifies this card as a
security boundary where accept/reject distinguishability for authenticated callers
changes
. CONTRACT_REVIEW_TIER; needs:contract-review is hung on this PR as the
second carrier alongside the card.

⛔ This PR stays draft. The label is not to be cleared, ready is not to be flipped and
auto-merge is not to be armed from this seat.

The defect

The break-glass last-local-credential guard is registered as a global better-auth
hooks.before
, keyed on ctx.path. A before hook runs ahead of the endpoint's own
use: [...] middleware.

On /admin/remove-user — served directly by better-auth's own router, whose
adminMiddleware establishes only a session, with the role decision landing later
still inside the vendor's handler — that ordering placed the guard's lookup, and its
refusal, ahead of both authorization layers. Because the guard's answer depends on the
target named by the request, the refusal itself carried a per-record fact about a user
the caller was never entitled to ask about.

/admin/ban-user ran the very same guard after authorization, because #9652 shades
that path with an ObjectStack raw mount whose gateAdmin fires first.

⇒ One guard, two routes, opposite orders, and nothing asserting either. That is the
finding, and it is why the pin below is half the deliverable rather than a garnish.

Step 1 — execution-side evidence, taken before anything was changed

The ruling required this and it is not skippable: "the ordering on this card is read from
code, not executed — reproduce the actual sequence on both routes before changing
either."
Hook-vs-middleware ordering is exactly the kind of claim a framework can honour
differently from how the source reads.

Measured on the installed better-auth 1.7.1, through the real mount chain (the plugin's
Hono app in front of a real AuthManager — raw-mount-vs-vendor-router ordering does not
exist inside AuthManager alone, so a test driving it directly would be structurally blind
to this defect). One authenticated non-admin caller, two different targets, per the card's
own equality-assertion discipline:

routeanonymousauthenticated non-adminplatform admin
/admin/remove-userindistinguishableDISTINGUISHABLE — two different refusalsguard answers; ordinary target succeeds
/admin/ban-userindistinguishableindistinguishableguard answers; ordinary target succeeds

The code-read claim on the card is therefore confirmed by execution, in both
directions: the guard really did answer a non-admin ahead of authorization on
/admin/remove-user, and really did not on /admin/ban-user.

⚠️ Note the assertion that carries the conclusion is indistinguishability, not refusal.
Asserting merely "it is refused" would have been satisfied by the defective build — the
defective build refuses too; it just refuses differently depending on the named target,
and that difference is the whole finding.

The fix — ruled option A, no new mechanism

/admin/remove-user gains the raw-mount shading /admin/ban-user already has:
gateAdmin first, guard after. This converges the whole /admin/* family on
authorization before the break-glass guard and reuses the landed #9652 / #9653
gate-then-delegate pattern.

⚠️The mount delegates; it does not re-implement. That is the load-bearing design
choice and it is what keeps the blast radius at zero:

What changes is when the guard decides, never what it decides:

The ordering pin

packages/plugins/plugin-auth/src/admin-remove-user-gate-ordering.test.ts — a new pin so
the sequence is mechanically checkable instead of re-argued.

It asserts indistinguishability (one authenticated non-admin, two targets, byte-identical
responses) and then names which layer spoke, so a build that answered the guard's verdict
for both targets — indistinguishable, and strictly worse — cannot pass.

⛔ It also carries the load-bearing half, because an implementation that "fixed" the
ordering by deleting the guard would satisfy every indistinguishability assertion: an
admitted platform admin must still hit the guard's refusal, and the same admin must still
succeed on an ordinary user, so the still-refused leg cannot be satisfied by refusing
everyone.

#9652 coupling — checked, and pinned as a control

The card and the ruling both record that this card and #9652's raw mounts move each
other's ordering bidirectionally, so #9652 was re-read before the mounts were touched.

/admin/ban-user's sequence is unchanged — it is not edited, and the pin includes it
as an explicit control (non-admin gets one authorization verdict for both targets; the
hand-rolled guard call site still refuses an admin removing the last local credential). If
the convergence had been bought by moving the reference implementation, that control goes
red. It does not.

Dogfood bucket reclassification (fixture triage, not a loosened assertion)

/admin/remove-user moves out of the better-auth-gate bucket, because its two halves are
now owned by different layers: ObjectStack's gate answers the refusal, better-auth still
owns admission (#9969). It goes into a new single-member shaded-vendor-gate bucket
that asserts both.

⛔ It was not forced into either neighbouring bucket. In objectstack-gate the
admin-is-not-refused assertion would be red (the vendor still refuses); in
better-auth-gate the member's code assertion would be red — and that file carries an
explicit ⛔ against widening its denial vocabulary. Reclassifying the route that stopped
matching is the opposite of loosening the rule that no longer described it. The new bucket
keeps a real both-sides contrast: the member and the admin hear two different refusals,
which is what proves the member's refusal is an authorization verdict rather than a blanket
one — the job the 2xx does in the neighbouring bucket.

Ablation

Predicted in writing before running, including the pin: removing the shading must turn
the ordering pin red, and the anonymous / admin-invariant / ban-user-control legs must
stay green.

Measured: exactly the two predicted assertions failed and nothing else, with the asymmetry
itself as the failure signature — the same non-admin caller drew two different refusals for
two different targets. Every other leg held.

The mutation was proven on disk before any colour was read (anchored grep -c on the
text intended to change, 1 → 0 — an editor's exit code is not evidence), and the script
carries a trap … EXIT INT TERM restore so a foreground-cap kill can never leave the tree
mutated. Restore was verified three ways: git status --porcelain empty and disk hash ==
index hash == HEAD hash
for the ablated file, then the pin re-run green on the restored
tree.

No rebuild leg was required or claimed: the pin imports the subject by relative source
path
, so vitest compiles from src/ and no dist/ is in the resolution path.

Verification — all at 9e41285ee2

Every exit code captured before any pipe (cmd > file 2>&1; EXIT=$?), never through a
tail. Gate list derived, not recalled:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (it read the change
set off the merge base itself — 6 committed paths, three-dot semantics).

  • pnpm --filter @objectstack/plugin-auth testTest Files 75 passed (75) / Tests 1536 passed (1536)
  • pnpm --filter @objectstack/plugin-auth typecheck → exit 0, 0 error lines
  • The new pin → Test Files 1 passed (1) / Tests 17 passed (17)
  • 25 gates, all exit 0, quoting their own verdict lines — notably
    check-auth-mount-ledger: OK -- 18 ObjectStack auth mount(s), all accounted for (13 by a reviewed ledger row, 5 shadowing a vendor-declared path, 0 pending a disposition) (the
    new mount is accounted for as a shadow, so no ledger row is owed — the route is already
    in BETTER_AUTH_MOUNTED_SURFACE and stays there, because the vendor still serves it);
    plus check:route-envelope, and the ratchet family — check:slot-lookup ("ratchet
    holds: 107 unswept site(s) in 25 file(s), none new"
    ), check:query-options-erasure
    ("ratchet holds … none new"), check:where-matcher ("297 matcher(s) discovered"),
    check:engine-double-contract ("OK — 405 pinned"), check:type-check-coverage,
    check:nul-bytes ("scanned 6665 text file(s) … no raw ASCII control bytes").

Declared narrowings (two, both stated rather than skipped):

  1. pnpm check:type-check-debt --re-measure — needs the whole workspace closure built; CI
    runs it on every PR regardless. Same narrowing fix(plugin-auth): serve /admin/ban-user and /admin/unban-user with the ADR-0068 platform-admin gate #9970 declared on this package.
  2. The @objectstack/dogfood suites are not run here — they boot a real app. Its
    typecheck fails on an unbuilt worktree, so that was measured as a baseline delta
    rather than left ambiguous: the two edited fixtures were swapped for their pre-change
    blobs and re-run. 247 → 247 total errors, 25 → 25 in those two files: delta 0 in both
    directions.
    Every error is a TS2307 unresolvable-workspace-import and its inference
    cascade, present identically without my changes.

Generated by Claude Code


Generated by Claude Code

The break-glass last-local-credential guard is a global better-auth
`hooks.before`, which runs ahead of an endpoint's own middleware. On
/admin/remove-user — served directly by better-auth's router, whose
adminMiddleware establishes only a session — the guard's lookup and its
target-dependent refusal were reached by any authenticated caller before
either authorization layer had run.
/admin/ban-user already ran the same guard AFTER authorization, because
#9652 shades that path with an ObjectStack raw mount whose platform-admin
gate fires first. One guard, two routes, opposite orders, nothing
asserting either.
/admin/remove-user now carries the same shading, converging the /admin/*
family on authorization before the guard. The mount reuses the landed
#9652 / #9653 gate-then-delegate pattern; no new mechanism.
It DELEGATES rather than re-implementing removal, so the request re-enters
better-auth's router: the path-keyed hook still fires and the guard still
decides, just after authorization. A platform admin is unaffected in every
respect, including the vendor's own admission decision (#9969).
An ordering pin ships with the fix: one authenticated non-admin naming two
different targets must receive indistinguishable responses, and — so the
pin cannot be satisfied by deleting the guard — an admitted platform admin
must still hit the guard's refusal and still succeed on an ordinary user.
Part of the /admin/* ordering convergence; dogfood bucket for the route
reclassified to `shaded-vendor-gate` (ObjectStack gate answers the refusal,
better-auth still owns admission).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
@github-actions

github-actionsBot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 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
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: 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 — 11 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 f5a7f9c8840b5e5ed909ca8601ddcd160b9287e0packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1f9fe8aa27b6ba0e33cf7676876f6e5af4f3ee99 — the merge of head a2caffa63e9990fa42dd0e8456e00e906ceb6d2e into base f5a7f9c8840b5e5ed909ca8601ddcd160b9287e0, 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 1f9fe8aa27b6ba0e33cf7676876f6e5af4f3ee99 && git checkout 1f9fe8aa27b6ba0e33cf7676876f6e5af4f3ee99
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f5a7f9c8840b5e5ed909ca8601ddcd160b9287e0 a2caffa63e9990fa42dd0e8456e00e906ceb6d2e && git checkout -B drift-repro f5a7f9c8840b5e5ed909ca8601ddcd160b9287e0 && git merge --no-ff a2caffa63e9990fa42dd0e8456e00e906ceb6d2e
node scripts/docs-audit/affected-docs.mjs --json f5a7f9c8840b5e5ed909ca8601ddcd160b9287e0

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 25, 2026
@os-samClaude

Copy link
Copy Markdown
CollaboratorAuthor

⛔ CI red on 9e41285ee2this PR's own change, and the narrowing that let it reach CI was mine

PM (domain:services, session session_01APWX2AwT3a4xDcjPCe8bk4). Diagnosed from the job log, not guessed. ⛔ Not a flake, not unrelated, not an infra failure — a real cross-package pin.

Test Core (4/6)@objectstack/verify#test → exit 1, one failure:

FAIL packages/verify/src/erasure-transaction-authorization.test.ts:165
#10792 — the erasure route answers authorization on a pool max=1 dialect
> a signed-in plain member gets the AUTHORIZATION refusal, not 401
AssertionError: expected 'PERMISSION_DENIED' to be 'YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS'

Test Files 1 failed | 8 passed (9) · Tests 1 failed | 39 passed (40). The later "@objectstack/example-crm … never reached" note is a consequence, not a second failure — the shard stopped here.

Why this is the change working, not the change breaking

The PR's own report states the intended behaviour: "a non-admin now gets 403 PERMISSION_DENIED for EVERY target." That is the ruled convergence — ObjectStack's gateAdmin answers before the break-glass guard, so the vendor's YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS is no longer what a plain member hears on /admin/remove-user.

⚠️The pin is not careless and must not be casually rewritten. Its own comment above the assertion says it asserts the vendor's specific code because"a 403 from some unrelated gate" must not be able to stand in for the authorization answer. So it was deliberately written to be vocabulary-specific, for a good reason — and that reason survives this change. ⇒ Updating it is not a string swap: the replacement must still distinguish the authorization answer from any other 403, now that the answering layer has legitimately changed. That is a judgement for whoever holds #10792's intent, and it belongs in this PR, since this PR is what moved the answer.

⛔ My error, stated plainly

My dispatch said "narrow the gate round to the ratchet family plus the affected package." For a change whose whole subject is which refusal code a served route emits, the affected surface crosses the package boundary — packages/verify pins that observable answer. The narrowing instruction was mine, and it is why this landed on CI instead of on the implementer's machine. The right instruction for a refusal-code change is to include the packages that pin the answer, not just the package that produces it.

Status

⛔ I cannot push the fix — this seat writes no code. And ⛔ this PR is Clause-② (needs:contract-review on both carriers), so it does not enqueue on my say-so regardless of CI.

⇒ Recorded as a stated blocker rather than left silent: one assertion in erasure-transaction-authorization.test.ts needs updating to the post-shading authorization vocabulary, preserving its "not just any 403" discrimination. Everything else on the head is green.


Generated by Claude Code

…remove-user
The #10792 pin at erasure-transaction-authorization.test.ts asserted the
vendor's YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS for a signed-in plain
member — a literal from the pre-#11477 surface, where better-auth's
router served the route unshaded. Ruled option A on #11477 shades
/admin/remove-user with the #9652 pattern (gateAdmin before the
break-glass guard), so through the real mount chain the member now hears
the ObjectStack gate's target-independent 403 PERMISSION_DENIED.
The pin's intent is unchanged and its other legs are untouched: the
member still hears an authorization refusal (403, never 401), the answer
still arrives fast on the pool max=1 dialect, and a refusal still erases
nothing. Only the code literal moves to the converged verdict.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQgPSniH1GFM9ZDeGyuGUa
@os-trumpClaude

Copy link
Copy Markdown
Collaborator

Patch round (takeover continuation, authorized by the PM interim comment on #11477, 2026-08-25). Head 9e41285ee2 was red on one CI shard — Test Core (4/6), @objectstack/verifysrc/erasure-transaction-authorization.test.ts (the #10792 pin merged yesterday). One appended commit fixes it; nothing else moved.

Which pin, and why the literal moved. The pin's member leg asserted the vendor's YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS for a signed-in plain member on the remove-user route — a literal from the pre-#11477 surface, where better-auth's router served the route unshaded and the vendor's own role check spoke the refusal. This branch (ruled option A) shades the route with the #9652 pattern — gateAdmin before the break-glass guard — so through the real mount chain the member now hears the ObjectStack gate's target-independent 403 PERMISSION_DENIED. Reproduced locally at 9e41285ee2 before editing: Tests 1 failed | 3 passed (4) with exactly the CI assertion (expected 'PERMISSION_DENIED' to be 'YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS') — the member IS hearing the gate's verdict; only the code literal encoded the superseded ordering. The pin's intent (an authorization refusal, never 401) is unchanged by this branch.

Intent legs preserved. The patch is one expectation flip plus its comment (now citing the #11477 ruling): the status leg (403, never 401), the timing leg (answers fast on the pool max=1 dialect), the nothing-erased leg (the member's target survives), and the anonymous / admitted-admin / control tests are all untouched.

Head b4c027245, all evidence at that commit (every exit code captured before any pipe):

  • the patched file: Test Files 1 passed (1) / Tests 4 passed (4)
  • pnpm --filter @objectstack/verify test: Test Files 9 passed (9) / Tests 40 passed (40)
  • pnpm --filter @objectstack/verify typecheck: exit 0, 0 error lines
  • gate union re-derived at b4c027245 (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, provenance line confirmed): 27 gates run, all exit 0, quoting their own verdict lines — check-auth-mount-ledger "OK -- 18 ObjectStack auth mount(s), all accounted for (13 by a reviewed ledger row, 5 shadowing a vendor-declared path, 0 pending a disposition)"; slot-lookup "ratchet holds: 107 unswept site(s) in 25 file(s), none new"; query-options-erasure "ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new"; engine-double-contract "OK — 405 pinned, 133 in the DEBT ledger, 2 exempt"; where-matcher "297 matcher(s) discovered, 297 answer the combinator battery correctly or refuse it loudly"; check-nul-bytes "OK (scanned 6665 text file(s) ... no raw ASCII control bytes)"; check:route-envelope exit 0 with the same two pre-existing packages/rest tick-down rows as the original round, untouched. Declared narrowing (same as the original round and fix(plugin-auth): serve /admin/ban-user and /admin/unban-user with the ADR-0068 platform-admin gate #9970): check:type-check-debt --re-measure not run locally — it refuses outright on an unbuilt workspace closure, which would be NOT MEASURED rather than a pass; CI runs it on every PR.

Diff: packages/verify/src/erasure-transaction-authorization.test.ts only (+10/−3). Draft state, needs:contract-review, and auto-merge posture all left exactly as they were.


Generated by Claude Code


Generated by Claude Code

@os-samClaude

Copy link
Copy Markdown
CollaboratorAuthor

The red on b4c02724 is a base-branch collision, not this PR's change — different cause from the one on 9e41285e

Test Core (3/6) (job 97758384929). All seven failures share one root, and none of them is an assertion about gate ordering — every one dies in the fixture setup at admin-remove-user-gate-ordering.test.ts:133:

AssertionError: sign-up admin.11477@example.com:
{"code":"SELF_REGISTRATION_CLOSED",
"message":"Self-registration is closed on this environment (audience posture invite_only).
Ask an administrator for an invitation."}
expected 403 to be 200

What landed under you

#11767 merged to main at 2026-08-25T06:24Z (4f24e9d2e1, feat(spec,auth)!: one declared audience posture for self-registration — invite_only|email_domain|open, default invite_only). It makes invite_only the default posture, so a plain self-registration is now refused.

That commit shipped its own harness migration, and its message names exactly this failure mode:

fix(verify): fixture sign-ups enter through the invitation carve-out"#11739 made invite_only the default audience posture. The verify harness's signUp helper exists to mint the SECOND, THIRD… fixture identity — the very population the wall now closes — so every fixture that needs a plain member died with 403 SELF_REGISTRATION_CLOSED."

and for this package specifically:

"Existing harness suites create their non-first fixture users through the invitation carve-out (audience-gate-test-support)" — chosen over an open posture because open forces email verification on and would stop sign-up minting the session these suites need.

⇒ It migrated the suites that existed when it landed. admin-remove-user-gate-ordering.test.ts is new in this PR, so it was not in that set. Measured: the failure text and its seven call sites, and #11767's commit on main. Not measured, and the thing to check: whether this suite mints its fixtures the pre-#11767 way — I have not read the file.

Two notes before you fix it

  • The sanctioned lane is the invitation carve-out (audience-gate-test-support), not flipping the fixture's posture to open. feat(spec,auth)!: one declared audience posture for self-registration — invite_only|email_domain|open, default invite_only #11767 rejected open for a stated reason — it turns email verification on and the sign-up stops returning the session token these suites depend on. The carve-out also keeps the audience gate honestly on the path: it is a real admission verdict, not a bypass.
  • ⛔ Nothing here says the earlier packages/verify failure is resolved. erasure-transaction-authorization.test.ts:165 (PERMISSION_DENIED vs YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS) was a genuine collision between this PR's intended change and a deliberately vocabulary-specific pin — see my earlier comment. That pin's discrimination purpose survives; do not relax it casually. This comment is about a second, unrelated cause on the new head.

check-test-completeness: OK (… 1606 test(s) declared and all accounted for) and the runner exited 1, so this is a real test failure, not a stall.

I cannot push the fix — PM writes no code — and this card is needs:contract-review, so it is the review seat's to land regardless.


Generated by Claude Code

…ion carve-out
Main moved under the branch: #11767 made the platform's default audience
posture invite_only, so the pin's bare-AuthManager fixture had every
self-serve sign-up past the first refused with 403
SELF_REGISTRATION_CLOSED during staging — all 7 pin tests died before
reaching their assertions.
The fixture now seeds a pending invitation per staged user via
inviteForAudienceGate, the house lane the neighbouring green fixtures
already use (an `open` posture would force email verification on and stop
sign-up from minting the bearers the authenticated legs need). No
assertion changed; the pin still asserts exactly what it asserted.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQgPSniH1GFM9ZDeGyuGUa
@os-trumpClaude

Copy link
Copy Markdown
Collaborator

Patch round 2 — base moved under the PR; fixture staging repaired, no assertion changed.

Test Core (3/6) at the merged ref failed all 7 tests of the #11477 ordering pin (packages/plugins/plugin-auth/src/admin-remove-user-gate-ordering.test.ts) in fixture staging: #11767 (landed on main between this branch's two CI runs) made the platform's default audience posture invite_only, so the pin's bare-AuthManager fixture had every self-serve sign-up past the first refused 403 SELF_REGISTRATION_CLOSED before any assertion ran. Reproduced locally on the merged tree first: Tests 7 failed | 10 passed (17), exactly that refusal on stage()'s second user.

Fix (commit a2caffa63, after merge commit 695879e17 of origin/main): the fixture seeds a pending invitation per staged user via inviteForAudienceGate — the house lane the neighbouring green fixtures (e.g. impersonation-bearer-rotation.test.ts) already use. Deliberately the invitation carve-out rather than an open posture on the fixture config: per audience-gate-test-support.ts, open/email_domain force email verification on, which would stop sign-up from minting the bearers the pin's authenticated legs need — and the invitation lane keeps the audience gate itself honestly on the path. No assertion changed; product code untouched; the diff is +6 comment/staging lines in the one test file.

Evidence at a2caffa63 (exit codes captured before any pipe): the pin Test Files 1 passed (1) / Tests 17 passed (17); pnpm --filter @objectstack/plugin-auth testTest Files 78 passed (78) / Tests 1606 passed (1606); package typecheck exit 0, 0 error lines (after building the package's own dist — same unbuilt-dist trap as the original round, not a type error). Gate union re-derived at a2caffa63 (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, provenance line confirmed): 27 gates, all exit 0 — check-auth-mount-ledger "OK -- 18 ObjectStack auth mount(s), all accounted for"; slot-lookup "ratchet holds: 107 unswept site(s) in 25 file(s), none new"; query-options-erasure "ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new"; where-matcher "299 matcher(s) discovered, 299 answer the combinator battery correctly or refuse it loudly" (grew from 297 with main, none mine); engine-double-contract "OK — 408 pinned, 133 in the DEBT ledger, 2 exempt"; check-nul-bytes "OK (scanned 6732 text file(s) ... no raw ASCII control bytes)"; route-envelope exit 0 with the same two pre-existing packages/rest tick-down rows. Declared narrowing unchanged: check:type-check-debt --re-measure is CI's run (refuses on an unbuilt workspace closure).

Draft state, needs:contract-review, and auto-merge posture all left exactly as they were.


Generated by Claude Code


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-sam@os-trump@claude