Skip to content

fix(auth): in-process session reads no longer renew a browser session behind its cookie - #22367

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22258-session-read-renewal
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22258-session-read-renewal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #22258
Clause-②: yes (widening)
Release: the domain:services half of this card stays open, carried by domain:services. Nine in-process readers there (plugin-auth, plugin-webhooks, plugin-sharing, service-storage, service-settings, service-datasource; table H5 below) still renew a cookie session without re-issuing its cookie.

What this changes

An in-process auth.api.getSession read renews a session past better-auth's updateAge and stages the renewed cookie on a response the door never sends. The browser's cookie then dies before its session: a split session, a dead cookie beside a live bearer.

One rule now covers every in-process reader in this lane. It lives in one helper, inProcessSessionReadInput(headers) in @objectstack/types, and is decided by what the request carries:

  • A session cookie (a browser, including the console, which sends its cookie beside its bearer): the read passes query: { disableRefresh: true }. The session renews only through GET /api/v1/auth/get-session, which re-issues the cookie, so cookie and session expire together.
  • No session cookie (a bearer-only client): read exactly as before, renewal included. No cookie exists to fall behind, and the bearer is the session token, which renewal does not change.

The rule only ever adds disableRefresh. It sets no cookie, forwards none, and never changes which session a request resolves to.

