Repository navigation
auth.me() returns the literal null for an anonymous caller, which no value of its declared SessionResponse can express #17238
Description
Activity
- addedbugSomething isn't workingSomething isn't working
on Sep 9, 2026 Tier notice — the contract-review-tier requirement on this issue is lifted (skills seat, session
session_01MoTv7pn338AZ71owsp19gQ, 2026-09-10T03:13Z; record and rule-text change in flight: #17285).Maintainer ruling, verbatim: 「现有的卡片如果写了要求fable的,也要让相关的项目经理知道,opus就够了。」 Under the same ruling set (quoted in full on #17285), the contract-review tier is reserved for the skills seat (protocol files + the published
skills/**), the spec seat's clause-② review, and the maintainer-summoned director; triage and every other seat run the default tier.For this card: its
Clause-②: yesdeclaration no longer calls for a contract-review-tier review. The lane seat's own default-tier review, plus the gates (widening tells, pin tests,dispatch-gates --tier), is the review of record, and the build stays at the default tier. Unchanged: theClause-②declaration itself, the manual floor for widenings under 代裁, and the routing rule that a diff touchingpackages/specgoes to the spec seat, where the contract-review-tier review still applies. This comment changes no label, assignee or claim.
Generated by Claude Code
- addedpriority:p2Medium: important, M3Medium: important, M3and removed
on Sep 10, 2026 Triage:
finding→needs-user-decision,domain:cliunchanged, typeBug,priority:p2.Why this one goes to the maintainer and its sibling #17234 does not
Both are class (b) and both are real. The difference is the fix:
- client SDK
auth.login/auth.registernormalize intodatabut never setsuccess— neither satisfies theSessionResponsethey declare #17234 delivers a contract that is already declared ⇒ mechanical boundary, work lane. - This card cannot be fixed without changing a published contract. There is no value of
SessionResponsethat means "nobody is signed in", so every available fix either edits the declared schema or changes what the HTTP endpoint answers. Published-contract change is a maintainer floor — ⛔ triage does not rule it, and the auto-adjudication channel excludes it outright.
The filing seat and the delivering seat both read this correctly: PR #17237 closed the shape half for the signed-in answer and deliberately left the anonymous one, pinning it in block 3 of
packages/client/src/auth-get-session-envelope.test.tsso it cannot drift. ⭐ That pin is also honestly labelled by its author — it "pins the residue, not the fix", and cannot redden under the ablation. ⇒ Nothing here is waiting on more measurement; it is waiting on a decision.维护者速读
一个没登录的人问「我是谁」,服务器回的是 HTTP 200 加一个空值
null。而 SDK 上这个方法声明自己一定会返回一个「会话信息」结构 —— 那个结构里没有任何一种写法能表达「没人登录」。⇒ 最普通不过的一次调用(任何客户端启动时都会做),返回的东西不在它自己承诺的类型里。今天不炸,是因为 JavaScript 不检查;
⚠️ 用 TypeScript 的调用方、以及任何按我们的接口说明书自动生成客户端的人,拿到的是一份在最常见输入上就是假的说明书。三条路,各有代价:
- A —— 改说明书:让「会话信息」这个结构本身允许「空」。⇒ 服务器一个字不用改,线上行为完全不变,只是说明书从此说的是实话。代价:所有按它写代码的地方要多一次判空。
- B —— 改服务器:没登录时不再回
null,改回一个标准的「失败」信封(或 401)。⇒ 说明书不用动。代价:线上行为变了,已经在按null判断的调用方会坏掉。 - C —— 都不改:把这个例外写进文档,留着现在那条测试盯住它别再扩散。代价:说明书上永远留着一句已知是假的话。
A / B / C?
os-decision-facets
- ① 项目长远合理性:A 让类型变成全的 —— 服务器可能给出的每一种回答,类型里都有对应的值,这正是契约存在的理由。B 也是全的,但它是用改动一个已发布的线上行为换来的。C 永久保留一条已知为假的声明,而每保留一条,其余所有声明的可信度都跟着打折 —— 读者无法再假定「声明了就是真的」。
- ② 实际业务拉动:撞上它的是每一个客户端的启动路径(未登录状态问一次「我是谁」),不是边角场景。但今天没有人被它弄坏,因为运行时不检查类型 ⇒ 拉动集中在两类人:用 TypeScript 写调用的人,和拿我们的接口说明书生成客户端的人(含 AI)。⇒ 真实但不流血。
- ③ 防 AI 犯错:C 是三条里最差的 —— 「在最普通的输入上就是假的类型声明」正是让 AI 写出不判空代码的那种陷阱,而且它有测试背书、看起来像被管住了。A 是闭合的:类型说了可空,生成出来的调用方必须处理。B 也闭合,但把陷阱挪到了「未登录到底回哪个状态码」这个新问题上。
- ④ 创业阶段不扩散:C 最省(零改动)。A 是一处 schema 改动加上调用方的连带修改。B 最贵:服务端改一次,所有客户端跟着改一次,且是破坏性的。
推荐:A。 它让契约表达出服务器本来就在做的事,不动任何人已经依赖的线上行为;B 移动的是外部调用方可能正在分支判断的 HTTP 行为,代价最高而收益与 A 相同。C 省下的那点工作量,是用「我们的类型声明可以是假的」这个先例换的,⛔ 这个先例比这张卡贵。
本分析看不见什么:我没有量过今天有多少调用方在
SessionResponse上做窄化,也没有量过是否已经有已发布的客户端或控制台在按200 null分支判断。若已有调用方就是在判null,A 的代价比我写的更低(它只是把大家已经在做的事写进类型);若有调用方在按success分支,那么 B 的代价估计是错的,推荐应重估。⚠️ 这两个读数都没取,取它们不需要裁决,任何执行席一轮就能给出。⚠️ Conditional re-route the ruling triggersdomain:cliis where the defect is observed and whereauth.me()lives. But A editspackages/spec/src/api/auth.zod.ts, and the standing rule is absolute: anything touchingpackages/specgoes to thedomain:specseat, no matter who needs it. ⇒ If A is ruled, re-route todomain:specin the same write that moves this card topm:queue. If B is ruled, the fix is server-side and the lane is re-read then. ⛔ I am not pre-routing on an unmade decision.priority:p2: the client boot path, wrong against its own type, on every typed consumer — but no signed-in user is broken today. Not p1.Triage seat ·
session_017VGfRocA8VjczSe84fgjY3· R+166/R+167 · 2026-09-10T15:37Z (timestamp taken in the same tool call that posts) · comment from the triage seat
Generated by Claude Code
- client SDK
Ruling recorded — B: the server stops answering an anonymous
get-sessionwith200 null; the declaredSessionResponsestays as it is (director seat, decision batch #117 item 4, 2026-09-12)Maintainer, verbatim (live PM chat, 2026-09-12T01:2xZ): 「17238 B」 — the triage seat's B (5621313495): change the server, not the contract.
The seat had recommended A (widen the declared type to admit
null). The maintainer's correction, verbatim: 「我改推荐的都是从平台长远合理性角度考虑的」 — and the charter already says so: 「spec 与代码不一致默认改代码,改协议单独立卡非选项」. The published contract is the target; the implementation is corrected to it. Anullon the most ordinary call is the implementation's quirk (better-auth's bare answer), ⛔ not a shape the platform's contract should grow a nullable arm to accommodate forever.What is ruled
GET /api/v1/auth/get-sessionanswers an anonymous caller with the platform's standard failure envelope (ADR-0112) —success: false, a registered error code, HTTP 401 — instead of200+null.SessionResponseSchemais untouched.Execution notes the implementer lands
- Server side, in
packages/plugins/plugin-auth(the route that lifts better-auth's answer; PR fix(client): auth.me / auth.refreshToken deliver the SessionResponse envelope they declare, and refreshToken reads session.token #17237's client-side lift stays). ⇒ lane re-routeddomain:cli→domain:servicesin the same write, per the triage seat's conditional re-route; theauthlabel stays. - The error code ships registered in
packages/spec'sERROR_CODE_LEDGER([Decision] Clause ② on an UNREGISTERED error code carried by a thrown value: #14552 landedno, #15963 landsyes, and they are the same class #16404) — the spec seat's write; reuse an existing unauthenticated code if the ledger already carries one, ⛔ do not mint a second spelling. - Client consequences are enumerated in the PR body, not discovered downstream:
ObjectStackClient.auth.me()on the anonymous answer now sees a failure envelope; the CLI'swhoamipath and any caller branching onnullare updated in the same round (packages/clientisdomain:cli's serial hot file — coordinate, ⛔ do not rider). Block 3 ofpackages/client/src/auth-get-session-envelope.test.tsis flipped with the reversal named; it pinned the residue, not a fix. - The two readings the triage seat asked for (how many callers narrow on
SessionResponse; whether any shipped client or console branches on200 null) are taken FIRST and written on this card — they size the client half. - Sibling client SDK
auth.login/auth.registernormalize intodatabut never setsuccess— neither satisfies theSessionResponsethey declare #17234 (auth.login/auth.registernever setsuccess) may share the round; siblingSessionUser.imageis declaredz.string().optional(), but every/auth/*session route serves"image": null— no real session body parses asSessionResponse#17235 (image: null) is not this ruling. - This is a change to a published endpoint's behaviour:
Clause-②declared honestly by the taker (the accept set does not widen — a 401 replaces a 200 — but the observable behaviour changes; the changeset banner says so in as many words).
State
needs-user-decision→pm:queue;domain:cli→domain:services;bug/auth/priority:p2kept.
Generated by Claude Code
- Server side, in
4 remaining items
Note-4 readings, taken before the fix (
domain:services, branchclaude/issue-17238-anonymous-session-401)The director seat's ruling
5642584682note 4 asks for two readings the triage seat named as missing. Both are taken againstorigin/mainatc88fa2ccd, repoobjectstack-ai/objectstack. Method for each is stated so the number is re-derivable.Reading 1 — how many callers narrow on
SessionResponseZero callers narrow on it. One declaration site, three methods, no consumer-side narrowing anywhere in the repo.
Method:
git grep -n "SessionResponse"across the tree excludingpackages/specand everydist/. Every hit classified:where hits what it is packages/client/src/index.ts16 the declaration side — the type import, normalizeSessionResponse, and the four methods annotatedPromise<SessionResponse>(login,me,register,refreshToken)packages/client/src/*.test.ts12 the repo's own pins, incl. the residue pin this card is about .changeset/*.md,content/docs/references/**9 prose and generated reference docs ⇒ No narrowing site exists to break. No
if (x === null), no type guard, nosatisfies, no destructure ofSessionResponseoutside the SDK that declares it. The triage seat wrote that if callers were already narrowing onnull, option A's cost would be lower than estimated; the measurement says nobody narrows at all, so that discount does not apply — and symmetrically, B breaks no narrowing site either.Reading 2 — does any shipped client or console branch on
200 nullOne shipped runtime consumer, and it is unaffected. Two test pins are affected. No console/objectui branch exists.
consumer branches on 200 null?effect of the 401 packages/plugins/plugin-auth/src/register-sso-provider.ts:53— re-dispatches/get-sessionto read the caller's active orgno — it guards if (!resp.ok) return undefined;none. Today: ok, bodynull,data?.session?.activeOrganizationIdundefined →undefined. After: notok→undefined. Same value, best-effort by its own docblockpackages/cli/src/commands/whoami.ts:49no explicit nullbranch;response.data || responsethen optional readsbehaviour change: client.auth.me()now rejects, so an invalid/expired token prints the error and exits 1 instead of printing an emptyCurrent Session.requireAuth(token)already exits earlier when no token is storedpackages/client/src/client.test.ts:884—expect(await client.auth.me()).toBeNull()yes stale, not red. It drives createMockClient(null), afetchmock, so it keeps passing while pinning a shape the server no longer servespackages/client/src/auth-get-session-envelope.test.tsblock ③yes goes RED. It drives a real AuthManagerthroughhandleRequest(file header: "everything below that call is better-auth's real pipeline")console / ../objectui— no hit. packages/console/ships no built bundle in-tree and no source here readsget-sessionShipped-console answer: no. Searched
packages/console/, everycontent/docs/**example and the CLI: nothing branches on the literalnullbody.What the two readings size
The client half is two test edits and no runtime edit, plus one CLI behaviour improvement that needs no code change:
packages/client/src/auth-get-session-envelope.test.tsblock ③ — flip with the reversal named (it pinned the residue, not a fix, and its own comment says so).packages/client/src/client.test.ts:884— re-point; it is mock-driven so it will not announce itself by going red.
⛔
ObjectStackClient.auth.me()itself needs no source change:this.fetchalready throws on every non-2xx (packages/client/src/index.ts:7129,if (!res.ok)), so the anonymous answer arrives as a rejection carryingcode: 'UNAUTHENTICATED'andhttpStatus: 401. That is what actually closes this card — every valueme()returns is now insideSessionResponse.⚠️ Consequence for sequencing, reported rather than acted on: because block ③ drives the real server, the server change is not independently landable — merging it alone turnsTest Corered forpackages/client.packages/clientisdomain:cli's serial hot file and is out of this card's face by the ruling's own note 3 (「coordinate, ⛔ do not rider」), so the two edits above are reported here for that lane rather than taken. The server PR is opened as a draft and is not armed.Two stale card references found while re-reading the ruling's premises
- client SDK
auth.*family: bind the 14return res.json()methods (auth 7 · sessions 3 · twoFactor 3 · accounts.unlink 1) to their better-auth wire shapes — #12104 family card 2 of 3 #14313 — named in this card's body (⭐ Sequencing) as theauth.*family card — returns HTTP 404; it does not exist in this repository. Control: client SDKauth.login/auth.registernormalize intodatabut never setsuccess— neither satisfies theSessionResponsethey declare #17234,SessionUser.imageis declaredz.string().optional(), but every/auth/*session route serves"image": null— no real session body parses asSessionResponse#17235, client SDKauth.me/auth.refreshTokendeclare the REST{ success, data }envelope for/get-session, which answers the bare{ user, session }— andrefreshTokennever captures a token because of it #16760, client SDKauth.*family: bind the 14return res.json()methods (auth 7 · sessions 3 · twoFactor 3 · accounts.unlink 1) to their better-auth wire shapes — #12104 family card 2 of 3 #14313 read with the same credential in the same batch; only client SDKauth.*family: bind the 14return res.json()methods (auth 7 · sessions 3 · twoFactor 3 · accounts.unlink 1) to their better-auth wire shapes — #12104 family card 2 of 3 #14313 404s. - client SDK
auth.login/auth.registernormalize intodatabut never setsuccess— neither satisfies theSessionResponsethey declare #17234 — the ruling's note 5 offers it as a sibling that may share the round. Live state isneeds-user-decision+domain:cli. Not dispatchable and another lane's; ⛔ not touched, noFixesline for it.
domain:servicesexecution seat · readings taken againstorigin/mainc88fa2ccd· 2026-09-12
Generated by Claude Code
os-dev-report
{ "issue": 17238, "status": "done", "branch": "claude/issue-17238-anonymous-session-401", "pr": "https://github.com/objectstack-ai/objectstack/pull/17881", "head": "b8c2b00f8", "premise_still_valid": true, "clause_2": "no — confirmed by delivery: packages/spec untouched, UNAUTHENTICATED reused not minted, accept set narrows (401 replaces 200), new module is package-internal (not re-exported from index.ts, 0 occurrences in the published .d.ts)", "summary": "GET /api/v1/auth/get-session now refuses an anonymous caller with the ADR-0112 envelope at 401 (code UNAUTHENTICATED) instead of 200 + literal null. Implemented in packages/plugins/plugin-auth as a new package-internal module wired into AuthManager.handleRequest beside the existing /admin/ envelope seam, under three narrowings (exact endpoint, status 200 only, body exactly the literal null) each pinned by identity. Every value auth.me() returns is now inside its declared SessionResponse; the anonymous case arrives as a rejection because the SDK fetch wrapper already throws on non-2xx, so packages/client needed no runtime edit. The two client TEST pins were flipped under the fence lift granted mid-run.", "gates": { "derived": 70, "run": 70, "not_measured": 0, "unrun": 0, "all_exit_zero": true, "reconciliation": "dispatch-gates --ran with recorded exit codes: '70 derived famil(ies) accounted for — 70 run, 0 NOT-MEASURED (a DERIVED zero — all 70 recorded an exit code and none of them is 3)'", "derivation_note": "re-derived after packages/client entered the change set; that added 9 families over the first derivation, all run", "prerequisite_blocked_then_satisfied": [ "check:dual-build-cjs-loads — first run exit 3 PREREQUISITE NOT MET; satisfied by building the needed packages, then exit 0", "check:type-check-debt — exit 3 then a 420s timeout (124); re-run at 540s, exit 0, '5 ledger entr(ies) re-measured in 71.5s, none above its recorded number'", "pnpm --filter @objectstack/spec check:skill-examples — exit 1 on unbuilt client-react dist; built, then exit 0" ], "ledger_writes": "scripts/engine-double-contract.pinned.json +3 rows (delete/findOne/update for the new test file) via the gate's own --write. Pure addition, no baseline weakened, no exemption added." }, "tests": { "plugin_auth_full_suite": "2283 passed / 108 files (pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2)", "plugin_auth_typecheck": "pass, incl. check:test-typecheck — 'OK — @objectstack/plugin-auth's test layer compiles'; required building the package first (examples tsconfig resolves it by name)", "client_affected_files": "250 passed / 5 files", "new_test_file": "packages/plugins/plugin-auth/src/anonymous-session-refusal.test.ts — 11 cases; refusal asserts BOTH status 401 AND error.code UNAUTHENTICATED, plus envelopeViolations(body) === [] exhaustively", "signed_in_pin": "asserted BY IDENTITY (toBe) plus key-set and SessionSchema parse. The end-to-end SessionResponseSchema parse of a signed-in answer is owned by packages/client block 1 and is unchanged by this diff; deliberately NOT re-derived server-side, which would be a second copy free to drift", "ablation": { "method": "neutered the guard in anonymous-session-refusal.ts (restoring the old pass-through), REBUILT plugin-auth, proved the marker live in dist before reading anything", "on_disk_proof": "SRC ablation added: 1 (expect 1); blob b7e8f76e2915cae2581a158384822cd8367bcfd3 != HEAD ba3aeb861171543e87d4841766bc84e6a0fe346e", "dist_proof": "ablation-dist-preflight: '✓ dist/: marker present in 2 built files'", "server_red": "4 of 11 failed — 'expected 200 to be 401'; and envelopeViolations returned [\"body is null, not an envelope object\"]", "client_red": "block 3 failed — 'promise resolved \"null\" instead of rejecting'. This is the card's own loop closing: the card records block 3 'CANNOT redden' on the #16760 ablation; it reddens now", "restore": "'✓ dist/: marker absent from all 12 built files' + '✓ tree: working tree clean against HEAD'; re-run 11/11 and 223/223 green", "two_traps_hit_and_recorded": [ "first marker was written inside a /* */ comment — tsup strips comments, so it never reached dist; the preflight caught it rather than a false green", "a source-only mutation would have left client block 3 GREEN, because packages/client resolves plugin-auth through dist — the rebuild is load-bearing" ] }, "lint": "narrowed and PROVEN narrow: 11 of 6657 files, 0 errors / 0 warnings. (1) population 6657 is eslint's own enumeration; (2) 11 is the --format json count; (3) invariance — this repo runs one eslint.config.mjs that never enables type-aware linting for ANY file (its own header, line 328, measured there with a positive control), so no untouched file's verdict can have moved." }, "note_4_readings": { "posted_on_card_before_the_fix": "comment 5647024737", "reading_1_callers_narrowing_on_SessionResponse": { "answer": 0, "method": "git grep SessionResponse across the tree excluding packages/spec and all dist/", "breakdown": "37 hits total: 16 in packages/client/src/index.ts (the DECLARATION side — type import, normalizeSessionResponse, 4 annotated methods), 12 in packages/client/src/*.test.ts (this repo's own pins), 9 in changesets and generated reference docs", "implication": "no narrowing site exists to break, in either direction. The triage seat's conditional discount for option A does not apply, and option B breaks no narrowing site either" }, "reading_2_branches_on_200_null": { "answer": "one shipped runtime consumer, unaffected; two test pins affected; no console/objectui consumer", "register_sso_provider_ts": "UNAFFECTED — guards 'if (!resp.ok) return undefined'. Before: ok, body null, org undefined. After: not ok. Same value; best-effort by its own docblock", "cli_whoami": "no explicit null branch; auth.me() now rejects so an invalid/expired token prints the refusal and exits 1 instead of printing an empty session. requireAuth(token) already exits earlier when no token is stored. No code change needed", "client_test_ts": "pinned the null, MOCK-driven — would have stayed GREEN while pinning a shape no server serves. Re-pointed by hand", "auth_get_session_envelope_test_ts_block_3": "pinned the null, drives the REAL AuthManager — went red. Flipped with the reversal named", "console_objectui": "no hit; packages/console ships no built bundle in-tree and no source here reads get-session" } }, "files_changed": { "count": 13, "insertions": 667, "deletions": 43, "in_declared_face_packages_plugins_plugin_auth_src": [ "anonymous-session-refusal.ts (new, 153)", "anonymous-session-refusal.test.ts (new, 302)", "auth-manager.ts (+18, the wiring)", "auth-manager.optional-plugin-isolation.test.ts (re-pointed: its anonymous 200 was the vehicle for 'the route survives')", "session-of-record.test.ts, session-tombstone.test.ts, revoke-session-match-guard.test.ts, admin-revoke-user-session-match-guard.test.ts (doc comments my change falsified: each said 'NOT a 401'; the helpers themselves were already correct and still are)", "two-factor-rotated-token-echo.test.ts (same class; its helper reads the JS API, which this change does not touch)" ], "outside_the_original_face": [ ".changeset/anonymous-get-session-refusal.md (required)", "scripts/engine-double-contract.pinned.json (+3 rows, written by the gate's own --write)", "packages/client/src/auth-get-session-envelope.test.ts and packages/client/src/client.test.ts — the two files the dispatching seat's mid-run fence lift authorised, and ONLY those" ] }, "line_budget": "not applicable — the diff touches no skills/** path, so no published-skill line or token ratchet applies. check:skills-token-ratchet is in the artifact-roster set and scored silent for this change set.", "deviations": [ "Ran `pnpm --filter @objectstack/plugin-auth test -- --maxWorkers=2` once. The bare `--` means vitest silently discarded the flag, so that run was uncapped on a shared box. The reading itself was sound (108 files ran, exit 1 on a real failure), but I re-ran it as `exec vitest run --maxWorkers=2` and report the second run. No result rests on the first.", "Edited 6 test files in my face beyond the new one. Five are doc comments my change made false ('better-auth answers 200 + null, NOT a 401'), which after this change would actively misdirect the next author; one is a real assertion re-point. Reported rather than assumed to be in scope.", "check:dual-build-cjs-loads and check:type-check-debt initially returned exit 3 PREREQUISITE NOT MET, which is NOT MEASURED rather than red. I satisfied both by building the named packages rather than reporting them unmeasured; both then exit 0.", "The changeset's first draft carried a FROM -> TO table; the ADR-0087 gate reads that as a migration prescription and refuses the no-migration-prescription category. I restated the guidance as prose plus a try/catch example and kept the BREAKING declaration. ⛔ I did NOT drop the bang — the gate names that as the move it exists to stop — and I did NOT edit packages/spec to register a migration." ], "spec_fence": "HELD, and it was tested twice. (1) UNAUTHENTICATED already exists in StandardErrorCode (errors.zod.ts:69) and HttpStatusErrorCodeMap[401] (:174) — confirmed, so nothing is minted and ERROR_CODE_LEDGER needs no row (it registers EXTENSION codes; standard-catalog members are not in it). (2) The ADR-0087 gate briefly pointed at `registered SOME-MIGRATION-ID`, whose ledger lives in packages/spec — I did not take it; the correct disposition was not-required, matching a previous HTTP-status-change precedent verbatim.", "mcp_calls": "0 — every GitHub read and write in this run went through repo-scoped REST (probe returned HTTP 200 at the start). Card body + all 4 comments, 7 cross-referenced cards, the readings comment, the PR create and both read-backs.", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: packages/client/src/organization-get-active-member-addressing.test.ts (lines 40, 88, 120) models the anonymous answer as `200 null` in a socket-level double. It stays GREEN because the double serves itself — stale, not broken; same hazard class as client.test.ts, which WAS in scope and was fixed. Successor: the domain:cli seat, which already owns the two sibling files this round.", "noted, not filed: packages/client/src/index.ts docblocks at :1474 and :4389 still describe the anonymous answer as `200 null`. Untouched deliberately — index.ts is the runtime file the fence lift excluded, and #14313 owns published-annotation moves on it. Successor: whoever next moves that file.", "STALE CARD REFERENCE: #14313 — named in this issue's body as the `auth.*` family card that owns published-annotation moves on packages/client/src/index.ts — returns HTTP 404 and does not exist in this repository. Control: #17234/#17235/#17237/#16404/#17454/#16760 all resolved normally with the same credential in the same batch. The issue body's ⭐ Sequencing section and the triage comment both route work to it. PM action needed: the family card either has a different number or was never filed.", "STALE CARD REFERENCE (confirmed, already corrected by the PM mid-run): #17234 carries needs-user-decision + domain:cli. Ruling note 5's offer to share the round does not survive its live state. Untouched, no Fixes line.", "noted, not filed: packages/plugins/plugin-auth/src/auth-route-ledger.ts lists `POST /api/v1/auth/get-session` in BETTER_AUTH_MOUNTED_SURFACE, but on the default plugin set I measured POST /get-session as a 404. Not a defect — that inventory is pinned at LEDGERED_PLUGIN_CONFIG (every plugin on) and my probe ran the default config, so the two are not comparable. Recorded only so the next reader of that row does not treat my measurement as contradicting it." ], "landing_note": "⚠️ NOT independently landable, and left as a DRAFT. packages/client block 3 drives the real AuthManager, so it goes red the moment the server answers 401 — server and client halves must land together, which is why both are in this one PR. Nothing is armed; no label was written (Clause-② is no, so no needs:contract-review is owed; the auto-labeler applied size/l). Governed surfaces: none touched." }
Generated by Claude Code
Seat note — the dev's 404 on #14313 is CONFIRMED, and properly controlled. Two routing statements on this card point at an issue that does not resolve.
domain:servicesexecution seat,session_01URLHobLUJB9K1ABV6ofdjj, R1, 2026-09-12T16:55Z. ⛔ Not a rework item and ⛔ nothing blocks PR #17881 — recorded so the next reader does not chase a phantom.The measurement
The delivering dev reported that #14313 returns HTTP 404. Its controls were #17238 / #16760 / #17234 / #17235 — all in the 16–17k band, so they did ⛔ not control for a range problem, and the first thing I read was three consecutive 404s (#14312 · #14313 · #14314), which is the signature of an unreadable range rather than a missing card.
So I re-probed the neighbourhood with adjacent controls (same credential, same batch):
probe result #14000 · #14100 · #14200 · #14250 · #14280 · #14300 · #14305 · #14310 · #14311 resolve #14312 · #14313 · #14314 HTTP 404 #14315 · #14320 · #14350 · #14400 · #14500 · #15000 resolve 15 resolvable, 3 not, and the 404s are bracketed by resolving neighbours one number away on both sides. ⇒ ⛔ Not a credential or range artefact. Those three numbers genuinely do not resolve in
objectstack-ai/objectstack(deleted, or transferred and not redirecting). The dev's conclusion stands; only its evidence needed strengthening.What it affects — and what it does ⛔ NOT affect
Two places route work to #14313:
- this card's body, the 「⭐ Sequencing」 section: 「client SDK
auth.*family: bind the 14return res.json()methods (auth 7 · sessions 3 · twoFactor 3 · accounts.unlink 1) to their better-auth wire shapes — #12104 family card 2 of 3 #14313 — theauth.*family card,pm:blockedon auth-method-matrix:change-emailanddelete-userare booked as mounted inauth-route-ledger.tsbut plugin-auth never configures better-auth'suser.changeEmail/user.deleteUser— no switch to enable them #7735 — already exists to move published annotations on that same file」; - the triage comment's conditional re-route.
⛔ Neither blocks anything here. Both are about option A (widening the declared type), and the maintainer ruled B — the server is corrected to the contract and
SessionResponseSchemais untouched. So the #14313 window was already moot for this card before the reference was found to be dangling.⚠️ What it does affect is the next reader of the published-annotation family: the standing claim 「a family card already exists to own published-annotation moves onpackages/client/src/index.ts」 is, as written, not verifiable. Whoever takes that work should ⛔ not assume a home card exists — it isdomain:cli's surface, and that seat owns re-establishing it.⛔ No card filed for this. A dangling cross-reference is not a reproducible defect, not a violation of a declared contract, and not a metadata trap an author can fall into — it clears none of the three filing classes, and filing it would cost a live seat attention for a prose correction. Recorded here instead, which is where the stale sentence lives.
⭐ Process note, because the finding pattern is the point. I corrected this same failure mode on myself earlier this round (a ruling's sibling reference read as a live fact), and told this dev that every card number in its order was a premise to re-read rather than inherit. It applied that to the card's own body and found a second one. That is the behaviour this seat wants — a dev auditing its own dispatch's references is ⛔ not scope creep.
domain:servicesexecution seat · seat post #6021 · R1
Generated by Claude Code
- this card's body, the 「⭐ Sequencing」 section: 「client SDK
ACCEPT — PR #17881, verified against GitHub and against the delivered diff
domain:servicesexecution seat,session_01URLHobLUJB9K1ABV6ofdjj, R1, 2026-09-12T17:21Z. Reviewer of record. ⛔ Nothing below is taken from the report's self-description.Checklist — PASS
item reading PR shape draft, base main, first body lineFixes #17238✓CI 31 success · 3 skipped · 0 running · 0 red on head b8c2b00f8, counted as the LATEST run per check NAME across 34 checks; legacy combined statussuccess;mergeable_state: cleanpath face 13 files — changeset, 2 packages/clienttests, 8plugin-auth, 1 generated ledger. ⛔ No governed surfacechangeset minor+ a BREAKING banner — see below, this is correct and ⛔ not a mismatchreport on the card first ( 5647311915) ✓Clause-②re-derived from the DELIVERED diff —noholds, and the reason is the sharp one⚠️ I re-derived rather than inherited my claim-timeno, because that same prediction was wrong on this round's sibling card #17628. Here it survives, and the distinction is exact:packages/plugins/plugin-auth/src/index.tsis untouched (0 hits for the new module).- The diff does add four
exports —GET_SESSION_PATH,ANONYMOUS_SESSION_REFUSAL_STATUS,isGetSessionPath,refuseAnonymousSession— but all four live in a new internal module, and the package'sexportsmap has exactly two entries,.and./rate-limit-storage, with no wildcard. ⇒ ⛔ No consumer of@objectstack/plugin-authcan reach any of them. packages/spectouched 0 times.
⇒ ⭐ The mechanical floor's 「new exported symbol」 means reachable from the PUBLISHED ENTRY, ⛔ not the word
exportappearing in a source file. #17628 wasyesbecause its additions were re-exported atindex.ts:33/:78; this one isnobecause nothing re-exports it. Two cards this round, one on each side of that line — worth stating plainly since both were judged by the same seat within the hour.Also re-checked: the accept set narrows (a 401 replaces a 200), and
UNAUTHENTICATEDis reused, not minted. Both point the same way.The changeset level is right, and I verified the rule rather than assuming
minorbeside a BREAKING banner looks wrong and is not:scripts/check-changeset-no-major.mjsis a launch-window guard — 「a PR may not introduce a changeset that declares amajorbump」. ⇒minor+ an explicit BREAKING banner is the only available encoding for a breaking behaviour change here, andCheck Changesetis green on it. ⭐ The dev also declined to drop the bang, which the ADR-0087 gate names as the move it exists to stop.What I judge the strongest part of this delivery
The ablation closed a loop the card itself said could not close. #17238 recorded that block ③ of
auth-get-session-envelope.test.ts「CANNOT redden」 under the #16760 ablation — it pinned the residue, not a fix. It reddens now:promise resolved "null" instead of rejecting.Two traps were hit on the way there and both are worth more than the fix:
- a marker placed in a
/* */comment was stripped by tsup and never reacheddist; - a source-only mutation would have left block ③ green, because
packages/clientresolves plugin-auth throughdist— so the rebuild is load-bearing and a source-only ablation would have certified nothing.
⭐ And the measurement that actually closed the card:
this.fetchalready throws on non-2xx, soauth.me()needed no runtime change at all — which is why thepackages/clientfence only ever had to lift for two test files.⚠️ Landing is HELD — this is the third baton, ⛔ not a blockerThis PR also modifies
scripts/engine-double-contract.pinned.json, the shared os-regen ledger, which PR #17872 is mid-relay on. ⇒ It lands after #17872 merges, by mergingmainand regenerating through the repo's tooling, ⛔ never by hand.⚠️ That will move this head, so this ACCEPT is bound tob8c2b00f8and I re-verify the reviewed surface is byte-identical before landing — exactly as I did for #17872's PASS when its head moved.⛔ Not flipping ready, ⛔ not arming auto-merge, until the baton passes.
domain:servicesexecution seat · seat post #6021 · R1 · readings 17:19Z
Generated by Claude Code
os-dev-report
Relay baton for PR #17881 — main-sync + ledger regeneration. ⛔ No rework: the ACCEPTed surface is byte-identical, proven below.
{ "issue": 17238, "pr": "https://github.com/objectstack-ai/objectstack/pull/17881", "step": "relay-main-sync", "status": "done", "new_head": "a249cc69081c4a0f7c17b9acbc2224a4a5dfc5e0", "new_head_short": "a249cc690", "previous_head": "b8c2b00f8", "merged_main_at": "2266438ce", "merge_commit_only": true, "rebase_amend_or_force_push": "none — a249cc690 is a true merge commit; b8c2b00f8 is still its first parent", "three_row_ledger_assertion": { "verdict": "PASS — all three prior rows survive alongside mine", "baseline_taken_independently_before_the_merge": { "origin_main": "792 rows — notification-delivery-retention-sweep 1 (delete), claim-seed-ownership-seed-settle-rerun 2 (findOne, update), anonymous-session-refusal 0", "my_branch_b8c2b00f8": "791 rows — anonymous-session-refusal 3 (delete, findOne, update), the other two 0", "arithmetic_predicted": "795 if both sides survive; 792 would mean mine was dropped, 791 would mean theirs was" }, "after_merge_before_regeneration": "795 rows — 1 + 2 + 3, all six probe rows present", "regeneration": "node scripts/check-engine-double-contract.mjs --write → '795 (file, verb) row(s), 0 added or grown, 0 LOST' and '6 seam row(s), 0 added or grown, 0 lost'", "byte_identity": "PASS — git hash-object 9db49c9bd54ec91079f004adfc52a50aaa2efc9b BEFORE the regeneration and 9db49c9bd54ec91079f004adfc52a50aaa2efc9b AFTER. The regeneration reproduced the merged bytes exactly, so the merge dropped nothing.", "hand_edits": "none — the file was only ever written by the gate's own --write", "note": "this path is NOT one of the 18 os-regen merge-driver patterns, so it took git's ordinary text merge. The byte-identity assertion is what makes that merge trustworthy rather than merely exit-0." }, "implementation_bodies_not_just_index_rows": { "method": "git rev-parse origin/main:<path> vs HEAD:<path> — blob identity, not grep presence", "17872": "IDENTICAL ×4 — claim-seed-ownership.ts, security-plugin.ts, bootstrap-platform-admin.ts, claim-seed-ownership-seed-settle-rerun.test.ts", "17871": "IDENTICAL ×2 — notification-delivery-retention-sweep.test.ts, objects/notification-delivery.object.ts", "verdict": "nothing swallowed — the bodies, not only the ledger entries that index them" }, "accepted_surface_unchanged": { "verdict": "PASS — every one of the 12 files I authored is byte-identical between b8c2b00f8 and a249cc690", "method": "blob-hash compare of each path in `git diff --name-only c88fa2ccd b8c2b00f8`", "only_difference_in_the_whole_diff": "scripts/engine-double-contract.pinned.json, 791 → 795 rows — which IS the merge absorbing main's 4 new rows. Nothing else moved.", "regeneration_commit": "none needed — the regeneration produced zero delta (byte-identical), so there was nothing to commit. ⛔ No empty commit was manufactured to look like step 5 ran." }, "os_regen_merge_sh": { "exit": 0, "steps_1_to_3": "merge → per-file side selection → merge committed FIRST, before any regeneration", "step_2_result": "'merge left no additional changes to commit' — main moved os-regen artifacts my branch never touched, so git resolved them trivially to main's side and step 2 had no reconciliation to do", "deferral_record": "no $GIT_DIR/os-regen-pending file in either the worktree git-dir or the common dir — nothing owed", "step_4_chain_run": "spec build + check:generated → '✓ All 15 generated artifacts are up to date' (all 15 named gates individually ✓). No gen:* rerun was needed because this branch touches no packages/spec source; the anchor-rollback trap could not apply, and the merge was committed first regardless." }, "post_merge_gate_reconciliation": { "derived": 70, "run": 70, "not_measured": 0, "unrun": 0, "all_exit_zero": true, "verdict_line": "✓ dispatch-gates --ran: 70 derived famil(ies) accounted for — 70 run, 0 NOT-MEASURED (a DERIVED zero — all 70 recorded an exit code and none of them is 3)", "derivation": "re-derived at a249cc690 against the NEW merge base 2266438ce — 13 paths, three-dot. Family set is identical to the pre-merge derivation: 0 added, 0 removed.", "exit_3_handling": "4 gates answered exit 3 PREREQUISITE NOT MET in the freshly recreated worktree (nothing was built in it yet): check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt, and check:skill-examples at exit 1 on an unbuilt client-react dist. ⛔ Not reported as passes and ⛔ not left unmeasured — I ran a full `pnpm build` (73/73 tasks successful) and re-ran all four to real verdicts: every one exit 0. check:type-check-debt re-measured 5 ledger entries, none above its recorded number.", "exit_codes": "captured into a variable before any pipe, never read through tail/head" }, "post_merge_tests": { "plugin_auth_full_suite": "2283 passed / 108 files", "plugin_auth_typecheck": "pass, incl. check:test-typecheck OK", "client_affected_files": "250 passed / 5 files", "why_rerun": "main moved packages/objectql/src/engine.ts (+154) and the driver family under plugin-auth; AGENTS.md §10 asks for the overlap to be re-checked rather than assumed. packages/runtime/.objectstack cleared per §9." }, "untouched_as_instructed": { "ready_flip": "not performed — PR is still draft", "labels": "not written. The auto-labeler added documentation/tests/tooling on this push; carriers are the seat's", "arming": "none" }, "open_questions": [] }
Generated by Claude Code
Landing note — the ACCEPT was re-verified across the relay head move, ⛔ not assumed to carry
domain:servicesexecution seat,session_01URLHobLUJB9K1ABV6ofdjj, R1, 2026-09-12T19:14Z.The ACCEPT (
5647471920) judged headb8c2b00f8. The serial-relay main-sync then moved the head toa249cc690, so it was re-checked rather than inherited:git diff b8c2b00f8 a249cc690 -- packages/plugins/plugin-auth/ packages/client/ .changeset/anonymous-get-session-refusal.md→ empty. The reviewed surface is byte-identical.- Control, same command over
scripts/engine-double-contract.pinned.json→1 file changed, 20 insertions(+), proving the diff instrument reads rather than answering empty to everything. - ⇒ The only change since the ACCEPT is the merge and the ledger. The acceptance binds to what is landing.
⚠️ ⛔ No contract-review record is owed here and none is re-issued: this card isClause-②: no(measured on the delivered diff —plugin-auth/src/index.tsuntouched, the package'sexportsmap carries no wildcard, so its four newexports are internal and unreachable by a consumer). Neither carrier was ever hung, so there is nothing to clear. ⭐ That is the opposite disposition from sibling card #16974, whose ownindex.tsre-exports its changed modules — same round, same lane, and the difference is a measurement rather than a habit.The relay assertion, third leg
This was the third and final baton on
scripts/engine-double-contract.pinned.json. All three prior rows survive alongside this PR's own three:notification-delivery-retention-sweep(1),claim-seed-ownership-seed-settle-rerun(2),anonymous-session-refusal(3); relay head 795 rows against main's 792.⭐ The dev's instrument was better than the one the dispatch asked for: it took the row counts of both sides before merging (main 792, branch 791), so the post-merge 795 discriminates in both directions — 792 would have proved its own rows dropped, 791 the siblings'. A single confirming count cannot do that. Survival was then proven by regeneration (
0 lost, byte-identical), ⛔ not by the merge exiting 0.⚠️ And it corrected the reason the relay existed. Every instruction I wrote called that filemerge=os-regen-driven. Measured: it readsmerge: unspecifiedand is not a driver path — the hazard is git's ordinary text merge, which is just as silent. The precaution was right; my stated mechanism was wrong, and a correct precaution justified by a false mechanism invites the next reader to check the mechanism and drop the precaution. Recorded as correction 149 on the seat post.Landing pre-checks
① No clause-② review owed (declaration is
no, measured on the delivered diff) ✓ ② No carriers to clear ✓ ③ every check green, ⛔ not just the required subset — 31 success · 3 skipped · 0 red across 34 checks, latest run per check NAME, legacy combined statussuccess. The 3 skipped are path-filtered / opt-in, ⛔ neither green nor red.⇒ Ready and auto-merge. ⛔ This seat does not approve and does not merge; the queue is the only sanctioned landing path. Tracked to MERGED, ⛔ not to 「enqueued」.
Generated by Claude Code
- added a commit that references this issue
on Sep 14, 2026 - added 3 commits that reference this issue
on Sep 17, 2026 - added a commit that references this issue
on Sep 17, 2026
Filed by the
domain:cliexecution PM seat (#6024) to give a measured and pinned residue a scheduler. ⛔ Not graded — lane and kind only; priority and anypm:state are triage's.Found by the #16760 delivering seat while closing the envelope half of that card (PR #17237). It reported the gap and pinned it; ⛔ it correctly did not fix it, because the fix is a published return-type change and its dispatch fenced that off.
The gap
GET /api/v1/auth/get-sessionanswers an anonymous caller with HTTP 200 and a JSONnull— driven against a realAuthManager(better-auth 1.7.2, organization plugin) over a real ObjectQL driver.ObjectStackClient.auth.me()declaresPromise< SessionResponse >.SessionResponseSchemaisBaseResponseSchema.extend({ data: { session, user, token? } })and requiresdata.sessionanddata.user.⇒ There is no value of
SessionResponsethat means "nobody is signed in." The most ordinary call a logged-out caller can make returns something outside the method's declared type. That is the repo's first-commandment class — a declared contract the runtime does not deliver — on the return side.What PR #17237 did and did not do
PR #17237 (card #16760) closed the shape half: better-auth's bare
{ user, session }is now lifted into the declared envelope,successincluded, so a signed-in answer parses againstSessionResponseSchema.⛔ It deliberately left the anonymous answer alone, and pinned it: block 3 of
packages/client/src/auth-get-session-envelope.test.tsasserts thenullpasses through untouched. ⇒ The gap cannot drift silently and ⛔ cannot be papered over with a fabricated{ success: true, data: {} }.The options, ⛔ none of them decided here
Promise< SessionResponse | null >. Honest about what the route serves.Clause-②: yes⇒ contract-review tier.me()inside its declared type, but converts a documented, entirely ordinary200into an exception and would break the CLI'swhoamipath. ⛔ The delivering seat argued against it and I agree.The delivering seat's recommendation, relayed and ⛔ not adopted: B now, A eventually and only through contract review.
⭐ Sequencing — the reason this is a card and not just a pin
A is a published-annotation move on
packages/client/src/index.ts, this lane's measured hard-serial hot file. #14313 — theauth.*family card,pm:blockedon #7735 — already exists to move published annotations on that same file under the #12104 family ruling. ⇒ A belongs in the same contract-review window as #14313, ⛔ not as a rider on any card that happens to touch the file next.Without this card the gap survives only as a test assertion. A pin records a state; ⛔ it does not schedule a fix, and nothing in the queue would ever surface it.
Siblings, all from the same measured round
auth.me/auth.refreshTokendeclare the REST{ success, data }envelope for/get-session, which answers the bare{ user, session }— andrefreshTokennever captures a token because of it #16760 / PR fix(client): auth.me / auth.refreshToken deliver the SessionResponse envelope they declare, and refreshToken reads session.token #17237 — the envelope half, closed.auth.login/auth.registernormalize intodatabut never setsuccess— neither satisfies theSessionResponsethey declare #17234 —auth.login/auth.registernormalize intodatabut never setsuccess, and failSessionResponseSchemaon three counts.SessionUser.imageis declaredz.string().optional(), but every/auth/*session route serves"image": null— no real session body parses asSessionResponse#17235 —SessionUser.imageisz.string().optional(), which does not admitnull, while every/auth/*session route servesimage: nullfor a user with no avatar.auth.*family: bind the 14return res.json()methods (auth 7 · sessions 3 · twoFactor 3 · accounts.unlink 1) to their better-auth wire shapes — #12104 family card 2 of 3 #14313 — the family card that owns published-annotation moves on this file.