Skip to content

Commit f1c9bb3

Browse files
docs(qa): re-point identity-auth's get-session clauses at the 401 refusal envelope, and pin them to it (#18742)
Fixes #18650 `docs/qa/platform-checklist/areas/identity-auth.json` still taught better-auth's retired no-session convention — HTTP `200` with a JSON `null` body — and instructed a runner accordingly. Since #17881 landed `refuseAnonymousSession`, the live answer is `401` + `UNAUTHENTICATED` in the ADR-0112 refusal envelope, so the file asserted the **opposite** of the truth: it said a `401` expectation "misdescribes a correct implementation", when an implementation answering `401` is today the correct one. A runner following it recorded correct behaviour as a defect, and the remedy its negative pointed at was undoing an auth tightening. Clause-②: no ## The two premises, measured here rather than relayed **① The count.** Re-derived on today's `origin/main` by a structural walk of every `docs/qa/platform-checklist/areas/*.json` (by JSON node, not by line), with controls in both directions: positive `get-session` = 35 occurrences across the area files, negative nonsense token = 0. The result is **five** sites, all in `identity-auth.json`, all inside one item: | node path | field | |---|---| | `items[4].steps[7]` | the runner instruction | | `items[4].acceptance[4].verify` | clause 5's oracle | | `items[4].negative[2]` | the negative | | `items[4].source[6]` | the evidence row | | `items[4].history[2].change` | revision 3 | Five is what the card said and five is what the sweep found — reported because it was measured, not because it was expected. The card's declared-unswept question is answered in the same pass: **0** other area files carry the convention, and `grep` over `RUNNER.md` / `README.md` / `SWEEP.md` / `FOLLOW-UPS.md` finds none either, so nothing else needs filing. Deliberately **not** counted among the five: `get-session` statements at other sites that describe behaviour without teaching the convention (a post-sign-out read "no longer returns the user"; a pre-2FA read "does not return an authenticated user"; the post-remove avatar read). Those stay true under both wire answers. **② The behaviour.** Driven on this checkout through a real `AuthManager` over the in-memory engine this package's better-auth suites use, because the card's first declared act was to drive the **revoked** path specifically — #17881 is described in terms of an *anonymous* caller and nobody had checked they were the same door: ``` sign-up -> 200 cookie issued GET /get-session (live) -> 200 {"user":{...},"session":{...}} POST /revoke-sessions -> 200 {"status":true} GET /get-session (revoked) -> 401 {"success":false,"error":{"code":"UNAUTHENTICATED","message":"Sign in first"}} GET /get-session (fresh mgr) -> 200 [control, on its own engine] ``` They are the same door. `refuseAnonymousSession` keys on the **answer shape** — the `/get-session` path, status `200`, and a body that is exactly the literal `null` — never on how the caller became anonymous, so "never signed in", "unknown cookie" and "revoked session" all convert. The control on a separate manager rules out the revoke having poisoned the harness. The probe was a throwaway; it is not in this diff. ## What changed Four instructional sites re-pointed at the live answer. The **substantive** advice is kept and re-founded rather than deleted: `get-session` is still not this clause's oracle, but no longer because its status cannot discriminate (it now can) — because the clause's contract is an immediate kill on a **PROTECTED** request, and one auth-route seam's status is not proof the authorization layer refuses the target's in-flight protected reads. That reason does not move with the wire. The negative was the most dangerous of the five and is now explicit in both directions: a `401` after the revoke is the **correct** answer and must never be filed as the defect, while a `200` carrying a user after the revoke is no longer the benign convention this line once described — it is the live-session answer, so it IS a real signal the kill did not land. `revision 3` is left standing, **unedited**. It was right when it was written; its cited authority (`session-of-record.test.ts`) was inverted underneath it afterwards and now pins the very `401` it was cited for denying. Rewriting or deleting it would erase what the August judgement rested on, which is the whole point of a revision history. A new `revision 6` says so, and the item's `revision` field moves 5 to 6 (`check:platform-checklist` holds those equal). ## The pin, and the proof it can go red `packages/plugins/plugin-auth/src/checklist-refusal-envelope-consistency.test.ts` holds the checklist equal to the runtime's refusal envelope. The status comes from `ANONYMOUS_SESSION_REFUSAL_STATUS` and the code is **derived** from it through ADR-0112's own status map — neither is spelled a second time, so the pin cannot agree with a runtime that has moved. It lives in `plugin-auth` on purpose. The next behaviour change is a diff in that package, which puts it in the affected set; `check:platform-checklist` is deliberately not a per-PR gate (its own header records that ruling), so a pin sitting beside the docs would be judged by where the docs live. The escaping read into `docs/qa/platform-checklist/areas/` is declared in `scripts/cross-package-test-inputs.mjs` with matching `turbo.json` inputs, so neither of CI's scoping layers replays a cached green over it. Three legs, because any one alone is satisfiable by a file that says nothing: ALIGNMENT (every site naming the seam states the runtime's status and code), ABSENCE (no instructional string in any area file teaches the retired convention), FLOOR (each instructional family still carries an aligned statement). `history` is exempt from the absence leg by design — a revision entry has to be free to quote what it corrected, which is exactly what this card was told to preserve. **Ablation**, two legs, mutation shown on disk before each run and the restore verified by blob hash, never by exit code: ``` HEAD blob checklist = 4d00eca HEAD blob seam = ba3aeb8 LEG A simulate the next behaviour change (401 -> 403 at the seam) on disk BEFORE: 'ANONYMOUS_SESSION_REFUSAL_STATUS = 401' x1 on disk AFTER : 401 x0 · 403 x1 pin exit = 1 FAIL "every site naming the refusal seam states the status and code the RUNTIME emits" + "items[4].steps[7]: missing status 403" + "items[4].steps[7]: missing code PERMISSION_DENIED" + "items[4].acceptance[4].verify: missing status 403" (and the rest) restored, blob matches HEAD: ba3aeb8 LEG B revert the checklist to the RETIRED assertion on disk BEFORE: '200-with-null-body' x0 on disk AFTER : '200-with-null-body' x1 pin exit = 1 FAIL "no instructional string teaches the retired 200-plus-null convention" + "items[4].negative[2]" restored, blob matches HEAD: 4d00eca RESTORED-TREE CONTROL pin exit = 0 ; git diff HEAD over both paths empty ``` Leg A also settles the resolution question the dist-preflight rule exists for: the mutated constant is reached through a **relative source import** (`./anonymous-session-refusal`), not through a dependency's `exports`, so no rebuild sits between the mutation and the red — and the red arriving without one is the evidence, not an assumption. Leg B's subject is a JSON file read at runtime, with no build in its path at all. ## Tier Measured, not assumed: `packages/plugins/plugin-auth` runs `vitest run` with no project partition and has no `vitest-tiers.ts` (the only one in the repo is `packages/cli`'s). The new pin therefore lands in the package's single tier and is covered by the ordinary `pnpm --filter @objectstack/plugin-auth test` run below — nothing moved out of a tier, and no existing pin changed tier. ## Verification - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived **72** families; reconciled with `--ran` carrying every exit code: **72 derived, 71 run, 1 NOT-MEASURED, 0 UNRUN.** - NOT MEASURED: `pnpm check:dual-build-cjs-loads` exit **3** = PREREQUISITE NOT MET (reads built output; 40 packages have no `dist/` in this worktree). Not a pass and not a red — CI's `Build Core` job owns it. - Two gates went red or refused on first contact and were repaired inside this diff rather than argued with: `check:cross-package-test-inputs` exit 1 naming `scripts/cross-package-test-inputs.mjs` as a path the test *names* in prose with no glob covering it (the "named rather than read" class this entry already carries for two sibling scripts — declared, per its own note that declaring is cheaper than rewording prose to dodge the collector), now exit 0; and `check:type-check-debt` exit 3 until its three unbuilt workspace dependencies were built, then exit 0 with `4 ledger entries re-measured, 53 raw tsc errors, none above its recorded number`. - `pnpm lint` **repo-wide** (`eslint . --no-inline-config`, full population, no narrowing): exit 0. Run although `dispatch-gates` does not name this family. - `pnpm --filter @objectstack/plugin-auth test`: **112 files, 2365 tests passed.** - `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0 — `test layer compiles under tsconfig.test.json; 10 files / 94 errors / 23 pinned signatures held` (unchanged ledger). - `pnpm check:platform-checklist`: exit 0 — `15 areas, 264 items; symbol anchors 577/633 resolved, 17 file floors held`. The evidence row's anchor was swapped one-for-one (`session-of-record.test.ts#body` out, `anonymous-session-refusal.ts#ANONYMOUS_SESSION_REFUSAL_STATUS` in), so the per-file floor is untouched. - Dependency closure built first (`pnpm --filter '@objectstack/plugin-auth^...' build`), so nothing here was judged against a stale `dist`. ## Landing surface and changeset `check-governed-merges.mjs --test` on the **final** four-path file list: **0 of 4 hit the register — NOT governed**, ordinary queue landing. Control in the other direction: the same tool answers **1 of 1 GOVERNED** for `.claude/skills/pm-dispatch/SKILL.md`. `skip-changeset`, measured rather than assumed. `plugin-auth` publishes `files: ["dist","README.md","CHANGELOG.md"]`; after building it, the new test's symbols (`checklist-refusal-envelope-consistency`, `RETIRED_CONVENTION`, `instructionalStrings`) hit **0** across all three, while the positive control `ANONYMOUS_SESSION_REFUSAL_STATUS` hits `dist/index.js` and `dist/index.mjs`. Across the 70 published packages, **0** name `scripts` in `files[]` and exactly one names `src` (`packages/spec`, which this diff does not touch); `turbo.json` is named by none. Positive control: all 70 name `dist`. ## Acceptance notes - `noted, not filed:` the revoke leaves one `sys_session` row behind — the deliberate tombstone `session-tombstone.ts` writes, not a leak. Named here because the probe printed the count and a later reader would otherwise have to re-derive it. - `noted, not filed:` `check:platform-checklist` is not a per-PR gate. That is a recorded maintainer decision, stated in the script's own header, and it is the reason this card's pin had to live in a package rather than beside the docs — not a gap to file. Successor: whoever next moves a checklist invariant. - `noted, not filed:` five further `get-session` statements in this file describe behaviour without teaching the convention and stay true under both wire answers, so they were left alone rather than swept along. Successor: whoever next re-points this item. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 --- _Generated by [Claude Code](https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9be2b59 commit f1c9bb3

