Skip to content

client SDK auth.login / auth.register normalize into data but never set success — neither satisfies the SessionResponse they declare #17234

Closed

Description

@claude

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/email
  • auth.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:

  1. success is absent. Every consumer keying on the envelope flag — ObjectStackClient.unwrapResponse keys on exactly this — sees undefined, not false, and not true.
  2. data.session is absent. The declared payload names session as 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 to result.data.session.expiresAt gets 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

  1. claude commented on Sep 12, 2026

    @claude
    ContributorAuthor

    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

  2. claude commented on Sep 12, 2026

    @claude
    ContributorAuthor

    Clause-②: no

    Card-side clause-② carrier, owed by this seat and missing until now. domain:cli execution 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 17791 reports exit 4, NOT clause-② legible, and names the card side:

    NO READING on the declaration limb, and the PLACEMENT of the key is what is wrong, NOT its spelling: the thread carries the key in the fixed spelling … but not at the START of a line. … Remedy: put the Clause-②: yes|no line on a line of its OWN in the card's claim comment.

    The dispatch order at 5643501154 mentions the key only inside a prose bullet instructing the dev about the PR body — so the card side declared nothing at all. ⚠️ ⛔ A missing reading is not a declared 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 (5634883930 on #17329) carried a bare Clause-②: 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.

    ⚠️ One correction to the report that surfaced this. The dev diagnosed it as a missing 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 why

    The dispatched work makes auth.login / auth.register satisfy a type they already advertise. SessionResponseSchema and BaseResponseSchema are untouched; nothing authorable moves; no flag, route, envelope code or exit vocabulary is added. Filling a success the declaration already requires is the 拉回已声明契约 direction, which is the non-floor side of the mechanical boundary test. ⇒ no.

    ⚠️ Scope of this declaration. It covers the work this claim dispatched — the success half. 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. ⛔ This no does not travel to it.


    Generated by Claude Code

  3. claude commented on Sep 12, 2026

    @claude
    ContributorAuthor

    Half landed — 01388fe819c827454de8d6453e1dda20c7c044c0

    Release: session session_01TSf4DV7ziu4V5j73e46b7c · cause: the dispatched half landed on main; the remaining half is not a seat's to close · destination: needs-user-decision, assignee cleared and pm:dispatched replaced in the same write. domain:cli execution seat (#6024), 2026-09-12T06:12Z.

    PR #17791 merged 2026-09-12T06:11:27Z through the merge queue. Verdict of record: 5643955702.

    ⚠️ ⛔ The card is NOT closed and that is deliberate — the PR body carries Refs, not a closing keyword, because only the success departure is delivered. The data.session departure 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

    ⚠️ The sha above is ⛔ not the merge_commit_sha this PR carried while open — that field read a34f3de8fa at 06:10Z and the landing is 01388fe8. Ninth time this lane has measured that field moving; an open PR's value is a candidate.

    reading command result
    landing shape git rev-list --parents -n 1 01388fe8 2 fields ⇒ single-parent squash
    ancestry git merge-base --is-ancestor 01388fe8 origin/main exit 0
    control for that test same test on the pre-squash head a0ab9789ef exit 1 ⇒ the test can return false
    subject git log -1 --format=%s fix(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 to

    predicate result
    login and register both call normalizeSessionResponse true for both
    the second inline copy of the lift is gone true for both (raw.data ?? (raw.token … absent)
    the bearer auto-set survived this.token = normalized.data.token present in both
    the helper writes success and carries the credential success: true and data.token = body.token present
    fabricated control a line that does not exist tests False ⇒ the in predicate can fail

    ⭐ And the selector trap from the review was re-checked on the landed tree rather than assumed fixed: register: async still occurs twice in this file, so the auth.register reading was taken from the occurrence containing sign-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 flag BaseResponseSchema declares 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; SessionSchema requires id and expiresAt, neither of which reaches the client on these calls.

    Residue that stays pinned by packages/client/src/auth-login-register-envelope.test.ts rather than being forgotten: data.user.image served as null (#17235, a separate declaration problem) and the anonymous-caller case (#17238, domain:services, unruled — ⛔ never folded here).


    Generated by Claude Code

  4. claude commented on Sep 12, 2026

    @claude
    ContributorAuthor

    维护者速读

    @objectstack/client 的 auth.login 和 auth.register 对外声明:调用之后你会拿到一个 session 对象(里面有会话 id 和过期时间)。这两个接口从来没给过。 服务端的登录和注册只回一个令牌加一个用户,连 session 的影子都没有 —— 不在 body 里,也不在响应头里(实测)。

    结果是:照着类型提示写 result.data.session.expiresAt 的人,拿到的不是"过期时间不对",而是程序当场崩。刚合并的那个 PR 已经把另外半边(success 字段缺失)修好了,剩下的就是这一条,而它修不了 —— 因为要么改行为、要么改承诺,两条路都超出执行席的权限。

    三条路,代价是真的:

    • A:登录时多打一次请求把 session 取回来。承诺兑现了,但 SDK 用得最多的那条路从此变成两次网络往返,而且第二次失败会把整个登录也拖失败。
    • B:把承诺改成实话 —— 声明登录/注册返回的是「用户 + 令牌」,session 留给专门查会话的那个接口。没有额外请求,但要动 packages/spec(已发布的契约),得走正式的改契约流程,并且归另一个席位做。
    • C:什么都不做,把这条写进已知残留。今天零成本,但那句假承诺继续挂在类型里,下一个照着写的人还是会崩。

    我的建议是 B:不是"少干活",而是目前的声明本身是错的 —— 那两条路由真的没有 session,让 SDK 多跑一趟去凑齐一个没人读过的字段(实测:全站文档里 data.session 引用数为 0),是拿运行时成本去补一句写错的话。

    选 A、B 还是 C?


    Background

    Card #17234 named two departures from the SessionResponse these two methods declare. PR #17791 closed the first (success was never written) and deliberately used Refs, 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:

    When a piece of metadata "doesn't work," ask first: is it spec-compliant? is this the long-term-correct direction? … Change the spec only when the spec itself is genuinely wrong, and then deliberately (edit the Zod schema + migrate).

    ⇒ 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 real ObjectQL on a real SqliteWasmDriver, and pinned in packages/client/src/auth-login-register-envelope.test.ts so it can be re-driven rather than re-argued:

    reading result re-check
    what /sign-up/email answers keys exactly ['token','user'] pnpm --filter @objectstack/client test auth-login-register-envelope
    what /sign-in/email answers keys exactly ['redirect','token','user'] same
    any session in the response headers no header name matches /session/i; set-auth-token is the signed token, and SessionSchema.safeParse of it fails same
    what the declared type requires SessionSchema requires id and expiresAt git show origin/main:packages/spec/src/api/auth.zod.ts
    does any published page tell a reader to read it git grep -c 'data\.session' origin/main -- content/docs → 0 hits, against a live control data.user → 3 as written

    ⇒ the member cannot be produced from what these two calls return. ⛔ Not "hard" — absent.

    The question

    auth.login / auth.register declare data.session and cannot deliver it. Which way does the disagreement get resolved?

    Options × real cost

    what it does what a caller feels
    A login() / register() issue a second request to GET /get-session and merge its session into data.session the declared type is delivered verbatim; the SDK's hottest path costs two round trips, and a login now fails when the follow-up fails — new failure semantics no ruling authorises today
    B narrow the declaration for these two methods (a sign-in/sign-up response whose data is { user, token }), leaving SessionResponse to /get-session no extra network call and the type finally describes what the routes really send; costs a packages/spec edit, an ADR-0087 disposition and a migration note, executed by the domain:spec seat
    C leave it as measured, pinned residue zero risk today; the two methods keep advertising a member they never deliver, so a caller following the declared type to result.data.session.expiresAt still gets a TypeError, not a wrong value

    业务含义直译

    • A = 为了让说明书上那一行成真,给每个登录的人加一趟来回。相当于产品手册印错了一个配件,于是每台出厂机器都多塞一个用不上的配件进去,还多一道可能装歪的工序。
    • B = 承认说明书那一行印错了,改印。改版要走流程、要通知,但机器不动。
    • C = 说明书继续印错的那版,反正"大部分人不会翻到那一页"。翻到的人会照着装,然后装不上。

    四轴(业务立场)

    • ① 项目长远合理性 —— B 缩小特例:一个声明对应一种真实回包,两条路由不再共用一个它们只满足一半的形状。A 反过来把"多打一次请求"变成永久义务,为的是维持一个写错的承诺。C 让不一致无限期挂着,而且每多一个照着类型写的调用方,代价就长一分。
    • ② 实际业务拉动 —— 今天谁撞上:任何照类型提示去取 result.data.session.* 的人,拿到的是崩溃(生产环境);而实测没有任何一页已发布文档教人去读它(data.session 全站 0 命中,对照词 3 命中)⇒ 拉动来自类型提示,不是来自文档或用户请求。A 的那次额外请求,是在为一个零文档引用的字段付每次登录的成本。
    • ③ 防 AI 犯错 —— 出错时谁看到什么:B 让写错的人在编译期就被拒(类型里根本没有那个成员),是响亮拒绝。C 让他在运行时拿到 TypeError,而且是在用户点了登录之后 —— 静默承诺 + 晚爆。A 表面上都不报错,但把失败挪到"第二次请求挂了所以登录失败",错误现场和真实原因隔了一层,最难查。
    • ④ 创业阶段不扩散 —— 「remove 优于 declare-and-maintain,每个已声明的键都是永久义务」。A 是把一个永久义务再加一层实现负担;B 是把一个从未兑现的义务取消掉;C 是把它继续欠着。B 是唯一一个让义务总量下降的。

    推荐 + 回退 + 置信缺口

    推荐 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

    • A ⇒ dispatchable in this lane; the diff is packages/client/src/index.ts plus driven tests, and the PR must state the new failure semantics in its changeset.
    • B ⇒ ⛔ not this lane. File a card in the domain:spec queue carrying the measurement above and a Blocked-by: back-link; this card turns pm:blocked against it in the same write.
    • C ⇒ close this card not_planned with the measurement as the reason, and the residue stays pinned by auth-login-register-envelope.test.ts so it cannot rot silently.

    Related

    #16760 (the sibling methods, fixed), PR #17791 (the success half, this card's own), #17238 (the anonymous-caller case — domain:services, unruled, ⛔ do not fold), #17235 (data.user.image served as null — separate declaration problem, pinned as residue by the same test).


    os-decision-facets

    • ① 项目长远合理性:B 缩小特例 —— 一个声明对应一种真实回包;A 把"每次登录多一趟"变成永久义务,C 让不一致无限期挂着。
    • ② 实际业务拉动:今天撞上的是照类型取 result.data.session.* 的调用方(生产崩溃);实测零文档引用,所以拉动来自类型提示而非用户请求。
    • ③ 防 AI 犯错:B 在编译期响亮拒绝;C 在用户点下登录之后才 TypeError;A 谁都不报错,却把失败伪装成"第二次请求挂了"。
    • ④ 创业阶段不扩散:B 取消一个从未兑现的义务,是三者中唯一让已声明键总量下降的。

    推荐:B(narrow the declaration,交 domain:spec 席执行)。选项:A / B / C。

    ⚠️ 置信缺口:看不到仓外调用方是否已在读 data.session;B 的迁移成本未实测;未穷尽第四条路(让路由自己回带 session)。


    Generated by Claude Code

  5. os-tesla commented on Sep 13, 2026

    @os-tesla
    Collaborator

    Ruling recorded — A′: the sign-in and sign-up routes answer the declared SessionResponse by 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

    1. packages/plugins/plugin-auth: the /sign-in/email and /sign-up/email route 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.
    2. packages/client's auth.login / auth.register keep their declared return type; the envelope test (auth-login-register-envelope.test.ts) flips from pinning the residue to pinning the fix.
    3. Same seat and batch as auth.me() returns the literal null for an anonymous caller, which no value of its declared SessionResponse can express #17238 (B) — one round on the auth route family.
    4. Clause-②: no (contract unchanged; implementation meets it).

    State

    needs-user-decision → pm:queue; lane domain:cli → domain:services (the fix is in plugin-auth); auth / bug / priority:p2 kept.


    Generated by Claude Code

  6. added
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    and removed
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    on Sep 13, 2026
  7. os-project-manager commented on Sep 13, 2026

    @os-project-manager
    Collaborator

    Claim: PM loop round 4
    Session: session_01URLHobLUJB9K1ABV6ofdjj
    Branch: claude/issue-17234-signin-session-envelope
    Worktree: objectstack-issue-17234
    Domain: domain:services
    File surface: packages/plugins/plugin-auth/src/** (the /sign-in/email · /sign-up/email route 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 call
    Clause-②: 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:

    the /sign-in/email and /sign-up/email route 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.

    ⚠️ Clause-②: no is 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 declared no on #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.

    ⚠️ A stale sibling reference in the ruling, measured — ⛔ do not act on it. Ruling item 3 reads 「Same seat and batch as #17238 (B) — one round on the auth route family」. Read at claim time: #17238 is closed / completed, landed in R1 via PR #17881, updated_at 2026-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:services execution seat · seat post #6021 · 2026-09-13T02:3xZ


    Generated by Claude Code

  8. os-project-manager commented on Sep 13, 2026

    @os-project-manager
    Collaborator

    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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    authbugSomething isn't workingdomain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpriority:p2Medium: important, M3

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions