Skip to content

fix(plugin-auth)!: an anonymous get-session is refused with the declared 401 envelope, not answered 200 null - #17881

Merged
os-project-manager merged 6 commits into
mainfrom
claude/issue-17238-anonymous-session-401
Sep 12, 2026
Merged

os-project-manager merged 6 commits into
mainfrom
claude/issue-17238-anonymous-session-401

Conversation

@claude

@claude claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #17238

Ruled B by the director seat (decision batch #117 item 4, comment 5642584682). Maintainer, verbatim: 「17238 B」. The seat had recommended A and was corrected, verbatim: 「我改推荐的都是从平台长远合理性角度考虑的」, under the charter rule 「spec 与代码不一致默认改代码,改协议单独立卡非选项」.

GET /api/v1/auth/get-session now answers an anonymous caller the platform's standard ADR-0112 failure envelope at HTTP 401, instead of 200 + the literal JSON null. SessionResponseSchema is untouched.

The defect, and why the code is what moves

ObjectStackClient.auth.me() declares Promise<SessionResponse>, and SessionResponseSchema requires data.session and data.user — so no value of that type means "nobody is signed in", and the most ordinary call a logged-out caller can make resolved to something outside the method's own declared type.

⇒ Every value auth.me() returns is now inside SessionResponse. The anonymous case is delivered as a rejection instead: the SDK's fetch wrapper already throws on every non-2xx, so the caller gets an error carrying code: 'UNAUTHENTICATED' and httpStatus: 401. No runtime edit in packages/client was needed — that fell out of the measurement rather than being assumed.

Measured, on the merge base and after

Driven through a real AuthManager (better-auth, default plugin set, in-memory engine) via handleRequest:

                                          BEFORE                      AFTER
GET  /get-session  (no credential)        200  application/json null  401  {"success":false,"error":{"code":"UNAUTHENTICATED","message":"Sign in first"}}
GET  /get-session  (unknown cookie)       200  application/json null  401  (same)
GET  /get-session  (signed in)            200  {user,session}         200  {user,session}   ← unchanged, asserted by identity
POST /get-session  (no credential)        404  (no content-type) ''   404  (unchanged)

The POST row is why the rule is keyed on the 200-null answer and not on the path alone: a path-only rule would have converted that 404 into a 401 and claimed a route better-auth does not serve.

Where it lives

packages/plugins/plugin-auth/src/anonymous-session-refusal.ts, wired into AuthManager.handleRequest beside the existing /admin/ envelope normalization — the one seam every vendor route passes through. ⚠️ It differs from that sibling in exactly one way, stated in both files: the /admin/ seam is forbidden to move admission, and this one is the admission move.

Three narrowings hold everything else still, each pinned by a toBe identity assertion:

  1. The /get-session endpoint exactly — not a prefix, not the session family.
  2. Status 200 only — anything better-auth already refuses, redirects or fails reaches the caller untouched.
  3. A body that is exactly the literal null — compared as text, so a body that merely parses to something falsy (0, "", false, {}, []) is left alone.

⛔ better-auth's JS API is untouched. auth.api.getSession() still returns null for an anonymous caller, so execution-context resolution, the platform-admin gates and the SSO bridges behave exactly as before. Only the wire moves. There is a negative-control test for precisely this.

No error code is minted. UNAUTHENTICATED is an existing StandardErrorCode member (packages/spec/src/api/errors.zod.ts:69), derived from the status through ADR-0112's own standardErrorCodeForHttpStatus map — so there is no string literal here to drift, ERROR_CODE_LEDGER is unchanged, and packages/spec is not touched by this PR.

Client consequences, enumerated (ruling note 3)

Note 3 requires these be listed here rather than discovered downstream. The two readings behind the list are posted on the card (comment 5647024737).

consumer effect
ObjectStackClient.auth.me() rejects for an anonymous caller instead of resolving null. No source change — this.fetch already throws on non-2xx
packages/client/src/auth-get-session-envelope.test.ts block ③ flipped, reversal named in place. It drives the real server, so it went red the moment the 401 landed
packages/client/src/client.test.ts re-pointed by hand. ⚠️ Fetch-mock driven, so it would have stayed green while pinning a wire shape no server produces any more — the more dangerous of the two, because it announces nothing
packages/cli/src/commands/whoami.ts behaviour improves with no code change: a stored-but-invalid token now prints the refusal and exits 1 instead of printing an empty Current Session. requireAuth(token) already exits earlier when no token is stored
packages/plugins/plugin-auth/src/register-sso-provider.ts unaffected. It guards if (!resp.ok) return undefined; — before: ok, body null, org undefined; after: not ok. Same value, best-effort by its own docblock
console / ../objectui no consumer found; nothing branches on the null body

packages/client was originally fenced out of this card as domain:cli's serial hot file. The dispatching seat lifted the fence for those two test files only, having measured that no open PR holds any packages/client path and that this PR cannot go green without the block ③ flip. ⛔ No runtime edit was made in that package.

Ablation — the negative pins are proven able to fail

This lane requires every negative pin carry one. The old 200 null was put back by neutering the guard in anonymous-session-refusal.ts, plugin-auth was rebuilt, and the marker was proven live in the artifact the client consumes before anything was read:

SRC ablation added : 1  (expect 1)
SRC blob b7e8f76e2915cae2581a158384822cd8367bcfd3 != HEAD ba3aeb861171543e87d4841766bc84e6a0fe346e -> MUTATION ON DISK
✓ dist/: marker present in 2 built files -- the ablation is live in the artifact the suite consumes.

⚠️ The rebuild is load-bearing and the first attempt caught me: packages/client resolves @objectstack/plugin-auth through dist, so a source-only mutation would have left block ③ green and certified a test that could never fail. A second trap the preflight caught: the first marker was written inside a /* */ comment, which tsup strips — it never reached dist at all.

Ablated, server suite (4 of 11 red):

FAIL src/anonymous-session-refusal.test.ts > ⭐ answers 401 AND the registered code — both, not either
AssertionError: expected 200 to be 401

FAIL src/anonymous-session-refusal.test.ts > the refusal body is a conforming ADR-0112 envelope, exhaustively
AssertionError: expected [ Array(1) ] to deeply equal []
+ [ "body is null, not an envelope object" ]

Ablated, client block ③ (2 red) — and this is the card's own loop closing:

FAIL src/auth-get-session-envelope.test.ts > ③ anonymous is REFUSED > rejects with the declared refusal instead of resolving outside its type
AssertionError: promise resolved "null" instead of rejecting

The card records that block ③ "is green under [the ablation] and CANNOT redden on it … it pins the residue, not the fix." It reddens now. That is the difference between a pin that records a gap and a pin that protects a fix.

Restored, rebuilt, and re-run green: ✓ dist/: marker absent from all 12 built files, ✓ tree: working tree clean against HEAD, 11/11 and 223/223.

Verification

All at b8c2b00f8, the final commit.

run result
plugin-auth full suite 2283 passed (108 files)
plugin-auth typecheck (incl. check:test-typecheck) pass — test layer compiles
client — the 5 affected files 250 passed
dispatch-gates --commands → all run → --ran 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN, all exit 0 (a derived zero: every command recorded its exit code)
eslint, narrowed 11 of 6657 files, 0 errors / 0 warnings

The eslint narrowing is a measurement, not a skip: ① the population (6657) is eslint's own enumeration, ② the 11 is the --format json count, ③ this repo runs one eslint.config.mjs which never enables type-aware linting for any file (its own header, line 328, measured there with a positive control) ⇒ no verdict on an untouched file can have moved.

One test outside the new file needed re-pointing: auth-manager.optional-plugin-isolation.test.ts's "get-session also survives a degraded optional plugin" used the anonymous 200 as its vehicle for "the route reached better-auth and came back". The claim is unchanged; the assertion now pins 401 and error.code, plus the recorded degradation, so it cannot pass on a 401 produced for some other reason.

Changeset

minor, declared BREAKING, with the observable behaviour change stated in as many words (ruling note 6). Its ADR-0087 disposition is not-required (no-migration-prescription): this retires no metadata surface, so objectstack migrate meta has nothing to rewrite and no ledger entry can be written for it — what moves is an HTTP status plus an SDK method's promise contract. The same disposition and reasoning a previous HTTP-status unification used.

⚠️ The first draft carried a FROM -> TO table, which that gate reads as a migration prescription — correctly, by its own rules — and a prescription refuses this category. The consumer guidance is kept, restated as prose plus a try/catch example. ⛔ The bang was not dropped to get around it; the gate names that move as the one it exists to stop.

Acceptance notes

Noted, not filed — all three are the same class (a pin or a docblock describing a wire shape the server no longer serves), and none is in this card's face:

Two stale card references found while re-verifying the ruling's premises: #14313 (named in the issue body as the auth.* family card) returns HTTP 404 — it does not exist in this repository; control cards read with the same credential in the same batch resolved normally. And #17234 as above.

Scope declaration

Clause-②: no, and the delivery is consistent with it: the accept set narrows (a 401 replaces a 200) rather than widening, UNAUTHENTICATED is reused rather than minted, packages/spec is not touched, and the new module is package-internal — it is not re-exported from index.ts and appears zero times in the published .d.ts.

⚠️ This PR is not independently landable and is deliberately left as a draft. Block ③ drives the real server, so server and client halves have to land together; they are in this one PR for that reason.


🤖 Generated with Claude Code

https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj


Generated by Claude Code

…401 envelope

`GET /api/v1/auth/get-session` answered an unauthenticated caller with HTTP 200
and the literal JSON `null` — a value no `SessionResponse` can express, so
`ObjectStackClient.auth.me()` resolved outside its own declared return type on
the most ordinary call a logged-out caller makes.

The route now answers the platform's standard ADR-0112 failure envelope:
`401 { success: false, error: { code: 'UNAUTHENTICATED', message } }`.
`SessionResponseSchema` is untouched — the implementation is corrected to the
published contract, not the other way round.

The refusal lives at the one seam every vendor route passes through, beside the
`/admin/` envelope normalization, and is held to three narrowings: the
`/get-session` endpoint exactly, status 200 only, and a body that is exactly the
literal `null`. The signed-in answer and better-auth's JS API are untouched.

No error code is minted: `UNAUTHENTICATED` is an existing `StandardErrorCode`
member, derived from the status through ADR-0112's own map.

Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <[email protected]>
…e 401 refusal

`get-session also survives a degraded optional plugin` used the anonymous
`200` as its vehicle for "the route reached better-auth and came back". The
request is anonymous, so that vehicle is now the declared 401 envelope.

The claim under test is unchanged. The assertion is also strengthened rather
than merely moved: it now pins `error.code === 'UNAUTHENTICATED'` and the
recorded degradation, so it cannot pass on a 401 produced for some other
reason by a degraded instance.

Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <[email protected]>
…DR-0087 disposition

The first draft carried a `FROM -> TO` rewrite table, which the ADR-0087 gate
reads as a migration prescription — correctly, by its own rules — and a
prescription refuses the `no-migration-prescription` category.

The category is nonetheless the right one: this change retires no metadata
surface, so `objectstack migrate meta` has nothing to rewrite and no ledger
entry can be written for it. What moves is an HTTP status plus an SDK method's
promise contract, and the changeset is the only channel that reaches those
consumers.

So the consumer guidance stays — restated as prose plus a `try`/`catch` example,
the shape the automation-trigger status unification used for the same class of
change — and the disposition is recorded in writing.

Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <[email protected]>
…limit contracts

`check:engine-double-contract` and `check:objectql-double-limit` both read this
new double. Its `update()` now routes through `assertEngineUpdateDispatch` and
reads the id back off the predicate, so the fake cannot be looser than the
engine it stands in for; its `find()` applies `limit`/`offset` by PRESENCE, so
`limit: 0` means no rows instead of every row.

Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <[email protected]>
…usal

Block 3 of `auth-get-session-envelope.test.ts` pinned the anonymous `null`
passing through. Its own comment recorded what it was: a pin of the RESIDUE,
green with the lift and without it, unable to redden on the lift's ablation.
The producer now closes that residue, so the block asserts the refusal instead
and names the reversal in place, with a control that fails a `me()` which
silently starts resolving a falsy session again.

`client.test.ts`'s twin is fetch-MOCK driven and so would have stayed GREEN
while pinning a wire shape no server produces any more — the more dangerous of
the two, since it announces nothing. Re-pointed by hand.

No runtime change in this package: `this.fetch` already throws on every non-2xx,
so the anonymous answer arrives as a rejection carrying `code` and `httpStatus`
with no edit to `auth.me()` itself.

Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <[email protected]>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 12, 2026
@github-actions

github-actions Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 8 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via auth.me (sdk, the route ledger binds it to GET /api/v1/auth/get-session))
  • content/docs/kernel/contracts/auth-service.mdx (via handleRequest (symbol, a method of class AuthManager))
  • content/docs/permissions/authentication.mdx (via handleRequest (symbol, a method of class AuthManager), auth.me (sdk, the route ledger binds it to GET /api/v1/auth/get-session), /api/v1/auth/get-session (route, a path literal on a changed line))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-4.mdx (via /api/v1/auth/get-session (route, a path literal on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: 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 — 14 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 2266438ce0ba039634f9dcec06db7be12c0d58eepackageMentionDocs.

Which tree this was computed on

This run read content/docs from 1d0dfb0957393d795bb013194a7960a833263afe — the merge of head a249cc69081c4a0f7c17b9acbc2224a4a5dfc5e0 into base 2266438ce0ba039634f9dcec06db7be12c0d58ee, 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 1d0dfb0957393d795bb013194a7960a833263afe && git checkout 1d0dfb0957393d795bb013194a7960a833263afe
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2266438ce0ba039634f9dcec06db7be12c0d58ee a249cc69081c4a0f7c17b9acbc2224a4a5dfc5e0 && git checkout -B drift-repro 2266438ce0ba039634f9dcec06db7be12c0d58ee && git merge --no-ff a249cc69081c4a0f7c17b9acbc2224a4a5dfc5e0

node scripts/docs-audit/affected-docs.mjs --json 2266438ce0ba039634f9dcec06db7be12c0d58ee

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2266438ce0ba039634f9dcec06db7be12c0d58ee → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-project-manager
os-project-manager marked this pull request as ready for review September 12, 2026 19:14
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 12, 2026
Merged via the queue into main with commit 374d9d3 Sep 12, 2026
36 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-17238-anonymous-session-401 branch September 12, 2026 19:42
baozhoutao pushed a commit that referenced this pull request Sep 14, 2026
…us get-session

The packed-tarball release-candidate smoke asserted that an anonymous
`GET /api/v1/auth/get-session` answers 200. Since #17881 the platform
deliberately answers 401 with the ADR-0112 envelope
(`code: UNAUTHENTICATED`), ruled by the director seat in decision batch
 #117 item 4 on card #17238: better-auth's `200` + literal JSON `null` is a
value no `SessionResponse` can express, so `auth.me()` resolved outside its
own declared type; the code moved and the published schema did not.

The smoke was therefore asserting a contract the product deliberately stopped
serving. This re-pins the probe to what the code declares — the same move
 #14000 made for `SELF_REGISTRATION_CLOSED` — and pairs the status with the
envelope `code`, so a 401 from an origin check, a rate limiter or a later
guard cannot keep the probe green while measuring nothing.

The signed-in `get-session` probe is untouched and still asserts 200: the
refusal seam converts only a 200 whose body is exactly `null`.

Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
Co-authored-by: Claude <[email protected]>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…us get-session (objectstack-ai#18140)

Closes objectstack-ai#18079

`Clause-②: no` — this neither loosens an accept set nor widens a
published contract surface. It tightens one CI assertion onto the
contract the code already declares.

## The question this card asked, and the answer

objectstack-ai#18079 named two hypotheses and asserted neither: either the anonymous
`GET /auth/get-session` behaviour regressed (fix the auth surface), or
the smoke's expectation is stale (fix the probe). **The second is true,
and it is established from the tree rather than inferred.**

`packages/plugins/plugin-auth/src/anonymous-session-refusal.ts` exists
for exactly this, and its header records the ruling:

> Director seat, decision batch objectstack-ai#117 item 4 (2026-09-12), maintainer
verbatim 「17238 B」 — the server answers the platform's standard ADR-0112
failure envelope with HTTP 401 instead of `200` + `null`, and
`SessionResponseSchema` is UNTOUCHED. The charter rule quoted in that
ruling: 「spec 与代码不一致默认改代码,改协议单独立卡非选项」.

`AuthManager.handleRequest` calls it on the one seam every vendor route
passes through, and the call site says in as many words that this one
**is** the admission move:

> ⚠️ NOT the same kind of change: that one is forbidden to move
admission and this one IS the admission move (`200` -> `401`).

So the 401 is the declared contract. The smoke was asserting a shape the
platform deliberately stopped serving. ⇒ **The auth surface is not
touched by this PR.**

Three independent legs agree on the exact envelope, so the assertion is
pinned to a measurement and not to a guess:

| leg | source | reading |
|---|---|---|
| the ruling | `anonymous-session-refusal.ts` header | 401 + ADR-0112
envelope |
| the wire | run 34774426350 / 34728125950 job log |
`{"success":false,"error":{"code":"UNAUTHENTICATED","message":"Sign in
first"}}` |
| the spec | `packages/spec/src/api/errors.zod.ts:174` | `401:
'UNAUTHENTICATED'` |

## Bisect — the true first failure, not the observed window

The card cautioned that the observed window (back to `b06b2db5c4`) is
not the start, because the runs on `a83dbb6124` and `6d647858b7` read
`no-run` and `cancelled`. Correct — and the real boundary is a day
earlier and is **not a `main` commit at all**.

- Causal commit on `main`: **`374d9d3afa`** — `fix(plugin-auth)!: an
anonymous get-session is refused with the declared 401 envelope, not
answered 200 null (objectstack-ai#17881)`, 2026-09-12T19:15:14Z. `git log
--diff-filter=A` names it as the commit that *adds* the refusal module;
the shallow graft boundary here is `ca0a1f83d6` (2026-07-29), far older,
so that add is genuine and not a graft artifact.
- Last green RC smoke: run `34726616436`, 2026-09-12T23:54:27Z, status
posted to RC head `a1effc8e44` (`chore: version packages`,
2026-09-12T18:12:26Z).
- First red RC smoke: run `34728125950`, 2026-09-13T00:30:11Z, job
`103645853837`, status posted to RC head `e612087feb` (`chore: version
packages`, 2026-09-13T00:29:49Z). Its log carries the same assertion and
the byte-identical body as the card's evidence run.

Those two runs carry the **same** `main` head sha (`a9c6477904`) and
opposite verdicts, which is the card's "NOT main-red" point showing up
as data: the smoke tests the release candidate, not the commit the check
attaches to. The decisive probe is therefore on content, not on timing —

```
                                anonymous-session-refusal.ts   platform-admin-gate.ts   (nonsense path)
a1effc8  LAST GREEN RC              ABSENT                        PRESENT                 ABSENT
e612087  FIRST RED RC               PRESENT                       PRESENT                 ABSENT
```

The firing control is present in both trees (the probe reaches them) and
the nonsense control is absent in both (the probe can say no).
`374d9d3afa` (19:15:14Z) falls inside the interval between those two
`chore: version packages` commits (18:12:26Z → 00:29:49Z), so the two
readings cross-validate.

## What changed

One file, `scripts/publish-smoke.sh`:

1. The anonymous probe now expects **401** and is paired with
`assert_body '.error.code == "UNAUTHENTICATED"'`. The status alone would
be satisfied by an origin check, a rate limiter or any later guard while
measuring nothing — the same reasoning the `SELF_REGISTRATION_CLOSED`
probe below it is already written under, and what this file's header
means by *"every assertion here is an HTTP status plus a `code` this
repo owns and publishes"*.
2. A comment pinning **why**, naming objectstack-ai#17881, objectstack-ai#17238 and the ruling, so
the next reader does not "fix" it back — the card asked for this
explicitly.
3. The header's declared-contract table updated to match.

The **signed-in** probe is deliberately untouched and still asserts 200:
the refusal seam converts only a 200 whose body is exactly `null`, so
that answer is byte-identical to before.

This is the objectstack-ai#14000 move repeated — that card re-pinned this same script
to the declared contract for `SELF_REGISTRATION_CLOSED` rather than
touching auth runtime code, and left a standing ⛔ against relaxing an
assertion *back toward 200*. This change runs the other way (200 → 401),
which is the direction that prohibition protects.

## Verification

- `bash -n scripts/publish-smoke.sh` → exit 0.
- The new `jq` filter, tested against the byte-exact body from job
`103645853837` and two controls: observed body → exit 0; a 401 carrying
`INVALID_ORIGIN` → exit 1; the old `null` → exit 1. So the assertion
accepts the real refusal and rejects both a foreign guard and the
retired shape.
- The full derived gate family for this diff — 26 commands from `node
scripts/pm/dispatch-gates.mjs --commands` — all exit 0, including
`check:bash32-floor` and `check:nul-bytes`.
- Control-character self-scan over the edited file: no matches.
- ⚠️ The smoke itself is **NOT MEASURED** locally. It packs ~70
tarballs, installs and builds a project outside the workspace and boots
a dev server; that is far past this container's foreground budget and
its shared-verification discipline. The real verdict is the next
`publish-smoke / packed-tarballs` status on the release-candidate head.

## Reverse-read, both directions

**Which currently-true sentence does this make false?** Inside this
file, the header's `→ 200 (anonymous)` row — updated in the same diff,
so the file does not contradict itself. Outside it, none: I grepped
`get-session` across `packages`, `scripts`, `docs`, `content` and
`.github` and no other statement depends on the smoke asserting 200.

**Which currently-false sentence does it make true?** Two. The file's
own claim that it asserts *"the DECLARED first-run contract"* was false
for this row and is now true. And the status text `Fresh install of the
release candidate: auth + CRUD green`, which this gate has been unable
to post since 2026-09-13T00:30Z, becomes reachable again — that is the
release-blocking half.

**Zero results reported as such:** no other consumer of the smoke's
expectation exists; no docs page restates it.

## Noted separately

The same grep found that objectstack-ai#17881 moved the wire answer but left the
**client SDK** still documenting `-> 200 null`
(`packages/client/src/index.ts:1474`) and a test double still modelling
it. That is `domain:services`, not this lane, and it is filed on its own
card — see objectstack-ai#18139. It is not addressed here.

## Merge channel

⚠️ The dispatch brief expected this fix to land in
`.github/workflows/publish-smoke.yml` and warned that such a PR cannot
be armed by the PM seat (HTTP 422, token lacks `workflows`). **That
premise does not hold** — the probe lives in `scripts/publish-smoke.sh`,
the workflow's driver script, and the workflow YAML contains no
assertion at all. This diff touches no path under `.github/workflows/`,
so that caveat does not apply to it. It also touches no governed surface
under Prime Directive objectstack-ai#14.

No changeset: the changed file is a CI driver shipped by nothing.
Measured rather than assumed — the root package is `private: true`, and
of the 70 package manifests declaring `files[]`, zero name this path.
The `skip-changeset` label carries that, not this sentence.

---
_Generated by [Claude
Code](https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU)_

Co-authored-by: Claude <[email protected]>
os-support-ai pushed a commit that referenced this pull request Sep 17, 2026
… the shipped 401

#17881 (374d9d3) moved the anonymous `GET /api/v1/auth/get-session` wire
answer from `200` + the literal `null` to the declared ADR-0112 refusal
envelope (401, `UNAUTHENTICATED`). Three present-tense statements in the SDK
and one test double still described the retired shape.

- `normalizeSessionResponse`'s `/auth/*` wire table no longer lists the
  anonymous `200 null` row among the bodies the helper is handed; the current
  answer is stated separately, with the reason it never reaches the lift.
- The closing `!body`-guard paragraph no longer claims that guard carries the
  anonymous answer.
- `auth.me()`'s docblock says the anonymous call REJECTS rather than resolving
  outside the declared type.
- `organization-get-active-member-addressing.test.ts`: the `signedIn: false`
  leg modelled an answer the runtime stopped producing, so it could not fail
  for the reason it existed. The double now serves the refusal envelope and
  case ⑥ pins that the refusal arrives on request ONE, discriminating on the
  code (`UNAUTHENTICATED` vs `list-members`' `UNAUTHORIZED`).

Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3
Co-authored-by: Claude <[email protected]>
os-support-ai pushed a commit that referenced this pull request Sep 17, 2026
…usal envelope

The five sites in `docs/qa/platform-checklist/areas/identity-auth.json` that
taught better-auth's retired no-session convention (HTTP 200 + a JSON `null`
body) were inverted by #17238/#17881: `refuseAnonymousSession` now converts
that answer into `401` + `UNAUTHENTICATED` in the ADR-0112 refusal envelope.
A runner following the file scored the correct implementation as defective,
and the remedy the negative pointed at was undoing an auth tightening.

Four instructional sites re-pointed (the step, clause 5's verify, the third
negative, the evidence row). `revision 3` is left standing and unedited --
it records what the August judgement rested on -- and a new `revision 6`
states that its cited authority was inverted afterwards.

Measured on this checkout rather than relayed: live session 200 with
{ user, session }; after revoke-sessions, 401 UNAUTHENTICATED; fresh-manager
control still 200. The refusal covers the REVOKED path, not only the
never-signed-in one, because the seam keys on the answer shape.

Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3
Co-authored-by: Claude <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

auth.me() returns the literal null for an anonymous caller, which no value of its declared SessionResponse can express

2 participants