Skip to content

fix(client): the anonymous /get-session statements say 401 UNAUTHENTICATED, and the test double stops modelling 200 null - #18642

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-18139-get-session-wire-shape-statements
Sep 17, 2026
Merged

os-support-ai merged 2 commits into
mainfrom
claude/issue-18139-get-session-wire-shape-statements

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes #18139

#17881 (374d9d3afa) moved the anonymous GET /api/v1/auth/get-session wire answer from 200 + the literal JSON null to the declared ADR-0112 refusal envelope — HTTP 401, code: UNAUTHENTICATED. @objectstack/client reaches the server over the wire, so that is exactly what it sees. This PR pulls the SDK's own statements and one test double back onto it. No behaviour moves: packages/client/src/index.ts changes comments only (verified mechanically — every changed line in that file is inside a comment).

Half 1 — the SDK's statements

Three present-tense statements, none carrying a rev or a date, so none read as history:

  1. The /auth/* wire table on normalizeSessionResponse (located by symbol, not by line). Its anonymous -> 200 null row is removed from the list of bodies the helper is handed — it is not handed that body at all now, because the shared fetch wrapper throws on the non-2xx one frame out. The current answer is stated separately, anchored to the producer.
  2. The closing !body-guard paragraph in the same docblock, which claimed that guard "carries the anonymous answer" and that closing the gap "needs the published return annotation to widen". #17238 ruled the opposite: the producer moved and SessionResponseSchema is untouched. The guard's own code is byte-identical; it is now described as the defensive branch it is.
  3. auth.me()'s docblock, which said the route "serves the literal null at 200 and it is returned as-is". It now says the anonymous call REJECTS, with the code and status a caller catches.

Half 2 — the phantom, which is the half that mattered

Triage's grading is quoted verbatim and is what this half answers:

⭐ 更值钱的是第二半:organization-get-active-member-addressing.test.tssignedIn: false 腿建模了一个运行时已不再产生的答案 ⇒ 它不可能因为它存在的那个理由而失败(AGENTS.md 的 phantom-check 形状)。⛔ 只改 docblock 不算修完这张卡。

The disposition chosen: assert the refusal, not drop the leg — justified from the tree. The file's own header states its charter: "Only the socket is faked — every status, code and row shape below is a transcript line from the drive". Under that charter the anonymous row is not optional, it is stale, so it is re-anchored rather than deleted. Dropping the leg would delete the only coverage that getActiveMember does not invent a client-side diagnostic for an anonymous caller — a property that is still real and is now sharper, because the refusal arrives on request ONE instead of request TWO.

What changed in the fixture:

  • The anonymous /get-session arm serves 401 {"success":false,"error":{"code":"UNAUTHENTICATED","message":"Sign in first"}} — shaped from the producer (refuseAnonymousSession derives the code through standardErrorCodeForHttpStatus(401) and takes the message from PLATFORM_ADMIN_REFUSAL_MESSAGES[401]), not restamped onto a drive that was never re-run. The header says so in as many words; every other row is still the 2026-09-08 drive, untouched.
  • Case ⑥ now asserts code: UNAUTHENTICATED + httpStatus: 401 and pins urls as the WHOLE list [AUTH + '/get-session'], so a silent extra request cannot hide behind a length check.
  • The organisation-route 401 UNAUTHORIZED arm is KEPT although the SDK no longer reaches it, and that is load-bearing: it answers a different code from the /get-session seam, so a regression that swallowed the first refusal and walked on fails on the VALUE, not merely on a request count. Case ⑥ drives it directly as a guard-the-guard, in the file's existing ⑦/⑧ style.

Ablation — the proof it can now fail for the reason it exists

One-off, committed first, mutation proved on disk, restored and proved restored.

  • Mutation: the fixture's anonymous arm reverted to return json(200, null);. On-disk proof: refusal-arm grep count 1 -> 0, retired-arm grep count 0 -> 1, blob 48459424a (HEAD) vs 19eb96dcb (mutated) — not a no-op.
  • RED leg: Tests 1 failed | 7 passed (8)⑥ ... AssertionError: expected 'UNAUTHORIZED' to be 'UNAUTHENTICATED'. It fails on the CODE, which is the discrimination this design is for.
  • Restore leg: git checkout HEAD -- path under an EXIT INT TERM trap; restored blob 48459424a equals the HEAD blob, git diff HEAD empty, git status --porcelain empty.
  • GREEN leg: Test Files 1 passed (1) · Tests 8 passed (8).

The premise the dispatch asked me to falsify — FALSIFIED

The card's dedupe was a recent-window search and said so. Swept repo-wide for get-session, 200 null, "literal null" and "literal JSON null" with controls (a token known present returned 8 hits; a nonsense token returned 0 at exit 1). The :1474 table was not the only present-tense statement left. Six more were found; three are fixed here (the two above plus the guard paragraph) and three are listed in Acceptance notes because they are out of this PR's face.

The card's declared-unmeasured item — now MEASURED

The card wrote: "Whether auth.me()'s rejection path is covered anywhere today. I did not measure it." Measured: it IS covered, twice. packages/client/src/client.test.ts carries "me() rejects the anonymous refusal rather than resolving outside its type" asserting code: UNAUTHENTICATED / httpStatus: 401, and packages/client/src/auth-get-session-envelope.test.ts block ③ ("anonymous is REFUSED") carries the real-server twin. Both were reversed by #17238 and say so in their own comments. Nothing in this PR depends on that coverage; the reading is reported because the card asked for it.

Clause-②

Clause-②: no. Three readings, from the measured diff:

  • New exports: none. packages/client/src/index.ts changes comments only; the test file and the changeset export nothing. The package's public surface is byte-identical.
  • ERROR_CODE_LEDGER / StandardErrorCode: untouched — the diff names neither, at zero occurrences. UNAUTHENTICATED is an existing STANDARD member that #17881 already derives via standardErrorCodeForHttpStatus; nothing is minted here.
  • Accept-set changes: none. No .zod.ts in the diff (zero paths), no schema key moves, nothing previously admitted is refused and nothing new is admitted.

The expected direction held: this is a pull-back — the runtime already answers 401 and the SDK's self-description was lagging. Nothing widens.

Tests

All readings taken in a dedicated worktree cut from origin/main at 681ebe424; the union below was re-run at final HEAD 81a05648d.

  • pnpm --filter @objectstack/client typecheck — exit 0. Note the two halves: the root tsc --noEmit program does NOT include the edited test file (--listFiles count 0, with src/index.ts at 1 as the lit control); the test layer is covered by the script's second half, check:test-typecheck, which reports "@objectstack/client's test layer compiles under packages/client/tsconfig.test.json; 0 file(s) / 0 error(s)".
  • pnpm --filter @objectstack/client testTest Files 43 passed (43) · Tests 524 passed (524).
  • pnpm --filter '@objectstack/client^...' build — exit 0 (the dependency closure; the first test run was a false red from an unbuilt @objectstack/spec, not from this diff).
  • Gate derivation, rerun in this worktreenode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 59 families. Reconciled with --ran: 59 derived, 58 run, 1 NOT-MEASURED, 0 UNRUN.
    • NOT MEASURED: pnpm check:dual-build-cjs-loads — exit 3, PREREQUISITE NOT MET, 34 packages have no dist; it needs a full pnpm build, which does not fit a foreground turn here. Its own text says "This is NOT a pass: nothing was measured." CI's Build Core covers it.
    • pnpm --filter @objectstack/spec run check:skill-examples first exited 1 on the same prerequisite shape (packages/client-react unbuilt). Built that package and re-ran: exit 0, 258 prose examples across 3 surfaces.
    • pnpm check:cross-package-test-inputs — exit 1, and it is NOT from this diff. It flags packages/cli/test/init-created-files-summary.e2e.test.ts descending from packages/spec/dist/. Control: that file and scripts/check-cross-package-test-inputs.mjs and turbo.json are all byte-identical to origin/main (blob-for-blob), while a file this PR did change differs — so the verdict is a property of origin/main, not of these two paths. The walk is a static source property (join(SPEC_PKG, 'dist') at that file's line 115). Filed below rather than fixed here.
  • pnpm lint repo-wide (eslint . --no-inline-config) — exit 0. Run whole, not narrowed, so no narrowing needs proving.
  • Ratchet and changeset families re-run at final HEAD 81a05648d: check-empty-changeset, check-changeset-no-major, check-adr-0087-registration, check:objectui-changeset, check:pm-changeset-deadline-census, check:type-check-coverage, check:type-check-debt, check:published-files, check:tier-file-adoption, check:nul-bytes, check:test-source-alias — all exit 0.

Changeset, measured rather than assumed

skip-changeset does not apply and was not requested. @objectstack/client's published files[] is ["dist","README.md","CHANGELOG.md"], and auth.me() is a member of the exported ObjectStackClient, so its TSDoc is emitted into the shipped declarations. Measured on the built artifact: the corrected sentence is present in dist/index.d.ts, dist/index.d.mts, dist/index.js and dist/index.mjs; the retired sentence is absent from dist afterwards; getActiveMember carried as the lit control, found in the same four files. Hence a patch changeset.

Acceptance notes

Neighbour PR. #18429 (card #17274) also edits packages/client/src/index.ts and was open and unmerged when this branch was cut. Its measured hunks are at 3671-3689, 3962-3967, 3976-3983 (organizations.invitations.resend); this PR's edits are the /auth/* docblock near 1474 and the auth.me() docblock near 4437. The regions do not intersect — which guarantees textual mergeability and nothing more.

Three same-defect statements deliberately NOT fixed here, because they sit in the organizations.* region this PR is fenced out of. All three are in getActiveMember's docblock and body in packages/client/src/index.ts, and all three now misdescribe the runtime:

  • "The body is the bare { user, session } envelope for a signed-in caller and the literal null for an anonymous one (measured)" — step 1 of the docblock's two-request explanation.
  • "an anonymous caller still gets 401 UNAUTHORIZED, thrown from the list-members request by the same session middleware" — the refusal now comes from the /get-session request with UNAUTHENTICATED, one request earlier.
  • The inline comment "Anonymous → null, and the request below is then refused 401 by the session middleware before the filter is ever read."

They are the same defect class as this card and the fix is mechanical, but the in-place-fix criteria do not all hold: packages/client/src/index.ts is held by another live claim (#18429), and the dispatch fences this PR out of that region by name. Handed back for filing with dedupe words getActiveMember, get-session, anonymous, UNAUTHENTICATED, #17881. Reviewer, read this PR's case ⑥ together with that docblock: they now disagree, and the docblock is the one that is wrong.

Two more, outside packages/client, handed back for filing:

  • docs/qa/platform-checklist/areas/identity-auth.json (two clauses) instructs a QA runner that "better-auth's no-session convention is HTTP 200 with a JSON null body, so a 401 expectation misdescribes a correct implementation" and that get-session is not a valid oracle. Since #17881 a 401 is exactly what a correct implementation answers, so a runner following this verbatim scores a correct platform as defective. It even cites session-of-record.test.ts as its authority — and that file was updated by #17238 and now says the opposite in its own helper docblock. This is the highest-value item in the sweep. Dedupe words: identity-auth.json, get-session, oracle, JSON null body, revoke-user-sessions.
  • .changeset/client-get-session-envelope-and-refresh-read.md (pending, unreleased) closes with "Two answers stay outside the declared type and are not addressed here: the anonymous null, which would need the published return annotation to widen" — present-tense, and it ships verbatim into @objectstack/client's CHANGELOG.md at the next release. Its transcript block above it is anchored to a dated better-auth 1.7.2 drive and reads as history; only the closing paragraph is the problem. Not touched here because it is another PR's release input. Dedupe words: changeset, get-session, anonymous null, return annotation, #16760.

Noted, not filedpackages/adapters/hono/src/hono-auth-mount-basepath.test.ts carries a GET /api/v1/auth/get-session -> 200 null row in an AFTER transcript. It is anchored to a dated maintainer ruling and a named drive, so it reads as a measurement record rather than a present-tense claim, and no assertion in that file depends on it. Who would carry it: that adapter's next #16025-area edit. If nobody touches that file, nobody is misled by it, because the file's own cases assert a relation and never that body.

Cleanup: the worktree used for this work is removed; no dev server was started and no temporary artifact is left in the tree.


Generated by Claude Code

… 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 <noreply@anthropic.com>
…ssion statements

The corrected `auth.me()` TSDoc is emitted into the shipped declarations
(`dist/index.d.ts`, `dist/index.d.mts`) and into `dist/index.js` / `dist/index.mjs`,
all under the package's published `files[]`, so this publishes and cannot take
`skip-changeset`. Measured on the built artifact with `getActiveMember` as the
lit control.

Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/client, touching 1 documentable anchor(s).

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

  • content/docs/permissions/authentication.mdx (via /api/v1/auth/get-session (route, a path literal in a comment 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 in a comment 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 15 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 32be735e5bdfa3be0211fb07d9347ed618ad1881packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 32be735e5bdfa3be0211fb07d9347ed618ad1881

⚠️ 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 32be735e5bdfa3be0211fb07d9347ed618ad1881 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-support-ai
os-support-ai marked this pull request as ready for review September 17, 2026 12:17
@os-support-ai
os-support-ai added this pull request to the merge queue Sep 17, 2026
Merged via the queue into main with commit d6137fd Sep 17, 2026
36 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-18139-get-session-wire-shape-statements branch September 17, 2026 12:41
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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] #17881 moved the anonymous /get-session wire answer to 401, but the client SDK still documents -> 200 null and a test double still models it

2 participants