5 files changed

Lines changed: 265 additions & 11 deletions

File tree

‎docs/qa/platform-checklist/areas/identity-auth.json‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@
370370
"title": "Admin user-lifecycle operations (ban/unban, set-password, impersonate, create/set-role/remove, revoke-sessions) enforce, persist, and stay closed to non-admins",
371371
"since": "v16",
372372
"status": "active",
373-
"revision": 5,
373+
"revision": 6,
374374
"priority": "P1",
375375
"surface": "mixed",
376376
"personas": ["platform admin", "target user", "non-admin forger"],
@@ -393,7 +393,7 @@
393393
"attempt to sign in as the banned user (POST /api/v1/auth/sign-in/email) and capture the refusal",
394394
"unban, then verify the same sign-in now succeeds",
395395
"set the target's password via the admin set-password (out-of-band recovery); verify the NEW password signs in and the OLD one is refused",
396-
"sign the target in to establish a LIVE session, then as admin POST /api/v1/auth/admin/revoke-user-sessions for that target; the target's very next PROTECTED authed request (e.g. GET /api/v1/data/<an object the target could read a moment ago>) must be refused mid-flight — the kill is immediate, not deferred to expiry. Do NOT score this off get-session's status code: better-auth answers get-session with HTTP 200 and a JSON null body when the session is gone (session-of-record.test.ts), so a status-only assertion passes against a fully revoked session",
396+
"sign the target in to establish a LIVE session, then as admin POST /api/v1/auth/admin/revoke-user-sessions for that target; the target's very next PROTECTED authed request (e.g. GET /api/v1/data/<an object the target could read a moment ago>) must be refused mid-flight — the kill is immediate, not deferred to expiry. Do NOT score this off get-session's status code — and since #17881 that is no longer because it cannot discriminate: an anonymous OR revoked get-session answers 401 with the ADR-0112 refusal envelope (code UNAUTHENTICATED), converted from better-auth's bare 200+null by packages/plugins/plugin-auth/src/anonymous-session-refusal.ts, while a live session still answers 200 with { user, session }. It is not the oracle because this clause's contract is an immediate kill on a PROTECTED request, and one auth-route seam's status is not proof the AUTHORIZATION layer refuses the target's in-flight protected reads",
397397
"change the target's role via POST /api/v1/auth/admin/set-role and prove the change bites: an operation the new role gates flips outcome (e.g. promote → an admin-only read now 2xx; demote → it now 403)",
398398
"impersonate the target from the admin surface; verify via the API that the impersonation session carries impersonated_by, and screenshot the console's impersonation state; stop impersonating and verify the admin's own session is restored",
399399
"POST /api/v1/auth/admin/remove-user for a throwaway user that OWNS at least one showcase row (task/note), then read that owned row back: its owner_id is cleared to null (engine referential-integrity FK clear), the row itself survives, and the owner-anchor transfer guard did NOT veto the cascade (#3023/#3048)",
@@ -428,7 +428,7 @@
428428
{
429429
"clause": "revoke-user-sessions kills the target's LIVE session mid-flight: a PROTECTED authed request that succeeded a moment earlier is refused immediately after the admin revoke — not at token expiry",
430430
"oracle": "api",
431-
"verify": "ORACLE = a protected authed request as the target (a data read the target was entitled to), 2xx before the revoke and refused on the very next call after it. get-session is NOT the oracle for this clause: better-auth's no-session convention is HTTP 200 with a JSON null body, so a 401 expectation misdescribes a correct implementation and a status-only assertion passes against a revoked session (packages/plugins/plugin-auth/src/session-of-record.test.ts). If get-session is captured at all, read its BODY (user null) as corroboration only",
431+
"verify": "ORACLE = a protected authed request as the target (a data read the target was entitled to), 2xx before the revoke and refused on the very next call after it. get-session is NOT the oracle for this clause. Since #17881 an anonymous OR revoked get-session answers 401 UNAUTHENTICATED in the ADR-0112 refusal envelope (packages/plugins/plugin-auth/src/anonymous-session-refusal.ts) while a live session still answers 200 with { user, session }, so a 401 there is the CORRECT answer and must never be filed as the defect. It stays off the oracle seat for a reason that does not move with the wire: this clause's contract is an immediate kill on a PROTECTED request, and a refusal emitted by that one auth-route seam is not proof the authorization layer refuses the target's in-flight protected reads. If get-session is captured at all it is corroboration only — read its BODY as well as its status, the discipline packages/plugins/plugin-auth/src/session-of-record.test.ts states: a session that is gone is proven gone by the absence of a user, not by a status the file would then be trusting a single seam to keep emitting",
432432
"evidence": "the before/after protected-request pair (plus the get-session body, if captured)"
433433
},
434434
{
@@ -465,7 +465,7 @@
465465
"negative": [
466466
"a non-admin forged admin operation succeeding is a FAIL of the highest severity — apply RUNNER rule 7 (independent re-derivation) before acting on it",
467467
"a ban that hides the user in the UI while their sign-in still works is a FAIL — the sign-in refusal is the enforcement, not the list filter",
468-
"revoke-user-sessions that only stops NEW logins while the existing live session keeps answering a PROTECTED request is a FAIL — the contract is an immediate kill. A get-session that answers 200 after the revoke is NOT that failure (better-auth's no-session convention is 200-with-null-body); filing it as one is the false positive run #7663 corrected — read the body, or better, re-drive a protected request",
468+
"revoke-user-sessions that only stops NEW logins while the existing live session keeps answering a PROTECTED request is a FAIL — the contract is an immediate kill. Two scoring errors to avoid on get-session, and since #17881 inverted the wire answer they point OPPOSITE ways. (a) A 401 UNAUTHENTICATED there after the revoke is the CORRECT answer, not the failure — filing it as one is the false positive run #7663 corrected, and the remedy that mis-filing invites is undoing anonymous-session-refusal.ts, an auth tightening. (b) A 200 carrying a user after the revoke is no longer the benign no-session convention this line once described; the live-session answer is the only 200 left, so it IS a real signal the kill did not land. Neither is this clause's oracle: read the body, or better, re-drive a protected request",
469469
"remove-user aborting because the owner-anchor guard vetoed the owner_id-null cascade (instead of exempting the engine FK clear) is the #3023 regression returned — FAIL; equally, a create-user that applies the GENERATED password when an explicit one was supplied is the #3031 failure — FAIL"
470470
],
471471
"automated": {
@@ -480,7 +480,7 @@
480480
"packages/plugins/plugin-security/src/security-plugin.ts#__referentialFieldClear (§A5 #3023 EXEMPTION: __referentialFieldClear owner_id-null cascade rides a server-derived context, the owner-anchor guard must not veto it) + security-plugin.test.ts '[#3023] … engine referential FK clear … is exempt'",
481481
"packages/spec/src/kernel/public-auth-features.ts#sys_user (admin flag gates the sys_user lifecycle actions; SCIM forces it on — ADR-0134)",
482482
"packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts",
483-
"packages/plugins/plugin-auth/src/session-of-record.test.ts#body (better-auth answers /get-session with HTTP 200 + a JSON null body when the session is gone — NOT 401; a status-only assertion would pass against a fully revoked session)"
483+
"packages/plugins/plugin-auth/src/anonymous-session-refusal.ts#ANONYMOUS_SESSION_REFUSAL_STATUS (since #17881 an anonymous or revoked /get-session answers 401 with the ADR-0112 refusal envelope, code UNAUTHENTICATED derived from that status; a live session still answers 200 with { user, session }. Both legs are driven end to end in packages/plugins/plugin-auth/src/anonymous-session-refusal.test.ts, and the body-not-status discipline for a revoke is kept in packages/plugins/plugin-auth/src/session-of-record.test.ts)"
484484
],
485485
"history": [
486486
{ "revision": 1, "date": "2026-08-07", "change": "new item: admin lifecycle operations with persistence, enforcement, attribution and both-sides gate checks", "ref": "claude/platform-test-checklist-ocwugl" },
@@ -492,6 +492,12 @@
492492
"date": "2026-08-24",
493493
"change": "the ADMIN side pinned, and three clauses re-graded from 'blocked on a product decision' to 'ruled'. Revision 4 recorded that ban/unban/set-role/remove-user/impersonate/revoke-user-session(s)/list-users/get-user/list-user-sessions/update-user all refuse the platform admin; that is no longer true. Re-measured on main: #9970 re-mounted ban-user/unban-user with the ADR-0068 gate and #10352 (consolidated onto hasPlatformAdminStanding by #11686) re-authorized impersonate-user, so those answer 200 and clauses 0 and 6 are now pinned with their stored effects read back. The remaining EIGHT routes refuse the platform admin BY DESIGN — #9969 closed not_planned for seven consumer-less routes, #9968 ruled B for set-role — so clauses 3, 4 and 5 are not coverage debt but clauses whose operation the maintainer declined to build. The new pin asserts both sides with SEPARATE instruments, behind an admin-identity control proving the subject is a platform admin who does NOT carry the legacy role scalar",
494494
"ref": "#9482"
495+
},
496+
{
497+
"revision": 6,
498+
"date": "2026-09-17",
499+
"change": "CORRECTION, and a correction OF a correction. Revision 3 was RIGHT when it was written: in August 2026 better-auth's bare no-session answer really was HTTP 200 with a JSON null body, so the literal 401 expectation it removed really did misdescribe a correct implementation. Its cited authority was then INVERTED underneath it — #17238 ruled the answer, #17881 landed refuseAnonymousSession (packages/plugins/plugin-auth/src/anonymous-session-refusal.ts), and packages/plugins/plugin-auth/src/session-of-record.test.ts now pins the very 401 it was cited for denying. Revision 3 is therefore left standing and UNEDITED: it records what the judgement rested on at the time, which is the whole point of keeping a revision history. This revision re-points the four instructional sites (the step, clause 5's verify, the third negative and the evidence row) at today's live wire answer, measured on this checkout rather than relayed: a sign-up read 200 with { user, session }, a revoke-sessions then read 401 with {\"success\":false,\"error\":{\"code\":\"UNAUTHENTICATED\"}}, and a fresh manager's new session still read 200 as the control — so the refusal covers the REVOKED path and not only the never-signed-in one, because the seam keys on the answer shape (/get-session + 200 + a body that is exactly null) rather than on how the caller became anonymous. The SUBSTANTIVE advice is unchanged and now rests on something that does not move with the wire: get-session is still not this clause's oracle, because the contract is an immediate kill on a PROTECTED request and one auth-route seam's status is not proof the authorization layer refuses one. Consistency between this file and the runtime's refusal envelope is now pinned by packages/plugins/plugin-auth/src/checklist-refusal-envelope-consistency.test.ts, which reads the status and code from the runtime constants rather than from a copy, so the next behaviour change reds instead of replaying this drift",
500+
"ref": "#18650"
495501
}
496502
]
497503
},

0 commit comments

Comments
 (0)