Repository navigation
client SDK auth.login / auth.register normalize into data but never set success — neither satisfies the SessionResponse they declare #17234
Description
Found while implementing #16760, which fixed the two SIBLING methods on the same route family (auth.me, auth.refreshToken). Out of that card's ruled scope — the dispatch explicitly bounded it to those two methods and directed this to be filed separately — so recorded here rather than fixed there. No assignee, no priority, no grade: left for triage.
The contract these methods declare
packages/client/src/index.ts, both methods annotate their return as SessionResponse:
auth.login—POST /api/v1/auth/sign-in/emailauth.register—POST /api/v1/auth/sign-up/email
SessionResponse is packages/spec/src/api/auth.zod.ts's SessionResponseSchema:
export const SessionResponseSchema = lazySchema(() => BaseResponseSchema.extend({
data: z.object({
session: SessionSchema.describe('Active Session Info'),
user: SessionUserSchema.describe('Current User Details'),
token: z.string().optional().describe('Bearer token if not using cookies'),
}),
}));and BaseResponseSchema (packages/spec/src/api/contract.zod.ts) declares success REQUIRED — no .optional(), no .default():
export const BaseResponseSchema = lazySchema(() => z.object({
success: z.boolean().describe('Operation success status'),
...
}));So the declared type requires success: boolean and data.session.
What the methods actually produce
Both carry the same normalization, which fills data from better-auth's bare { token, user } and stops there:
const data = raw && (raw.data ?? (raw.token || raw.user ? { token: raw.token, user: raw.user } : undefined));
const normalized = data ? { ...raw, data } : raw;No success is ever written, and the data it builds carries token and user but no session.
Measured
Driven through a real AuthManager (better-auth 1.7.2, organization plugin) over a real ObjectQL on a real SqliteWasmDriver, with an ObjectStackClient whose fetch is that manager:
register -> top-level keys ["token","user","data"] success === undefined
login -> top-level keys ["redirect","token","user","data"] success === undefined
login -> data keys ["token","user"]
SessionResponseSchema.safeParse on each, identical issue lists:
success : Invalid input: expected boolean, received undefined
data.session : Invalid input: expected object, received undefined
data.user.image : Invalid input: expected string, received null
BaseResponseSchema.safeParse alone fails on success for both.
Class
Declared contract not delivered (b). Two independent departures, and the second is the one triage did not anticipate when it flagged this:
successis absent. Every consumer keying on the envelope flag —ObjectStackClient.unwrapResponsekeys on exactly this — seesundefined, notfalse, and nottrue.data.sessionis absent. The declared payload namessessionas required, and these two routes serve none: better-auth's sign-in and sign-up answer{ token, user }. So a caller following the declared type toresult.data.session.expiresAtgets a TypeError, not a wrong value.
The third issue in the list, data.user.image, is a separate declaration problem filed on its own and is NOT specific to these two methods.
Relationship to #16760
#16760 fixed auth.me / auth.refreshToken on GET /get-session by lifting the bare answer into the declared envelope, and its lift DOES fill success. It deliberately did not touch login / register. Net effect today: within one auth.* family, two methods deliver the declared envelope and two do not.
A fix here has a decision in it that #16760's did not: /sign-in/email and /sign-up/email genuinely have no session object to put under data.session, so satisfying the declared type means either fetching the session, or changing the declaration for these two methods. That is why this is filed rather than fixed as a rider.
Refs: #16760 (where it was measured), #14313, #12104 (family head).
Generated by Claude Code
Activity
claude commentedon Sep 12, 2026
os-dev-report
{ "issue": 17234, "status": "done", "branch": "claude/issue-17234-auth-login-register-envelope", "pr": "https://github.com/objectstack-ai/objectstack/pull/17791", "premise_still_valid": true, "summary": "Re-measured all three premise faces against origin/main (15805ea3) before writing code; all three read as the dispatch recorded them. `auth.login` and `auth.register` now run the SAME `normalizeSessionResponse` lift `auth.me` / `auth.refreshToken` use, instead of a second inline copy that filled `data` and never wrote `success`; the helper was extended to carry a body's own top-level `token` into `data.token` and to copy only the members a body really has, so routing them through it does not drop the credential (the trap the dispatch flagged). Departure 1 (`success`) is CLOSED, judged by a parse against the declaration on the value each method returns. Departure 2 (`data.session`) is NOT closed and nothing was synthesized: measured on a real AuthManager, /sign-in/email answers {redirect,token,user} and /sign-up/email answers {token,user}; no session object, id or expiry reaches the client in body or header, and SessionSchema requires id + expiresAt, which are nowhere on these calls. The value is obtainable only via a SECOND call to GET /get-session. So the PR body carries `Refs #17234`, NOT a closing keyword, and states the undelivered half plainly. DO NOT CLOSE this card on merge. packages/spec is untouched. One docblock correction rode along under the dispatch's same-file / same-subject authorisation and is declared in the PR body (details in out_of_scope_findings).", "tests": "NEW: packages/client/src/auth-login-register-envelope.test.ts -- 12 driven cases, real AuthManager (better-auth 1.7.3, organization plugin) over real ObjectQL on real SqliteWasmDriver, ObjectStackClient whose fetch is the manager and which keeps a CLONE of each wire Response so bytes and return value come from one call. || BEFORE (on origin/main): register yields keys [token,user,data], success===undefined; login yields keys [redirect,token,user,data], success===undefined; SessionResponseSchema.safeParse issues for BOTH = [success, data.session, data.user.image]. || AFTER: success===true on both, BaseResponseSchema.safeParse ok on both, safeParse issue paths for BOTH = [data.session, data.user.image], pinned EXHAUSTIVELY so a `success` regression reappears as a third issue. || NEGATIVE CONTROL (block 3, can fail): the value the method really returned with `success` taken back out reports [success, data.session, data.user.image] again, so the instrument still sees the defect. || CREDENTIAL (block 4): res.data.token === wireBody.token byte-identical on both methods; client.token === wireBody.token (auto-set still fires); principalFor(client.token) === res.data.user.id; principalFor('not-the-session-token-17234') === null. Also pinned: the body token is the UNSIGNED spelling, and the set-auth-token HEADER is signed = token + '.' + signature. || ABLATION (reverse verification, from a COMMITTED state): mutation = drop `success: true` from the lift's return. On-disk proof: anchor grep count 1 to 0, replacement 0 to 1, blob a4d0655f to a4ac6283 (proved changed, not an exit-0 no-op edit). Mutated result: 'Tests 6 failed | 12 passed (18)', VERDICT command-exit 1; 4 are this PR's blocks 1 and 2, and 2 are #16760's own auth-get-session-envelope.test.ts, so the one shared lift is load-bearing for both families. Blocks 3/4/5/6 stayed green, which is the correct direction. RESTORE leg: `git checkout HEAD -- packages/client/src/index.ts`; blob back to a4d0655f, `git diff HEAD` empty, `git status --porcelain` empty, anchor count back to 1; trap on EXIT/INT/TERM with an absolute REPO_ROOT path. Re-run after restore: 518 passed (518), VERDICT command-exit 0. || COMMANDS (verdict line quoted; exit captured before any pipe): pnpm --filter '@objectstack/client^...' build :: VERDICT command-exit 0. pnpm --filter @objectstack/client test :: VERDICT command-exit 0, 43 files / 518 tests passed. pnpm --filter @objectstack/client typecheck :: VERDICT command-exit 0, tsc --noEmit clean plus check:test-typecheck 0 files / 0 errors in the shrink-only debt ledger. pnpm build (full) :: VERDICT command-exit 0, 73/73 tasks. pnpm lint (eslint . --no-inline-config, REPO-WIDE) :: exit 0 at a0ab9789e, the whole population, so NO narrowing is claimed and none is needed. || GATE FAMILIES derived from the real change set via scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, then reconciled with --ran carrying exit codes: 60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero: all 60 recorded an exit code and none is 3). THREE were re-run because their first result was not a measurement, and are reported at their real verdict: check:skill-examples PREREQUISITE NOT MET (client-react/dist absent), then exit 0 with 258 prose examples type-checking incl. 23 client-SDK blocks; check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (34 packages unbuilt), then exit 0 with 104 require entry points across 67 packages loading; check:type-check-debt exit 3 FATAL heap limit caused by MY OWN NODE_OPTIONS=--max-old-space-size=4096 being tighter than the gate's own ceiling (the gate says so in its output), then exit 0 with 55 raw tsc errors, none above its recorded number. None of the three was reported as a pass on its first result. || #16760 UNDISTURBED: `git show` of auth.me (7 lines) and auth.refreshToken (14 lines) at merge base 15805ea32 vs HEAD is byte-identical; auth-get-session-envelope.test.ts is absent from `git diff --name-only` and green. || DOCS: no change owed, measured. At origin/main 8fa3fe63, `git grep -c 'data.session' -- content/docs` = 0 hits (exit 1) against a live positive control `data.user` = 3 hits. The only page reading these calls (content/docs/permissions/authentication.mdx) reads result.data.user / result.data.token / session.data.user, all three preserved. Nothing under content/docs/releases/ touched. || NOT MEASURED: nothing in the derived family. CI convergence on PR #17791 is not awaited (PM's half).", "mcp_calls": "0 -- every GitHub read and write went through repo-scoped REST (probe: GET issues/17234 HTTP 200, rate_limit 15000/hr) plus git. No MCP GitHub tool was called, and no duplicate search was needed because nothing was filed The clause-2 carrier check made 3 REST reads of its own.", "open_questions": [ { "question": "How should `data.session` be satisfied for auth.login / auth.register? Measured: neither route serves a session object, id or expiry in body or header, and SessionSchema requires id + expiresAt, so the member cannot be produced from what these two calls return. This shapes a published contract, so it is not mine to decide, and packages/spec was not touched.", "options": [ "A -- make login()/register() issue a SECOND request to GET /get-session and merge its session into data.session. Cost: two round trips on the SDK's hottest path, and a login that now fails if the follow-up fails; a behaviour change no ruling authorises today. Benefit: the declared type is delivered verbatim, so callers of result.data.session.expiresAt work.", "B -- narrow the declaration for these two methods (a sign-in/sign-up response type whose data is { user, token } with session absent), leaving SessionResponse for /get-session. Cost: a packages/spec edit, so the domain:spec seat; a published-shape change needing an ADR-0087 disposition and a changeset migration note. Benefit: the type finally describes what the routes really send, with no extra network call.", "C -- leave it as measured residue, pinned. Cost: login/register keep advertising a member they never deliver, so a caller following the declared type to result.data.session.expiresAt still gets a TypeError. Benefit: zero risk now." ], "recommendation": "B, routed to the domain:spec seat. Prime Directive #12 is contract-first: when the declaration and the producer disagree and the producer is correct (these routes genuinely have no session), the spec is the thing that is wrong, and fixing it at the declaration beats making the consumer do extra I/O to satisfy a shape nobody asked for. A also converts a one-call method into a two-call one, changing failure semantics on the SDK's most-used path for a member no published doc even reads (measured: zero data.session references in content/docs). C is the status quo the card exists to end. Note that B is exactly what the dispatch ruled out for THIS seat, so it needs a spec-seat card either way." } ], "out_of_scope_findings": [ "FOR THE PM SEAT, reported and deliberately NOT acted on: `node scripts/pm/check-clause2-carriers.mjs --pair 17791` exits 2 -- UNJUDGED. The PR-side carrier is fine (bare `Clause-②: no` at column 0, verified in the stored body). The CARD-side carrier cannot be resolved: the dispatch comment 5643501154 matches the claim marker but its branch is named inside the `Claim:` sentence, and the reader needs a `Branch:` line of its own, so it parses to ZERO branches. The script's own remedy assigns this to the CLAIMING SEAT with one comment, and warns that a whole shift reading this way is a SEAT TEMPLATE fault rather than a typo -- so every card dispatched with this template is likely UNJUDGED too. I did not post it: the dispatch forbids me a second `Claim:` comment, and the script is report-only and forbids writing a label from it (自查放行). Not filed as a card either: the owner is the seat reading this report, and the fix is one comment.", "corrected in this PR (dispatch-authorised: same file, same docblock, same subject): the normalizeSessionResponse docblock claimed the token login puts at data.token is the SIGNED token.signature form. Measured false -- the response BODY's token and the session.token a following /get-session serves are the SAME unsigned string, while the signed form is what bearer() publishes in the set-auth-token HEADER. The rule it justified (never synthesize data.token from a session) is unchanged and now rests on correct ground; the corrected reading is pinned by a case in block 4 so it cannot rot again.", "corrected in this PR (the dispatch directed this check explicitly): the same docblock said auth.login 'has carried the same lift since long before this card'. That was loose -- login's inline copy filled data and never success, which IS this defect. After this PR it is literally the same lift, and the sentence says so.", "noted, not filed: login's own 'if (!res.ok) { ... throw ... }' block is unreachable. ObjectStackClient.fetch already throws on every non-2xx before login can inspect res.ok (observed directly: a SELF_REGISTRATION_CLOSED sign-up threw from fetch and never reached the caller's branch). Dead code, which is explicitly not a filing class. Successor: whoever converges the SDK's two error envelopes -- #3843 is the line that would touch this function.", "noted, not filed: the first sign-up on a fresh environment provisions the owner and the audience posture then closes self-registration, so a second register() against the same AuthManager is refused with SELF_REGISTRATION_CLOSED. Correct behaviour, not a defect, but a real trap for anyone writing a driven multi-user auth test -- recorded in the new suite's own comments so the next author does not lose a cycle to it. Successor: the next author writing a driven auth test in packages/client.", "noted, not filed: the card header says better-auth 1.7.2; the family was lifted to 1.7.3 by #17454. A stale version in a card body is a doc nit, not a filing class -- corrected in the PR body and in the docblock instead.", "NOT folded, deliberately: #17238 (anonymous caller) is a different defect in the domain:services lane and is unruled; #17235 (data.user.image served as null) is a separate declaration problem. Both stay out, and #17235 remains pinned as residue by block 2." ] }Generated by Claude Code
claude commentedon Sep 12, 2026
Clause-②: no
Card-side clause-② carrier, owed by this seat and missing until now.
domain:cliexecution seat (#6024), 2026-09-12T05:52Z. This is the declaration limb for card #17234 / PR #17791.Why this comment exists — a seat-template fault, measured
node scripts/pm/check-clause2-carriers.mjs --pair 17791reports exit 4, NOT clause-② legible, and names the card side:The dispatch order at⚠️ ⛔ A missing reading is not a declared
5643501154mentions the key only inside a prose bullet instructing the dev about the PR body — so the card side declared nothing at all.no: one is a decision, the other is an absent one. This comment supplies the decision.⭐ This is a template regression in this seat's own claim format, ⛔ not a typo on one card. An earlier claim from this seat (
5634883930on #17329) carried a bareClause-②:line on its own; the compact one-line claim used this round dropped it. #17620 is dispatched with the same template and is being fixed in the same pass.Branch:line making the claim parse to zero branches, and reported the script exiting 2 / UNJUDGED. Run here, the script exits 4 and names the declaration limb, not branch parsing. The finding was right and valuable; the mechanism it named was not, and the remedy differs. ⭐ Reported out rather than quietly corrected, because a wrong mechanism sends the next reader to the wrong line.The judgement —
no, and whyThe dispatched work makes
auth.login/auth.registersatisfy a type they already advertise.SessionResponseSchemaandBaseResponseSchemaare untouched; nothing authorable moves; no flag, route, envelope code or exit vocabulary is added. Filling asuccessthe declaration already requires is the 拉回已声明契约 direction, which is the non-floor side of the mechanical boundary test. ⇒no.successhalf. The still-undelivered half (data.session) is measured unobtainable on these two routes without a second call, and how to resolve it is a published-contract question this seat ⛔ does not answer. That half goes to the decision box with its own four-axis block, and whatever is dispatched for it later carries its own clause-② declaration. ⛔ Thisnodoes not travel to it.Generated by Claude Code
claude commentedon Sep 12, 2026
Half landed —
01388fe819c827454de8d6453e1dda20c7c044c0Release: session
session_01TSf4DV7ziu4V5j73e46b7c· cause: the dispatched half landed onmain; the remaining half is not a seat's to close · destination:needs-user-decision, assignee cleared andpm:dispatchedreplaced in the same write.domain:cliexecution seat (#6024), 2026-09-12T06:12Z.PR #17791 merged 2026-09-12T06:11:27Z through the merge queue. Verdict of record:
5643955702.Refs, not a closing keyword, because only thesuccessdeparture is delivered. Thedata.sessiondeparture is measured, undelivered, and above this seat's authority; it is converted to a decision in the comment that follows this one.Landing verified — two readings plus controls
merge_commit_shathis PR carried while open — that field reada34f3de8faat 06:10Z and the landing is01388fe8. Ninth time this lane has measured that field moving; an open PR's value is a candidate.git rev-list --parents -n 1 01388fe8git merge-base --is-ancestor 01388fe8 origin/maina0ab9789efgit log -1 --format=%sfix(client): auth.login / auth.register deliver the SessionResponse envelope they declare (#17791)Reading 2 — content on
origin/main, predicates taken from what the PR committed tologinandregisterboth callnormalizeSessionResponseraw.data ?? (raw.token …absent)this.token = normalized.data.tokenpresent in bothsuccessand carries the credentialsuccess: trueanddata.token = body.tokenpresentinpredicate can fail⭐ And the selector trap from the review was re-checked on the landed tree rather than assumed fixed:
register: asyncstill occurs twice in this file, so theauth.registerreading was taken from the occurrence containingsign-up/email, not the first match. A reading that was right once is not automatically right on the next tree.What is closed, and what is not
success— closed. Both methods now deliver the flagBaseResponseSchemadeclares required, judged by a parse against the declaration on the value each method actually returns.data.session— open, and now a decision. Measured absent from both routes in body and header;SessionSchemarequiresidandexpiresAt, neither of which reaches the client on these calls.Residue that stays pinned by
packages/client/src/auth-login-register-envelope.test.tsrather than being forgotten:data.user.imageserved asnull(#17235, a separate declaration problem) and the anonymous-caller case (#17238,domain:services, unruled — ⛔ never folded here).Generated by Claude Code
claude commentedon Sep 12, 2026
维护者速读
@objectstack/client的auth.login和auth.register对外声明:调用之后你会拿到一个 session 对象(里面有会话 id 和过期时间)。这两个接口从来没给过。 服务端的登录和注册只回一个令牌加一个用户,连 session 的影子都没有 —— 不在 body 里,也不在响应头里(实测)。结果是:照着类型提示写
result.data.session.expiresAt的人,拿到的不是"过期时间不对",而是程序当场崩。刚合并的那个 PR 已经把另外半边(success字段缺失)修好了,剩下的就是这一条,而它修不了 —— 因为要么改行为、要么改承诺,两条路都超出执行席的权限。三条路,代价是真的:
packages/spec(已发布的契约),得走正式的改契约流程,并且归另一个席位做。我的建议是 B:不是"少干活",而是目前的声明本身是错的 —— 那两条路由真的没有 session,让 SDK 多跑一趟去凑齐一个没人读过的字段(实测:全站文档里
data.session引用数为 0),是拿运行时成本去补一句写错的话。选 A、B 还是 C?
Background
Card #17234 named two departures from the
SessionResponsethese two methods declare. PR #17791 closed the first (successwas never written) and deliberately usedRefs, not a closing keyword, because the second is not a seat's to close. This comment converts the card to the decision box for that second half.Governing text
AGENTS.md:230, Prime Directive #12 — Contract-first, quoted verbatim:⇒ the directive names the test (is the spec itself genuinely wrong?) and the cost of answering yes (deliberately — edit the Zod schema + migrate). It does not answer this case, which is why this is here rather than self-adjudicated. Found by
git grep -n -i 'contract-first\|Prime Directive' origin/main -- AGENTS.md; control on the same corpus:changeset→ 16 hits ⇒ the search is live.Protocol declaration
Option B changes
packages/spec⇒ 「凡触packages/spec一律转domain:spec座位,不论谁需要它」, so B is executed by that seat, not this one, whoever asks for it. Options A and C do not change the protocol. All three are above this seat's confidence gate: a published contract's shape is a manual floor.Premise, with its re-check commands
Measured by the dev on a real
AuthManager(better-auth 1.7.3, organization plugin) over a realObjectQLon a realSqliteWasmDriver, and pinned inpackages/client/src/auth-login-register-envelope.test.tsso it can be re-driven rather than re-argued:/sign-up/emailanswers['token','user']pnpm --filter @objectstack/client test auth-login-register-envelope/sign-in/emailanswers['redirect','token','user']/session/i;set-auth-tokenis the signed token, andSessionSchema.safeParseof it failsSessionSchemarequiresidandexpiresAtgit show origin/main:packages/spec/src/api/auth.zod.tsgit grep -c 'data\.session' origin/main -- content/docs→ 0 hits, against a live controldata.user→ 3⇒ the member cannot be produced from what these two calls return. ⛔ Not "hard" — absent.
The question
auth.login/auth.registerdeclaredata.sessionand cannot deliver it. Which way does the disagreement get resolved?Options × real cost
login()/register()issue a second request toGET /get-sessionand merge its session intodata.sessiondatais{ user, token }), leavingSessionResponseto/get-sessionpackages/specedit, an ADR-0087 disposition and a migration note, executed by thedomain:specseatresult.data.session.expiresAtstill gets a TypeError, not a wrong value业务含义直译
四轴(业务立场)
result.data.session.*的人,拿到的是崩溃(生产环境);而实测没有任何一页已发布文档教人去读它(data.session全站 0 命中,对照词 3 命中)⇒ 拉动来自类型提示,不是来自文档或用户请求。A 的那次额外请求,是在为一个零文档引用的字段付每次登录的成本。TypeError,而且是在用户点了登录之后 —— 静默承诺 + 晚爆。A 表面上都不报错,但把失败挪到"第二次请求挂了所以登录失败",错误现场和真实原因隔了一层,最难查。推荐 + 回退 + 置信缺口
推荐 B,理由是四轴同向,且 Prime Directive #12 的那一问在这里有明确答案:这两条路由真的没有 session,所以是声明写错了而不是实现偷懒。回退是 C —— 如果你判断现在不值得动已发布契约,C 是安全的停车位,残留已经被测试钉住,随时可以再拿起来;⛔ 回退不是 A,因为 A 一旦落地就改变了登录的失败语义,回退它比回退 C 贵得多。
result.data.session—— 本次只量了本仓content/docs,读不到下游代码;② B 的迁移成本没有实测,domain:spec席对 ADR-0087 处置的估价可能改变 B 的性价比;③ 是否存在一条尚未提出的第四条路(例如让/sign-in/email自己在回包里带上 session),那属于服务端路由的形状,不在本卡测过的范围内。Post-ruling execution
packages/client/src/index.tsplus driven tests, and the PR must state the new failure semantics in its changeset.domain:specqueue carrying the measurement above and aBlocked-by:back-link; this card turnspm:blockedagainst it in the same write.not_plannedwith the measurement as the reason, and the residue stays pinned byauth-login-register-envelope.test.tsso it cannot rot silently.Related
#16760 (the sibling methods, fixed), PR #17791 (the
successhalf, this card's own), #17238 (the anonymous-caller case —domain:services, unruled, ⛔ do not fold), #17235 (data.user.imageserved asnull— separate declaration problem, pinned as residue by the same test).os-decision-facets
result.data.session.*的调用方(生产崩溃);实测零文档引用,所以拉动来自类型提示而非用户请求。TypeError;A 谁都不报错,却把失败伪装成"第二次请求挂了"。推荐:B(narrow the declaration,交
domain:spec席执行)。选项:A / B / C。data.session;B 的迁移成本未实测;未穷尽第四条路(让路由自己回带 session)。Generated by Claude Code
os-tesla commentedon Sep 13, 2026
Ruling recorded — A′: the sign-in and sign-up routes answer the declared
SessionResponseby attaching the session the server just created; the contract stands (director seat, decision batch #125 item 4, 2026-09-13)Maintainer, verbatim (live PM chat, reply received 2026-09-13T02:2xZ), to decision batch #125 presented as
1B·2A′·3A·4A′·5B: 「同意」.Derived first from the long-term axis, on the same line as #17238: the protocol is the target and the implementation is corrected to it. Returning the session on sign-in is the mainstream shape; the server holds the session it just created (the token is its credential), so one request can answer the declared envelope. A (a second client round trip) pays at runtime for what the server can hand over; B (rewrite the contract to what the routes happen to return) bends the contract to the implementation; C keeps a promise the type cannot keep.
Execution
packages/plugins/plugin-auth: the/sign-in/emailand/sign-up/emailroute wrappers look up the session created for the returned token and answer{ success: true, data: { session, user, token } }; if better-auth cannot yield the session synchronously after sign-in, stop and report — then B is the honest fallback, ruled here in advance.packages/client'sauth.login/auth.registerkeep their declared return type; the envelope test (auth-login-register-envelope.test.ts) flips from pinning the residue to pinning the fix.auth.me()returns the literalnullfor an anonymous caller, which no value of its declaredSessionResponsecan express #17238 (B) — one round on the auth route family.Clause-②: no(contract unchanged; implementation meets it).State
needs-user-decision→pm:queue; lanedomain:cli→domain:services(the fix is in plugin-auth);auth/bug/priority:p2kept.Generated by Claude Code
os-project-manager commentedon Sep 13, 2026
Claim: PM loop round 4
Session:
session_01URLHobLUJB9K1ABV6ofdjjBranch:
claude/issue-17234-signin-session-envelopeWorktree:
objectstack-issue-17234Domain:
domain:servicesFile surface:
packages/plugins/plugin-auth/src/**(the/sign-in/email·/sign-up/emailroute wrappers) ·packages/client/src/auth-login-register-envelope.test.ts(the envelope test flips from pinning the residue to pinning the fix) + test siblings (stop on breach; explain in the report)Container & model:
S,mode:subagent,model: default judgement tier — this run's dispatch-gates --tier derives no path mandate, so the tier is this seat's per-card callClause-②: no
Thread-read: 5621312285 · 5643501154 · 5643862019 · 5643904057 · 5644112299 · 5644113024 · 5650203205
Dispatched under the ruling A′ (director seat, decision batch #125 item 4, maintainer 「同意」,
5650203205). The ruling is the fence, not a suggestion, and it carries its own pre-authorised fallback:Clause-②: nois the RULING's declaration («contract unchanged; implementation meets it»), and this seat carries it because 维护者裁决 outranks 座位判断. ⛔ But it is not a blanket: the ruling speaks to the contract, while the mechanical floor tests for new exported symbols. This seat declarednoon #15556 this same session and was wrong for exactly that reason — the contract was untouched and the implementation invented published surface (correction 154). ⇒ If the delivered diff adds any exported symbol or a new key on a published payload, stop and say so in the report; this seat will re-derive from the diff and amend the declaration in place. ⛔ Do not argue wire-compatibility as a Clause-② defence — true, and the wrong axis.closed/completed, landed in R1 via PR #17881,updated_at2026-09-12T19:43Z — i.e. before this ruling was written. ⇒ There is no sibling to batch with and nothing on #17238 to do. ⭐ This is correction 142's exact shape («a ruling's sibling reference is not a live fact»), and correction 142 was itself recorded about this card — so it is being applied to the card that taught it.Serial constraints cleared:
File-disjoint from this lane's only other live PR. #17908 (card #15556, BLOCKED on #17909) touches plugin-approvals/src/approval-service.ts and service-automation/src/engine.ts — no overlap with plugin-auth. ⚠️ #15970 remains unclaimed and untouched. The merge queue holds nothing of this lane's.domain:servicesexecution seat · seat post #6021 · 2026-09-13T02:3xZGenerated by Claude Code
fix(plugin-auth): attach the session /sign-in|sign-up/email already c…
os-project-manager commentedon Sep 13, 2026
os-dev-report
{ "issue": 17234, "status": "done", "branch": "claude/issue-17234-signin-session-envelope", "pr": "https://github.com/objectstack-ai/objectstack/pull/17922", "premise_still_valid": true, "summary": "better-auth DOES yield the session synchronously inside the route wrapper, so ruled fallback B was not needed. Measured on real better-auth 1.7.3: internalAdapter.createSession is awaited to completion (the DB write included) before /sign-in/email or /sign-up/email return their {token,user} body, so the global after-hook can read the row back by the response's own token via internalAdapter.findSession -- the same seam /get-session already uses. Implemented as a new file packages/plugins/plugin-auth/src/session-envelope-completion.ts (attachSessionToCredentialResponse), wired into auth-manager.ts's existing after hook right beside two-factor-rotated-token-echo.ts's response-completion step. packages/client needed zero code changes: normalizeSessionResponse already lifts body.session into data.session when present. Both #17234 departures (success, closed in the prior round; data.session, closed here) are now closed, so the PR uses Fixes #17234.", "tests": "DRIVEN, real AuthManager (better-auth 1.7.3, organization plugin default-on) over real ObjectQL on real SqliteWasmDriver, ObjectStackClient.fetch handed straight to AuthManager.handleRequest -- packages/client/src/auth-login-register-envelope.test.ts. BEFORE (measured via reverse-verification, see ablation below) and per the dispatch's own prior-round measurement: SessionResponseSchema.safeParse issue paths on both auth.login() and auth.register()'s return value = ['data.session','data.user.image']; /sign-up/email answered keys exactly ['token','user'], /sign-in/email exactly ['redirect','token','user']; no session anywhere in body or headers. AFTER (measured live via a driven probe script against the real AuthManager, then pinned in the suite): /sign-up/email now answers ['token','user','session'], /sign-in/email ['redirect','token','user','session']; SessionResponseSchema.safeParse issue paths on both methods = ['data.user.image'] only (#17235, out of scope, still pinned) -- the data.session issue is fully gone on both. || CONTROLS: (a) negative control on the same instrument -- a body fabricated by stripping `session` back out of the real returned value still fails the parse, reporting ['data.session','data.user.image'] again (new test in block 3); SessionSchema.safeParse also still fails on {}, undefined, and a partial object missing expiresAt/userId. (b) data.token byte-identical before/after (proved against the WIRE body on the same call) and client.token still auto-set from it -- both pre-existing pins kept green, unchanged. || SESSION SHAPE: the attached session parses as SessionSchema (id + expiresAt + userId all present, expiresAt a valid ISO datetime string -- better-auth's supportsDates:false adapter path converts the DB's Date object automatically, same mechanism /get-session already relies on); session.userId matches the acting user; session.token equals the pre-existing data.token (no second credential introduced); and it is the SAME row (matching id, expiresAt, userId) a following client.auth.me() call reads back through /get-session -- proof this is a read, not an invention. || auth.me / auth.refreshToken (#16760) UNTOUCHED: packages/client/src/index.ts has zero diff (confirmed by git status/git diff --stat across this whole change -- only auth-manager.ts, the new session-envelope-completion.ts, the envelope test, and the changeset changed); packages/client/src/auth-get-session-envelope.test.ts reruns green unchanged (1 file / 7 tests passed). || ABLATION (reverse verification, from the already-committed fix, trap-guarded, REPO_ROOT-absolute): mutate = `git checkout HEAD~1 -- packages/plugins/plugin-auth/src/auth-manager.ts` (removes the attachSessionToCredentialResponse call site; grep count confirmed 1->0); blob hash proved changed (a1c5491b...->f0362cce...); rebuilt plugin-auth on the mutated tree (exit 0); reran the envelope test: 7 failed / 8 passed (15) -- the 7 failures are exactly the ones asserting data.session (both residue-exhaustiveness cases in block 2, the success-negative-control in block 3, and 4 of block 5's 5 cases; the 5th, a generic SessionSchema negative control, correctly stayed green since it needs no production code). RESTORE: `git checkout HEAD -- PATH` (trap on EXIT/INT/TERM, absolute path); git diff HEAD empty, git status clean, blob hash back to a1c5491b...; rebuilt plugin-auth again (exit 0) and reran the suite: 15/15 green. || COMMANDS (verdict read before any pipe): pnpm --filter '@objectstack/plugin-auth^...' build :: exit 0 (dependency closure, ~6m21s under the shared verify lock). pnpm --filter @objectstack/plugin-auth build :: exit 0. pnpm --filter @objectstack/client exec vitest run --maxWorkers=2 src/auth-login-register-envelope.test.ts :: exit 0, 15/15 passed. pnpm --filter @objectstack/client exec vitest run --maxWorkers=2 src/auth-get-session-envelope.test.ts :: exit 0, 7/7 passed (#16760 unaffected). pnpm --filter @objectstack/plugin-auth test :: exit 0, 108 files / 2287 tests passed (full package suite, no regressions). pnpm --filter @objectstack/plugin-auth typecheck :: exit 0. pnpm --filter @objectstack/client typecheck :: exit 1 -- 9 PRE-EXISTING test-typecheck-debt failures in files this PR never touches (analytics-automation-json-erasure.test.ts and 8 others; repo's own gate text names this MAINTAINER-ONLY, issue #5286); confirmed pre-existing via git status showing zero changes to any of the 9 named files. || GATE FAMILY: `node scripts/pm/dispatch-gates.mjs --commands` (re-derived after the changeset existed) named 63 commands; all 63 run. 61 exit 0. 2 report PREREQUISITE NOT MET (exit 3), never a pass: check:dual-build-cjs-loads (dozens of packages have no dist/ in this fresh worktree; needs a full `pnpm build`) and check:type-check-debt --re-measure (needs 4 unbuilt workspace deps -- @objectstack/hono, @objectstack/runtime, @objectstack/service-cluster, @objectstack/service-job -- built first). Both are whole-repo build prerequisites this diff does not otherwise need and are left to CI per the repo's own local-verification-scope guidance. check:skill-examples and check:dual-build-cjs-loads/check:type-check-debt were each independently re-run standalone (no artificial per-command timeout) after two of the sweep's readings turned out to be my own 90s wrapper timeout (exit 124), not real failures: check:skill-examples now exits 0 after building packages/client + packages/client-react (258 prose examples type-check); check:query-options-erasure and check:slot-lookup both independently confirmed exit 0. One re-run (check:slot-lookup) was accidentally first run from the WRONG cwd (the shared main checkout, due to a `cd X && (cmd) &`/`(cmd2) &` two-job script losing the outer shell's directory across backgrounded jobs) -- caught via its own error text naming /home/user/objectstack instead of the worktree, and correctly rerun from the worktree (exit 0).", "mcp_calls": "4 -- 1 issue_read (get_comments, verifying the newest Claim: names this branch before posting), 1 create_pull_request, 1 pull_request_read (verifying the PR body survived byte-intact), 1 add_issue_comment (this report).", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: packages/client/src/index.ts's normalizeSessionResponse docblock (around the 'Known residue -- data.session on /sign-in|sign-up/email (#17234)' paragraph, ~line 1508) is now stale -- it says those two routes serve no session anywhere, which this PR makes false. This file is OUTSIDE the dispatched file surface (packages/plugins/plugin-auth/src/** and the client TEST file + siblings only; index.ts is not a test file), so it was left untouched per the fence rather than edited. Carrier: whoever next touches packages/client/src/index.ts's normalizeSessionResponse docblock, or a follow-up doc-accuracy pass on this same PR if the seat wants it folded in before merge.", "noted, not filed: this seat's earlier same-session Clause-2 correction (referenced in the claim comment as 'correction 154') does not apply here -- re-derived from the delivered diff and it holds: no new exported symbol (session-envelope-completion.ts's exports are not re-exported from plugin-auth's index.ts, confirmed by grep), no new public method, no new key on any published payload (SessionResponseSchema.data.session was already declared in @objectstack/spec before this card). Clause-2 stays 'no' as the ruling declared.", "the ruling's item 3 stale-sibling note (#17238 already closed, no batching needed) was already flagged correct in the claim comment; independently re-confirmed at PR-open time: no #17238 references anywhere in this diff, PR, or changeset." ] }Generated by Claude Code
fix(client): auth.me / auth.refreshToken deliver the SessionResponse …
fix(client): auth.login / auth.register deliver the SessionResponse e…
fix(plugin-auth)!: an anonymous get-session is refused with the decla…
fix(plugin-auth): attach the session sign-in/sign-up already committed (
docs(pm-dispatch): the claim comment carries the clause-② line at the…
fix(client): normalizeSessionResponse's JSDoc records the data.user.i…
fix(changeset): date the pending client entry's closing register — al…