Applied at all ten readers in this lane. The census on b7e01fbbd found six. Four more use the optional-chained spelling api?.getSession?.(, which the .getSession( regex did not match:

package reader (line on this branch) in the PM census
rest rest-server.ts:3037 computeExecCtx getter yes
rest rest-server.ts:3224 auth-gate re-read yes
runtime http-dispatcher.ts:1362 enforceAuthGate yes
runtime http-dispatcher.ts:1442 enforceProjectMembership no
runtime security/resolve-session-principal.ts:57 (rate limiter, concrete route mounts) yes
runtime security/resolve-execution-context.ts:165 (dispatcher scope, MCP door) no
plugin-hono-server current-user-endpoints.ts:412 yes
cloud-connection marketplace-install-local-plugin.ts:2624 resolveActiveOrgId yes
cloud-connection marketplace-install-local-plugin.ts:2794 resolveInstallPrincipal getter no
cloud-connection cloud-connection-plugin.ts:209 session bridge no

The four extra readers are fixed in place under the bounded in-place-fix rule: the same defect class, the same one-line call, no other claim on those files, and the same packages and gates. Their doors split measurably: the MCP door and /i18n/locales go through resolve-execution-context (H1, ablation 2). This adds two files to the claim's file surface: packages/runtime/src/security/resolve-execution-context.ts and packages/cloud-connection/src/cloud-connection-plugin.ts.

Where the helper lives, and why there. The claim suggested a helper in packages/runtime. That cannot serve all ten readers: rest cannot import runtime (runtime depends on rest, so it would be a cycle), and plugin-hono-server does not depend on runtime. @objectstack/types is in this lane and is already a dependency of all four reader packages, which is the same "one home, no new edge" reasoning that file's barrel records for its other shared rules.

Why Part of, and why Clause-②: yes

  • Part of. Triage's done-when reads "No framework door extends a session without forwarding its cookie". After this PR, every door in this lane holds (H1). The nine domain:services readers still split a session through public doors (H5), so the card stays open for them.
  • Clause-②: yes (widening), not the claim's no. The claim's gloss on no was "No accepted input, export or published shape changes", written before the helper's home was chosen. The one shared helper has to be an export of a published package, so @objectstack/types gains three named exports: inProcessSessionReadInput, carriesSessionCookie and the InProcessSessionReadInput type. That is an additive widening of a published package's public surface, which the Check Changeset "WHICH LEVEL" ruling grades at least minor. The changeset is therefore minor for @objectstack/types and patch for rest, runtime, plugin-hono-server and cloud-connection. Raised with the seat in the dev report; no accepted input and no wire shape changes.

H1: reproduced through public doors, then measured on the fix

The probe is a fresh pnpm dev:crm -- --fresh stack, run on b7e01fbbd and again on this branch, with better-auth 1.7.3 as pinned. expiresIn (604800 s) is read from the fresh sys_session row; the pin reads both values off the running instance's sessionConfig, and updateAge is 86400 s. Each row ages the signed-in session in sys_session to now + expiresIn − updateAge − 60 s, sends one request, and reads expires_at back.

door by before: Δ expires_at · session Set-Cookie after
GET /api/v1/auth/get-session (control) cookie +86460 s · Max-Age=604800 +86460 s · Max-Age=604800
GET /api/v1/data/sys_user cookie +86460 s · none 0 · none
GET /api/v1/data/sys_user bearer +86460 s · none +86460 s · none
GET /api/v1/auth/me/permissions cookie +86460 s · none 0 · none
GET /api/v1/auth/me/permissions bearer +86460 s · none +86460 s · none
GET /api/v1/meta/object cookie +86460 s · none 0 · none
GET /api/v1/i18n/locales cookie +86460 s · none 0 · none
GET /api/v1/packages cookie +86460 s · none 0 · none
GET /api/v1/marketplace/install-local cookie +86460 s · none 0 · none
GET /api/v1/mcp (answers 406) cookie +86460 s · none 0 · none

Every door's bearer row is unchanged by the fix (+86460 s, no cookie); only the first two doors are shown here.

H3: who holds only a bearer

These clients would lose renewal under a blanket disableRefresh. Measured by reading where each one sends its credential and when it reaches get-session:

  • @objectstack/client sends Authorization: Bearer from its stored token on every request (fetch). It calls get-session only on an explicit auth.me() or refreshToken(). Outside a browser it holds no cookie jar, so it is bearer-only.
  • The CLI is bearer-only. It reaches get-session only at os login and os cloud whoami. Its data commands (datasource list-tables, validate, introspect, package publish, plugin publish) carry a bearer.
  • MCP: OAuth access tokens are verified separately and are not better-auth sessions, so they are unaffected. API keys are unaffected too. A session bearer presented there is bearer-only.
  • objectui console: NOT bearer-only. Its fetches send the cookie (credentials: 'include') beside the stored bearer, and it calls get-session on mount and on every re-resolution (AuthProvider.loadSession).

Before: every bearer-only data read past updateAge renewed, +86460 s on every door above. A blanket disableRefresh would end that, and a CLI or SDK session would die expiresIn (7 days) after sign-in however active. After: bearer-only reads still renew, +86460 s on every door (measured above, and pinned).

H4: the rule, chosen on that measurement

  • Forward the renewed Set-Cookie from every door: measured and not taken.
    • Only three of the ten readers have a response in hand: the Hono me/* endpoints and two cloud-connection routes, all on a Hono c.
    • REST's computeExecCtx(environmentId, req) has no response object and is cached per request across many routes.
    • The dispatcher is transport-neutral and returns { status, body }. A forward there would need a header channel through every dispatcher result and every adapter's sendResult.
    • The rate limiter reads before the route.
    • A request can pass two or three in-process reads (REST: 2, dispatcher: up to 3), and only the first renews.
    • A forward would still need this same cookie test, so that no cookie is ever set on a request that sent none.
    • So forwarding cannot be one rule for all ten.
  • Taken: the PM's lean, unchanged. A cookie request reads with disableRefresh; a bearer-only request reads as before. better-auth applies the same rule to its own reads that cannot write a cookie (React Server Components, dist/integrations/next-js.mjs:62-69).

H5: the domain:services readers, measured and not edited

Measured on this branch's build, so a remaining renewal belongs to the services reader and not to a lane reader that ran on the same request. Line numbers are at b7e01fbbd.

reader door by cookie by bearer
plugin-auth auth-plugin.ts:2464 POST /api/v1/auth/admin/oauth2/toggle-disabled +86460 s · no cookie (split) +86460 s
plugin-auth auth-plugin.ts:2527 (gateAdmin, every admin route behind it) POST /api/v1/auth/admin/sso/register +86460 s · no cookie (split) +86460 s
plugin-auth auth-plugin.ts:2594 POST /api/v1/auth/admin/unlock-user +86460 s · no cookie (split) +86460 s
plugin-auth auth-plugin.ts:2912 POST /api/v1/auth/admin/has-permission +86460 s · no cookie (split) +86460 s
plugin-webhooks webhook-outbox-plugin.ts:482 POST /api/v1/webhooks/redeliver (showcase stack) +86460 s · no cookie (split) +86460 s
service-storage storage-service-plugin.ts:843 GET /api/v1/storage/upload/chunked/:id/progress +86460 s · no cookie (split) +86460 s
plugin-sharing sharing-plugin.ts:940 (not in the PM census) DELETE /api/v1/share-links/:id +86460 s · no cookie (split) +86460 s
service-settings settings-service-plugin.ts:299 (not in the PM census) GET /api/settings +86460 s · no cookie (split) +86460 s
service-datasource admin-routes.ts:212 (not in the PM census) GET /api/v1/datasources/drivers +86460 s · no cookie (split) +86460 s

The fix there is the same rule: api.getSession(inProcessSessionReadInput(headers)). Five of the six packages already depend on @objectstack/types; plugin-webhooks would gain that one dependency.

Pins, and the tier each runs in

All pins run in each package's local vitest project (CI: Test Core). The runtime pin boots an in-process ObjectKernel, with no spawned process and no driver socket.

  • packages/types/src/in-process-session-read.test.ts: the cookie test across every spelling better-auth writes (default and custom cookiePrefix, __Secure-). Also: other cookies, empty values, plain header records, and bearer-only. The input builder passes the same headers object through and adds query only for a cookie.
  • packages/runtime/src/in-process-session-renewal.pin.test.ts, against real better-auth:
    • It reads expiresIn and updateAge off the running instance. A precondition proves the fixture renews: a bare in-process read without the rule moves expires_at.
    • Three doors, each by cookie and by bearer: GET /data/:object (rest), GET /auth/me/permissions (hono) and GET /i18n/locales (dispatcher resolveExecutionContext).
    • Pinned for each: a cookie request leaves cookie and session expiry aligned (no renewal, no cookie). A bearer-only request still renews to now + expiresIn, and no cookie is set on its response.
    • The rate limiter's reader (resolveSessionPrincipalId) by cookie and by bearer. After the limiter's read, get-session still renews AND re-issues the cookie.
    • Control: get-session renews and re-issues with Max-Age = expiresIn.
  • packages/rest/src/in-process-session-read.pin.test.ts, packages/plugins/plugin-hono-server/src/in-process-session-read.pin.test.ts, packages/cloud-connection/src/in-process-session-read.pin.test.ts: every remaining reader hands better-auth the rule's input. The cases are cookie, cookie plus bearer, and bearer-only. Covered: REST's getter and gate re-read; the Hono resolver; the cloud-connection session bridge, resolveActiveOrgId and resolveInstallPrincipal.
  • packages/rest/src/execctx-authz-input-seam-reachability.test.ts: its source-text pin on the auth-gate re-read now reads the new argument. Its intent is unchanged: still the raw, throwing api call.

Ablation, run twice: drop the rule from one reader

Both runs used scripts/ablation-replace.mjs in wrap mode, inside a script with an absolute-path restore trap. In both suites the mutated reader resolves from src (rest: a relative import; runtime: the @objectstack/rest alias and a relative import), so no dist leg applies.

  1. rest computeExecCtx getter reverted to api.getSession({ headers: h }). The anchor went 1 → 0 and the blob 89fae0b5e5f5 → 677bb44cb288.
    • Runtime pin: 1 failed | 10 passed. Exactly GET /data/:object — by cookie went red: "the session renewed (+86460 s) but its cookie was not re-issued".
    • rest pin: 2 failed | 1 passed (both cookie cases red; bearer-only green).
    • Restored: blob == HEAD 89fae0b5e5f5, and git diff HEAD is empty.
  2. runtime resolve-execution-context getter reverted the same way. The blob went c570e6e4cd84 → 2e86b8e71527.
    • Runtime pin: 1 failed | 10 passed. Exactly GET /i18n/locales — by cookie went red.
    • Restored: blob == HEAD c570e6e4cd84, and the diff is empty.

Verification

All at HEAD 8200f5778, the last commit on this branch:

  • Unit tests (pnpm --filter PKG test, each package's local project):
    • types: 25 files, 749 passed.
    • rest: 264 files, 4954 passed, 326 skipped.
    • runtime: 341 files, 4787 passed, 19 skipped.
    • plugin-hono-server: 28 files, 329 passed.
    • cloud-connection: 42 files, 514 passed.
    • test:repo: types 11, rest 191 (1 skipped), runtime 751, all passed.
  • Typecheck for the five packages: 41 of 41 turbo tasks green. Each test layer compiles; runtime is at its existing ledger (27 files, 190 errors), unchanged.
  • Gates: the 67 commands that node scripts/pm/dispatch-gates.mjs --commands derives for this diff. That is the order's 61 plus check:engine-double-contract, check:objectql-double-limit, check:query-options-erasure, check:type-check-coverage, check:type-check-debt and check:where-matcher. All exit 0. The --ran reconciliation reads 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN.
  • Lint: the full pnpm lint (eslint . --no-inline-config) exits 0.
  • Not merged with origin/main, which is two commits ahead (fix(cli): os migrate meta lists and writes a conversion the load already applies, on a stack the schema accepts #22351 cli, feat(plugin-security,plugin-auth): the grant readers read the permission-set name column (ADR-0131 D4, C2 stage S5b) #22352 plugin-security and plugin-auth). Neither touches a file here, and CI tests the merge ref.

Acceptance notes

  • Residue the server cannot see. Some browser requests carry the bearer without the cookie (a cross-origin fetch without credentials, or a blocked cookie). Those read as bearer-only and still renew in-process. If the same browser sends that session's cookie on other requests, that cookie can still fall behind. The rule decides from what each request carries.
  • A behaviour change for a tab that never calls get-session. Such a tab now signs out at the session's real expiry, instead of keeping a live bearer beside a dead cookie. The console calls get-session on mount and on each re-resolution.
  • Declaration drift, for domain:spec (not edited here). AuthSessionApi in packages/spec/src/contracts/auth-service.ts declares getSession's input as { headers }. Its doc says every reader "calls exactly getSession({ headers })", which is no longer true. The helper declares the wider slice it passes (query.disableRefresh) itself; that type-checks because the extra key reaches a non-fresh object. Carrier: the domain:spec seat.
  • Vendor behaviour, unchanged here. better-auth's own get-session re-issues the session cookie on a bearer-only request too (measured: Max-Age=604800 with a bearer). That route is better-auth's, and this PR does not touch it.
  • Overlap. Open PR fix(service-automation)!: retire RunProvenanceContext; pre-D5 principal-less readings outside packages/spec say D5 refuses it #22357 edits packages/runtime/src/http-dispatcher.ts in two comment hunks (around lines 1241 and 1508), disjoint from this PR's hunks (around 1344-1362 and 1420-1442).
  • Unread. The cloud measurement the card cites (objectstack-ai/cloud issue 2699's report and PR 2708) was not readable from this session. H1 re-measured the defect independently on this repo's doors.

Generated by Claude Code

claude added 6 commits October 8, 2026 21:58
…ehind its cookie

An in-process `auth.api.getSession` read renews a session past
better-auth's `updateAge` and stages the renewed cookie on a response the
door never sends, so the browser's cookie dies before its session.

One rule in `@objectstack/types` (`inProcessSessionReadInput`): a request
carrying a session cookie reads with `query.disableRefresh`, so a session
renews only where its cookie is re-issued (`/get-session`); a bearer-only
request renews as before. Applied at all ten in-process readers in rest,
runtime, plugin-hono-server and cloud-connection.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
… and cloud-connection reader

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
…earer-only, and the get-session control

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
…h for the four reader packages

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
…ward from the aged expiry

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
…process session-read input

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
@github-actions github-actions Bot added the size/l label Oct 8, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/cloud-connection, @objectstack/plugin-hono-server, @objectstack/rest, @objectstack/runtime, @objectstack/types, touching 18 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/types/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/client-sdk.mdx (via auth.me (sdk, the route ledger binds it to GET /api/v1/auth/get-session), data.create (sdk, the route ledger binds it to POST /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to POST /data/:object), data.find (sdk, the route ledger binds it to GET /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to GET /data/:object))
  • content/docs/api/data-flow.mdx (via data.create (sdk, the route ledger binds it to POST /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to POST /data/:object))
  • content/docs/api/environment-routing.mdx (via enforceProjectMembership (symbol, a method of class HttpDispatcher), data.find (sdk, the route ledger binds it to GET /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to GET /data/:object))
  • content/docs/api/error-catalog.mdx (via data.create (sdk, the route ledger binds it to POST /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to POST /data/:object))
  • content/docs/deployment/troubleshooting.mdx (via data.find (sdk, the route ledger binds it to GET /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to GET /data/:object))
  • content/docs/kernel/runtime-services/data-service.mdx (via data.create (sdk, the route ledger binds it to POST /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to POST /data/:object), data.find (sdk, the route ledger binds it to GET /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to GET /data/:object))
  • content/docs/permissions/authentication.mdx (via auth.me (sdk, the route ledger binds it to GET /api/v1/auth/get-session), data.find (sdk, the route ledger binds it to GET /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to GET /data/:object), /api/v1/auth/get-session (route, a path literal in a comment on a changed line))
  • content/docs/protocol/kernel/index.mdx (via resolveExecutionContext (symbol, a top-level function))

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

  • content/docs/releases/v16.mdx (via data.create (sdk, the route ledger binds it to POST /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to POST /data/:object))
  • content/docs/releases/v17/17-4.mdx (via /api/v1/auth/get-session (route, a path literal in a comment on a changed line))
  • content/docs/releases/v17/17-5.mdx (via auth.me (sdk, the route ledger binds it to GET /api/v1/auth/get-session), /api/v1/auth/get-session (route, a path literal in a comment on a changed line))

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/types/src/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: /data/:object (route, 67 pages)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 38 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 43fc50051cbde00b0da6b2d042df11b814a61d0c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7630364a8604b3ed1ce9ddf70b181a3ce7f28aa8 — the merge of head 8200f577855163b42b0b45470ca075308b353461 into base 43fc50051cbde00b0da6b2d042df11b814a61d0c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7630364a8604b3ed1ce9ddf70b181a3ce7f28aa8 && git checkout 7630364a8604b3ed1ce9ddf70b181a3ce7f28aa8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 43fc50051cbde00b0da6b2d042df11b814a61d0c 8200f577855163b42b0b45470ca075308b353461 && git checkout -B drift-repro 43fc50051cbde00b0da6b2d042df11b814a61d0c && git merge --no-ff 8200f577855163b42b0b45470ca075308b353461

node scripts/docs-audit/affected-docs.mjs --json 43fc50051cbde00b0da6b2d042df11b814a61d0c

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8200f577855163b42b0b45470ca075308b353461
Local-runs: none

① Derived judgments

Inputs read, 2026-10-08T23:02Z to 23:13Z. Card #22258 (body and all four comments: triage 6058300952, the claim 6069531196 as amended in place at 23:00Z, the os-dev-report 6070681067, the seat's ACCEPT 6070755027); PR #22367 (body, 16-file list, net diff origin/main...8200f577, merge base b7e01fbbd, six commits, +907/−14); the check-runs on 8200f577 (four reads, below); precedents #22141 / PR #22206 and #22188 / PR #22265 with their records. The PR head is still 8200f577 (draft, base main). The claim's and ACCEPT's conclusions were judged, not adopted. To judge the vendor semantics the diff leans on, better-auth 1.7.3 was read from the npm registry tarball (dist/api/routes/session.mjs, dist/cookies/index.mjs, dist/cookies/cookie-utils.mjs, dist/plugins/bearer/index.mjs, dist/plugins/custom-session/index.mjs, dist/integrations/next-js.mjs): a read, nothing built or run. The only git operation beyond diff/show/grep was an in-memory git merge-tree --write-tree of the head onto current main, which touched no worktree, index or ref.

Public surface, @objectstack/types: an additive widening, named right. packages/types/src/index.ts adds export * from './in-process-session-read.js' on the root entry (exports: "."; ./node untouched; publishConfig.access: public, not private; tsup entry src/index.ts, so dist carries it). The module exports exactly three names: inProcessSessionReadInput, carriesSessionCookie and the type InProcessSessionReadInput. Nothing is removed or renamed. No packages/spec edit. @objectstack/types has no export ledger and no entry in scripts/published-readme-exports.baseline.json, so no ledger update was owed. Right.

Public surface, rest, runtime, plugin-hono-server, cloud-connection: none. No export changes; each reader edit is a one-expression substitution of the getSession argument, with the surrounding .catch, try, ?. and api-resolution shape unchanged. No new dependency edge: all four already list @objectstack/types in dependencies (verified in each package.json on main). The home argument holds as stated: runtime depends on rest, so rest importing runtime would be a cycle; plugin-hono-server has no runtime dependency. Right.

The behaviour, against the vendor. better-auth 1.7.3 getSession (session.mjs:170) returns the found session before any write when ctx.query?.disableRefresh is truthy; otherwise (181-202) it renews when the session is past updateAge (updateSession to now + expiresIn) and stages the cookie with setSessionCookie(…, { maxAge: expiresIn }) on that call's response. getSessionQuerySchema admits disableRefresh (coerced boolean). plugin-auth installs customSession (auth-manager.ts:4014); its getSession endpoint declares the same query schema and spreads ...ctx into the inner call, so query passes through; getApi() returns auth.api unwrapped (auth-manager.ts:6314). So query: { disableRefresh: true } does what the diff says, through the API object the readers actually hold. The runtime pin's measured 0 s on cookie reads is consistent with this; CI's Test Core is the gate for it.

  • Accepted inputs: unchanged. No route accepts a new header, query or body member; the helper reads the request, it does not validate it.
  • Wire answers: unchanged. No status or body moves. No reader forwarded Set-Cookie before and none does after: the diff adds no response-header plumbing anywhere. The one change is a server-side side effect: a request carrying a session cookie no longer extends sys_session.expires_at through these ten readers. That is the removal of an undeclared side effect that contradicted the vendor's own design (renewal and cookie re-issue are one act, session.mjs 183-202) and the card's done-when. Not a narrowing of a published accept set; a correction. Its one user-visible consequence, a browser that never calls get-session signs out at the session's real expiry, is declared in the changeset and the PR. Right.
  • Bearer-only: unchanged. No cookie → no query → the vendor path is byte-for-byte the pre-fix call. Inside better-auth the bearer plugin writes the bearer token into the request's cookie header (setRequestCookie), so renewal proceeds exactly as on main. Right.
  • Which session resolves: never changed. The helper returns the same headers object (identity pinned in in-process-session-read.test.ts) plus, at most, query. Resolution is decided from the headers alone: on a request carrying both cookie and bearer, setRequestCookie (cookie-utils.mjs:177-181) overwrites the session cookie with the bearer token before and after this PR. The helper sets no cookie and forwards none. Right.

The cookie test (SESSION_TOKEN_COOKIE), misclassification judged both ways. better-auth writes the session cookie as secureCookiePrefix + (advanced.cookies.session_token.name || prefix + ".session_token"), with prefix = advanced.cookiePrefix || "better-auth" and the secure prefix __Secure- or nothing (cookies/index.mjs:21-36, 49). plugin-auth forwards only cookiePrefix, useSecureCookies, crossSubDomainCookies and disableCSRFCheck (auth-manager.ts:2697-2707), never advanced.cookies, so inside this repo's config surface the regex's shape matches every name better-auth can write (an empty cookiePrefix falls back to better-auth). No __Host- writer exists in 1.7.3; the regex's __Host- arm is inert and harmless.

  • False positive (a cookie matches, but renewal is withheld from a session that would otherwise have renewed): a cookie and a bearer naming different sessions (the bearer wins resolution; that session is not renewed in-process), or a foreign x.session_token cookie from another better-auth app on the same host beside a bearer-only client in a browser. Consequence: one withheld renewal that get-session supplies. Never a set cookie, never a split.
  • False negative (a session cookie is present and not matched): a custom advanced.cookies.session_token.name on a host configuring better-auth outside plugin-auth; a Cookie value assembled from several header lines by Fetch Headers.get (joined with , , which the ; anchor does not accept) — Node's HTTP/1 parser joins duplicate Cookie lines with ; , so this takes a hand-built Headers; and the dispatcher's record→Headers normalisation joining array values with , (http-dispatcher.ts:1352). Consequence: that request behaves as on main (renews, discards the cookie). Bounded to the request, never worse than today.
  • The two failure directions are asymmetric in the safe direction. Right.

The ten readers. At the head, every non-test getSession( call under rest, runtime, plugin-hono-server and cloud-connection goes through the helper (census: rest-server.ts:3037, :3224; http-dispatcher.ts:1362, :1442; resolve-session-principal.ts:57; resolve-execution-context.ts:165; current-user-endpoints.ts:412; cloud-connection-plugin.ts:209; marketplace-install-local-plugin.ts:2624, :2794). core/resolve-authz-context.ts:401 is the callback seam those getters feed, not a reader. enforceProjectMembership can hand the helper undefined; it then returns { headers: undefined }, the pre-fix input. resolve-execution-context's OAuth and unverified-JWT provenance arms bypass the getter and are unaffected. The rate limiter's resolveSessionData runs on every request, get-session included; the vendor's disableRefresh early return performs no write, so it consumes nothing, which the runtime pin's "limiter then get-session still renews and re-issues" case states. Right.

The edited source-text pin (execctx-authz-input-seam-reachability.test.ts): only the regex spelling moves to the new argument; the adjacent assertions (the gate-active predicate, throw new AuthzStoreUnavailableError('auth_gate', err)) are unchanged and still match rest-server.ts:3209-3227; the call is still the raw one with no .catch. Intent unchanged, right.

Part of #22258 and the Release: line. Census at the merge base and at the head, non-test, outside the lane: plugin-auth auth-plugin.ts:2464, :2527, :2594, :2912; plugin-webhooks webhook-outbox-plugin.ts:482; plugin-sharing sharing-plugin.ts:940; service-storage storage-service-plugin.ts:843; service-settings settings-service-plugin.ts:299; service-datasource admin-routes.ts:212. Nine readers in six packages, none edited, exactly as the PR's H5 table and the changeset's "Not changed here" say, including the three the PM census missed. The card's done-when ("No framework door extends a session without forwarding its cookie") is therefore not met, so Part of is right and Fixes would have been wrong; the Part-of PR must not also close its card check is green. plugin-webhooks does not depend on @objectstack/types (verified), as the PR states. The remainder is stated truly.

main since the merge base. Six commits. One, #22357 (746637e51), edits packages/runtime/src/http-dispatcher.ts at hunks 1241 and 1508, disjoint from this PR's 1344-1362 and 1420-1442. The in-memory trial merge of 8200f577 onto current main is clean. CI's 22:53Z runs were computed on the merge with base 43fc50051, before #22357 landed; the queue re-tests the merge ref.

One comment-accuracy nit, no contract effect. The helper's docblock and the PR say better-auth applies "the same rule" to RSC reads at next-js.mjs:62-69. In 1.7.3 that integration calls setShouldSkipSessionRefresh(true); needsRefresh (session.mjs:183) is false under either flag, so the principle claimed is true and the flag named is a sibling, not disableRefresh.

Docs-drift bot: advisory; index.ts yielded no anchor; no release-owned page is edited by this diff.

② Semver level

  • .changeset/22258-in-process-session-read-no-renewal-behind-cookie.md: @objectstack/types minor; rest, runtime, plugin-hono-server, cloud-connection patch. Right. minor on types follows the Check Changeset WHICH LEVEL ruling (a new exported symbol on an index takes at least minor; a fix( that widens an index is minor) and the two precedents that graded the same act the same way (fix(runtime): the @objectstack/hono catch-all's PUT /meta honours If-Match, If-None-Match and ?mode=draft #22206 metaSaveRequestOptions, fix(rest,runtime): ?package=all names no package on every /meta door of both hosts (#22188) #22265 metaItemPackageBinding, both @objectstack/rest minor / Clause-②: yes). patch on the four readers is right: a fix with no surface change and no published accept-set narrowing.
  • PR body line 2 Clause-②: yes (widening): a legal spelling (clause2-line.mjs: yes (widening) is yes said out loud, the widening arm). The level axis asks for at least one moved package graded minor or above, which types is. Check Changeset succeeded on the head at 22:55:44Z. Nothing breaking, so no ADR-0087 marker is owed; skip-changeset does not apply.
  • Every changeset sentence against the diff and the vendor: the renewal mechanism (true, session.mjs 181-202); "Ten doors read the session in-process" (ten readers behind the measured doors; wording only); the cookie-dies-first chain (true: a renewed row is not due again, so get-session re-issues nothing); the measurement paragraph (the dev's H1, which this record cannot re-measure; the runtime pin covers three of those doors, the limiter and the control against real better-auth, and CI is its verdict); the cookie rule and Max-Age = expiresIn (true, session.mjs:197-202); the bearer-only rule (true); "No cookie is ever set on a response to a request that sent none" (true of the in-process doors the bullet describes and pinned per door; better-auth's own get-session route does set one for a bearer-only request, which the PR's acceptance note records and this sentence does not claim to cover); "Upgrading. Nothing to change…" (true); the three named exports (true); "Not changed here" (true, nine readers in those six packages, verified above).
  • Observation, non-blocking: the changeset carries no Clause-②: line of its own, where both precedents' changesets did. AGENTS.md requires it in a changeset only beside an ADR-0087 marker on a breaking changeset, and the gate reads the PR body.

③ Boundary flags

The dev's five deviations, one open_questions entry and four out_of_scope_findings (report 6070681067), each answered or escalated:

  1. open_questions[0], Clause-② A/B/C — answered: A, as shipped and as the seat ruled. B would be a false declaration: the diff adds three names to a published entry, and AGENTS.md step 3 makes a wrong no an auditable misdeclaration with yes taking at least minor. C has no home: every workspace package that both rest and plugin-hono-server can import (core, spec, types, observability) is published, a deep import is unaddressable under types' two-entry exports map, and four copies are the drift the one-helper direction exists to prevent. The claim's no was written before the home was chosen and was amended in place at 23:00Z; claim, PR body and changeset now agree.
  2. File surface beyond the claim — answered, right. The types home is forced (①); the two extra reader files and the two extra readers inside claimed files are the same defect, the same one-expression call, in this lane; the claim was amended in place to name them. The pin-spelling edit is intent-preserving (①).
  3. cloud#2699 / PR 2708 unread — answered, acceptable. Not an input to this record either; the defect was re-measured on this repo's doors and the vendor mechanism is confirmed here from source.
  4. Overlap with fix(service-automation)!: retire RunProvenanceContext; pre-D5 principal-less readings outside packages/spec say D5 refuses it #22357 in http-dispatcher.ts — answered. It has since merged; hunks disjoint; trial merge clean (①). The queue re-tests the merge ref; the seat sequences nothing further.
  5. Branch behind origin/main — answered. Six commits now; only fix(service-automation)!: retire RunProvenanceContext; pre-D5 principal-less readings outside packages/spec say D5 refuses it #22357 touches a PR file (①).
  6. out_of_scope_findings[0], nine services readers — answered: the remainder, truly stated, carried by the Release: line. Escalated to the seat: whether it becomes a sub-issue is the seat's call; the services adoption adds one dependency edge (plugin-webhooks → @objectstack/types), which the release note to that lane should name.
  7. out_of_scope_findings[1], AuthSessionApi declaration drift — escalated to the seat for domain:spec. getSession?(input: { headers: unknown }) admits the helper's return structurally (not a fresh literal), so nothing fails to compile; the docblock sentence "every reader calls exactly getSession({ headers })" is false at this head. Not edited here, rightly; not blocking.
  8. out_of_scope_findings[2], bearer-without-cookie browser residue — answered, right. The server cannot see the cookie it was not sent; the rule decides per request; get-session on the next cookie request realigns. No carrier owed.
  9. out_of_scope_findings[3], vendor get-session re-issues to a bearer-only request — answered, right. Vendor route, unchanged, outside this diff.
  10. Mine, non-blocking: the false-negative class in ① (multi-line Cookie joined with ,, the dispatcher's array join) leaves such a request on today's behaviour, never worse; no action owed. The next-js.mjs flag nit in ① is a comment fix for a later touch of the helper.
  11. Check-runs on 8200f577, read 2026-10-08T23:12:46Z: 33 runs, 29 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke), 1 in progress (Test Core (1/6), started 22:53:53Z), 0 failed. Check Changeset, Lint & Repo Gates, Build Core, all four Type Check jobs, Test Core 2 through 6, the Dogfood gates and every guard are green. Earlier reads 23:02Z (16 in progress), 23:08Z (5), 23:11Z (1): nothing turned red. Not fully green at this reading; landing waits on that shard, which this record does not pre-empt.

Implemented-by: claude/issue-22258-session-read-renewal
Reviewed-by: session_01RWZbGvPFcRKvUqASZtunCU

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 23:32
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 23:32
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit a45d5d8 Oct 9, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22258-session-read-renewal branch October 9, 2026 00:11
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…ableRefresh its readers send (objectstack-ai#22406)

Fixes objectstack-ai#22384

Clause-②: yes (widening)

`AuthSessionApi.getSession` now declares the optional
`query.disableRefresh` that the in-process readers send. A type-level
pin in `packages/types` keeps the helper's return inside that
declaration, key for key.

## What changes

- **`packages/spec/src/contracts/auth-service.ts`.**
- The declaration: `getSession?(input: { headers: unknown; query?: {
disableRefresh?: boolean } })`. It was `{ headers: unknown }`. The
return type and the rest of `AuthSessionApi` are unchanged.
- The docblock now states the two read forms. A request carrying a
better-auth session cookie reads with `{ headers, query: {
disableRefresh: true } }`. A bearer-only request reads with `{ headers
}` alone.
- It names `inProcessSessionReadInput`
(`packages/types/src/in-process-session-read.ts`) as the one source of
both forms, and says what a hand-built `{ headers }` costs on a cookie
request.
- The old line "Widening this is for whoever needs more, with the call
site to prove it" stays. The docblock now cites the ten call sites from
PR objectstack-ai#22367 that send `query`: `rest-server.ts` (x2), `http-dispatcher.ts`
(x2), `resolve-session-principal.ts`, `resolve-execution-context.ts`,
`current-user-endpoints.ts`, `cloud-connection-plugin.ts` and
`marketplace-install-local-plugin.ts` (x2).
- **`packages/types/src/in-process-session-read.contract.test.ts`**
(new). This is the pin, and it is the one file declared on objectstack-ai#6024
(comment `6072295794`). It is described below.
- **`.changeset/22384-auth-session-api-getsession-input-query.md`.**
`@objectstack/spec` `minor`. `@objectstack/types` gets no entry, because
the only file changed there is a test, and `files[]` ships only `dist`,
`README.md` and `CHANGELOG.md`.

No consumer was touched. No reader changed in `domain:cli` or
`domain:services`.

## The pin, and a dispatch assumption it falsified

The dispatch asked for a type-level test that the helper's return is
*assignable* to the declared input, and asked that narrowing the
declaration back should make it fail. **Plain assignability cannot fail
here.** TypeScript refuses an undeclared key only on an object literal.
A non-literal value with an extra optional key is assignable to a type
that omits that key. That is how all ten readers compiled against `{
headers: unknown }` while sending `query`.

This was measured in the ablation below. With the declaration narrowed
back, a throwaway probe compiled with **0 errors**. The probe held
`const x: DeclaredInput = inProcessSessionReadInput(new Headers())` and
the readers' own call shape,
`api.getSession?.(inProcessSessionReadInput(...))`.

So the pin checks assignability **key for key**. `FitsDeclared` applies
the object-literal rule to a type:

- the value is assignable;
- it carries no key the declaration does not name;
- this holds at every depth where the declaration names a shape
(`headers` is `unknown`, so it is not looked into);
- a union is judged one member at a time, never by its common keys.

The file carries:

- three `holds(true)` lines, each typed with `FitsDeclared` applied to
`HelperInput` of a header type and `DeclaredInput`. There is one line
per header shape the readers hand the helper: Web `Headers`, a Node
header record, and `unknown`;
- four `@ts-expect-error` controls that prove the instrument can fail at
all. If `FitsDeclared` ever went vacuous, each directive would stop
matching an error and tsc would report TS2578;
- one runtime case: an implementer typed by the contract reads
`input.query?.disableRefresh`. That read is itself a compile-time check,
and it asserts a cookie read does not renew while a bearer read does.

**Mechanism.** The test runs under the package's existing `typecheck`
script (`tsc --noEmit`). `packages/types/tsconfig.json` includes
`src/**/*`, tests included, and `--listFiles` lists the new file once.
CI's `TypeScript Type Check` runs it, and it reads `@objectstack/spec`
from its built `.d.ts`. This follows the `@ts-expect-error` compile-time
pins already in `response-envelope.test.ts`. No new runner was added.

## Reverse verification (one-off; nothing kept)

The run is at `769d9f4db`, after the fix was committed. It used
`scripts/ablation-replace.mjs` in wrap mode and
`scripts/ablation-dist-preflight.mjs`, under the shared verify lock. The
predicted direction was red on the three `holds` and on the `query`
read, with the controls unchanged. That is what happened.

| leg | reading |
|---|---|
| pristine dist | marker `disableRefresh?: boolean` present in
`dist/contracts/index.d.ts` and `.d.mts`; tree clean |
| mutate | anchor `{ headers: unknown; query?: { disableRefresh?:
boolean } }` x1 -> x0; blob `698dd54bd9e8` -> `2df53d7829bd`; spec
rebuilt (exit 0); preflight `--absent`: marker absent from all 232 built
files |
| `tsc --noEmit` in `packages/types`, mutated | **exit 2**: `TS2344` at
`:76`, `:77`, `:78` (the three `holds`), `TS2339` at `:96` (`Property
'query' does not exist on type '{ headers: unknown; }'`); **0** errors
in the plain-assignability probe |
| restore | blob after restore `698dd54bd9e8` == HEAD; `git diff HEAD`
empty; spec rebuilt; preflight (present) green; tree clean |
| `tsc --noEmit` in `packages/types`, restored | **exit 0** |

The head after that run (`57d9d9a8c`) changes only docblock text in
`auth-service.ts`: 6 lines added and 5 removed, all inside the comment.
The declaration line is byte-identical.

## Consumers

Every consumer that types against `AuthSessionApi` keeps compiling, and
none was edited:

- The ten readers pass `inProcessSessionReadInput(...)`. That input is
now declared instead of tolerated.
- The readers that still pass `{ headers }` by hand: `plugin-auth` (x4),
`plugin-webhooks`, `plugin-sharing`, `service-storage`,
`service-settings`, `service-datasource`, and the dogfood `armed.ts`
harness. `query` is optional, so they type as before. Moving them to the
helper is objectstack-ai#22258's remaining half and is not done here.
- An implementer of `getSession` that declares `{ headers: unknown }`
still satisfies the contract, because the wider input is assignable to
it.

The `typecheck` of both edited packages is green (below). The downstream
consumer typecheck is left to CI's `TypeScript Type Check`.

## Verification

At `57d9d9a8c` (final head):

- **Build.** `pnpm exec turbo run build --filter='!@objectstack/docs'
--concurrency=2`: 72 of 72 tasks successful, and `git status
--porcelain` was empty afterwards.
`packages/spec/dist/contracts/index.d.ts` carries the new declaration
and docblock.
- **`@objectstack/types`.**
- `typecheck`: exit 0. `tsc --noEmit --listFiles` lists
`in-process-session-read.contract.test.ts` once.
  - `test` (local): 26 files, 750 tests passed.
  - `test:repo`: 1 file, 11 tests passed.
  - The pin file run alone: 1 file, 1 test passed.
- **`@objectstack/spec`.**
- `typecheck`: exit 0. This covers `tsc --noEmit`,
`check:scripts-typecheck` and `check:test-typecheck`.
  - `test:repo`: 54 files, 915 tests passed.
- `test` (local, `--maxWorkers=2`): 626 files, 18742 tests passed, 1
todo.
- **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 85 commands at `57d9d9a8c`. All 85
ran, each exit code captured before any pipe, and all 85 exited 0. The
`--ran` reconciliation reads: "85 derived famil(ies) accounted for — 85
run, 0 NOT-MEASURED (a DERIVED zero — all 85 recorded an exit code and
none of them is 3)". Selected verdict lines:
- `check:api-surface`: "public API surface + factory signatures
unchanged".
- `check-adr-0087-registration`: "this PR adds no declared-breaking
changeset (1 non-breaking changeset(s) seen)".
  - `check:nul-bytes`: OK.
- `check:dual-build-cjs-loads`, `check:lean-entry-closure` and
`check:doc-formula-expressions` were measured after the full build.
Their earlier runs at `769d9f4db` exited 3 ("prerequisite not met");
those runs are not counted.
- **`check-changeset-no-major --base origin/main`.** Run locally, it
prints "LEVEL AXIS: NOT APPLICABLE" because there is no `pull_request`
payload. The reading of `Clause-②: yes (widening)` against `minor` is
CI's on this PR.
- **Lint, narrowed (CI owns the full run).** I ran `pnpm exec eslint
--no-inline-config --format json` on the two changed `.ts` files: 2
files, 0 errors, 0 warnings. The `.changeset` file falls outside
eslint's config (eslint reports it as ignored). `eslint.config.mjs`
enables no type-aware linting (no `parserOptions.project`; see its
comment near `:326`), so this diff cannot change a verdict on any
untouched file.
- **Base.** The branch is 8 commits behind `origin/main` (`11d119ab1`).
None of those commits touches `packages/spec/src/contracts/` or
`packages/types/`, and the merge queue rebuilds the merged generation.

## Acceptance notes

- **The declaration's value is `boolean`, but the helper only ever sends
`true`.** This follows the card and the claim. `FitsDeclared` accepts
`true` against `boolean`. A helper that started sending `false` would
also fit, and `false` would mean renewal.
- **The docblock names files rather than lines.** The call sites move
often, so a line number would go stale on the next edit. The pin names
the helper, not the readers, and nothing checks the list of ten. A
reader added or moved later leaves the list stale but the declaration
correct.
- `check:entry-nameability` prints a standing `NOT MEASURED` for
`@objectstack/spec/api-assembled` and `@objectstack/spec/qa` (no
callable export). This is unrelated to `contracts`. The gate exits 0.

---

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants