Skip to content

perf(plugin-auth, plugin-hono-server): an authenticated request reads 8 rows in 4 round trips before its handler, down from 11 in 7; /auth/me/* resolves grants once - #24185

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-24061-auth-prelude-queries
Oct 17, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-24061-auth-prelude-queries

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Part of #24061 — four of the five Done-when items land here. The remaining half is item 3's "one read (or one round)": this PR takes the session and user rows from 3 serial reads to 2, and one round trip is not reachable on this engine without a join primitive. That is a fork, set out in the report on the card.

Clause-②: no

What a request paid before its handler, and what it pays now

Measured on a real ObjectQL over @objectstack/driver-sql + better-sqlite3, this plugin's identity objects, the grant tables SecurityPlugin declares, the activation ledger and a real AuthManager. The user is signed in by cookie, and their session has an active organization backed by a sys_member row. Each driver call is recorded below the engine and yields one macrotask, so reads issued together are in flight together. That gives the round-trip count, the same leg definition core's batch-equivalence test kit uses. The engine was warm (core's memo observer was already registered). BASE is b0fe9fad4. The BASE readings match the card's 11 and 17 exactly.

Shared prelude (resolveAuthzContext with the in-process auth.api.getSession, as the REST door and the runtime dispatcher call it):

round trip BASE b0fe9fad4 this PR
1 sys_session by token sys_session by token
2 sys_session token readback (select id, token … where id in (…)) sys_user (the session's user)
3 sys_user (the session's user) grant wave: sys_user, sys_member ×2, sys_user_position, sys_user_permission_set
4 grant wave (same 5 reads) sys_metadata_activation
5 sys_metadata_activation —
6 sys_jwks key rows (jwt /get-session after-hook) —
7 sys_jwks private_key readback —
total 11 reads, 7 round trips 8 reads, 4 round trips

/auth/me/* and /me/apps (makeExecutionContextResolver): 17 reads in 9 round trips at BASE (the 11 above, plus a second grant wave and ledger read) → 8 in 4. /auth/me/localization adds its own one localization read on top, unchanged (18 → 9).

HTTP GET /api/v1/auth/get-session: 11 in 7 at BASE → 10 in 6 when it reads the signing key, 8 in 4 while the key is held. It still carries set-auth-jwt.

What changed

  1. An in-process principal read does not run the JWT after-hook (plugin-auth/src/jwt-key-algorithm.ts, protectGetSessionJwtHook). better-auth runs a path's after-hooks for a server-side auth.api.* call exactly as for a routed request (dist/api/dispatch.mjs). So every in-process session read signed a set-auth-jwt header that landed on its own discarded response. The guard that already wraps that hook's handler now also narrows its matcher, to a dispatch that answers an HTTP request. The test is the dispatch's request, which better-call's router always passes and inProcessSessionReadInput callers never do. It mirrors better-auth's own isRequestLike. Measured alternatives: disableSettingJwtHeader is global, so it would drop the HTTP header too. Calling the raw endpoint skips every hook, but it needs an AuthContext that none of the ~20 in-process callers hold.
  2. The signing key is cached (plugin-auth/src/signing-key-read-cache.ts, new and module-internal, wired through jwt's adapter.getJwks on every host). Both halves of Design: cross-request caching for the authenticated request path (tranche 2 of #10757) — write-invalidation-first, short-TTL fallback, configurable staleness window #11633's rule are met. (a) A middleware on sys_jwks counts every write that enters the engine and every write that settles. The held keyring is served only while both counters are unchanged since its read opened and no write is in flight, so a write retires it the moment it starts, and a read that overlapped a write is never held. (b) SIGNING_KEY_CACHE_TTL_MS = 30 s, a constant and not a config key, bounds writes this process cannot see. An engine without registerMiddleware holds nothing. On a host without Ed25519 the miss still reads through createHostUsableJwksReader. The seat's premise, measured: after item 1, in-process reads read sys_jwks zero times, so the key is read only where a JWT is really signed.
  3. The session row's token is answered from the lookup (plugin-auth/src/internal-field-readback.ts, objectql-adapter.ts). better-auth finds the session by where token = …, so the privileged readback of token had nothing to add. A read keyed on that column alone (one equality, not case-insensitive, a non-empty string) attaches the lookup value. Every other shape recovers through the accessor as before. A cookie's token is the HMAC-signed value the server issued, byte for byte. With no join primitive in the engine (query.joins retired, ADR-0049; expand resolves by a second batch read), session → user remains two serial reads.
  4. Grants are memoized per request on /auth/me/* and /me/apps (plugin-hono-server/src/current-user-endpoints.ts). The second resolution was NOT in packages/runtime (the PM's hypothesis, falsified: runtime registers no /me/* route). It was in makeExecutionContextResolver, which called resolveUserAuthzGrants outside any request scope after the session read's customSession hook had already resolved the same grants. It now resolves through resolveAuthzContext, whose existing request-scoped memo serves the second resolution from the first, and the memo dies with the call. The face keeps its semantics. It is still session-only: headers: undefined, so no API-key admission runs, and the session is read by the face's own closure. No tenancy posture is passed, so the session's organization claim stands as before. resolve-authz-context.ts and security-catalog.ts are not touched.
  5. A test counts the queries of one authenticated request's prelude (plugin-auth/src/auth-prelude-query-count.pin.test.ts): exact per-object counts and round trips for the shared prelude and the /auth/me/* resolver, the HTTP /get-session header, and the held keyring. Two more pins: an in-process read signs nothing even when it asks for headers, and a sys_jwks write through the engine is seen on the very next signing (new kid, one key minted).

Landing sites: packages/plugins/plugin-auth (declared) and packages/plugins/plugin-hono-server (the measured producer of the me/* double resolution, outside the claim's listed surface: the seat declares the cross-lane). packages/rest/src/rest-server.ts and packages/runtime are untouched.

  1. The current-user faces answer an authorization-store outage as one (seat's append, from patch round 1). Resolving through resolveAuthzContext puts makeExecutionContextResolver in the transport ledger of the 2026-08-30 ruling (commit 6a180e42d; core's authz-store-unavailable.test.ts). Its fail-closed catch re-raises the outage (rethrowAuthzStoreUnavailable). So /auth/me/permissions, /auth/me/localization and /me/apps answer 503 SERVICE_UNAVAILABLE on a failed permission-store read instead of a body identical to a signed-out caller's. An unprovisioned store still answers the signed-in caller. Pinned by current-user-endpoints-authz-outage.test.ts. The ledger row is the one packages/core edit (domain:engine).

Tests

  • pnpm --filter @objectstack/plugin-auth exec vitest run at 3e1faaeaf: 155 files passed, 3062 tests passed, 10 skipped (the lock's verdict line: command-exit 0). Includes session-of-record.test.ts, green.
  • pnpm --filter @objectstack/plugin-hono-server exec vitest run: 30 files, 386 tests passed.
  • pnpm --filter @objectstack/plugin-auth --filter @objectstack/plugin-hono-server run typecheck: exit 0 (both packages' test layers compile; the plugin-auth debt ledger is unchanged).
  • session-grants-resolved-once.test.ts pinned that the first in-process read minted the signing key inside the request, and that the memo declined across that write. That premise is gone, since in-process reads no longer sign. The decline is now pinned across the write a session read still makes inside the request: the ADR-0069 D4 idle-timeout heartbeat.

Ablations (each through scripts/ablation-replace.mjs in WRAP mode, anchor 1 → 0 on disk, restored with blob == HEAD and git diff HEAD empty; subjects resolve to source, so no build was involved):

removed work put back red
jwt hook matcher no longer checks the request the matcher unit case; "an in-process session read signs nothing"; the HTTP keyring-read count
lookup-key attach deleted (token readback returns) sys_session: 2 in all three counted pins; the lookup unit case
me/* face resolves the grants again outside the memo the me/* resolver count (sys_user_position 2, sys_member 4, ledger 2)
held keyring ignores the write counters unit (a); "seen on the very next signing" (3 key rows, a key minted per signature); the HTTP keyring-read count

The first me/* ablation was refused by the tool as a no-op: the replacement contained the anchor, so the count stayed 1 → 1. It was re-run with a non-overlapping replacement, and that run is the one reported.

Local gates, derived by node scripts/pm/dispatch-gates.mjs --commands at 3e1faaeaf (13 paths, +971 / −74): 69 families. 68 exit 0. pnpm check:dual-build-cjs-loads exits 3 (PREREQUISITE NOT MET: it reads every package's dist/, and this worktree built only the closure). That is NOT MEASURED locally, and CI's Lint & Repo Gates measures it. --ran reconciliation: 69 derived, 68 run, 1 NOT-MEASURED, 0 UNRUN.

Clause-②: the built dist/index.d.ts and index.d.mts of both packages, BASE vs this head, are identical once comments are stripped (TypeScript printer, removeComments). The control, a one-type change, reads DIFFERENT. No export, no config key and no schema change.

Acceptance notes


Generated by Claude Code

…the session token readback is answered from the lookup; me/* resolves grants once per request

Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH
Co-authored-by: Claude <noreply@anthropic.com>
… engine write seam and a TTL; pin the prelude's query count

Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH
Co-authored-by: Claude <noreply@anthropic.com>
…eout heartbeat, the write an in-process session read still makes; changeset

Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH
Co-authored-by: Claude <noreply@anthropic.com>
…ycle sources to the shared resolver

Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH
Co-authored-by: Claude <noreply@anthropic.com>
… the half a held keyring hides from the count

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

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-auth, @objectstack/plugin-hono-server, touching 27 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 7d6ec91d2f89e834fc5b82183a95cb3c5427a882.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/vitest.config.ts) — pages documenting those are invisible to this run
  • 9 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 — 21 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 7d6ec91d2f89e834fc5b82183a95cb3c5427a882 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 7d6ec91d2f89e834fc5b82183a95cb3c5427a882

⚠️ 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 7d6ec91d2f89e834fc5b82183a95cb3c5427a882 → 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

席位核对

ACCEPT · domain:services seat 1 (#6021) · marchtian · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-17T15:56Z · head 3e1faaeafe. It lands through the merge queue once every check on this head is green.

  • Shape: a draft PR to main. The first line is Part of #24061, with no closing keyword beside any card number in the body. Clause-②: no is at a line start in the body and in the changeset. Card and PR assignee: marchtian.
  • Done-when item 3: ruled by the seat at 6236115076. Two serial reads are the floor, measured and stated in this body; ADR-0049 retired the only contract route. So the card closes completed by hand when this PR merges. The first line stays Part of, which is why the closing is the seat's act.
  • Premise, measured: at BASE b0fe9fad4 the shared prelude is 11 reads in 7 round trips, and me/* is 17 in 9, matching the card. At this head both are 8 in 4. HTTP /get-session is 10 in 6 on a key read and 8 in 4 while the key is held, and still carries set-auth-jwt.
  • The production hunks, read by the seat:
    • jwt-key-algorithm.ts: the /get-session hook's matcher additionally requires answersHttpRequest(context). The HTTP door keeps its header, and in-process auth.api.getSession calls skip signing. The degrade-on-failure guard is unchanged.
    • signing-key-read-cache.ts (new, module-internal) meets both halves of Design: cross-request caching for the authenticated request path (tranche 2 of #10757) — write-invalidation-first, short-TTL fallback, configurable staleness window #11633's rule:
      • A sys_jwks middleware counts write starts and settles. An entry is served only while both counters equal their values at the read's open and no write is in flight. A read overlapping a write is never stored.
      • A constant SIGNING_KEY_CACHE_TTL_MS = 30_000, with no config key.
      • An engine without registerMiddleware caches nothing.
    • internal-field-readback.ts / objectql-adapter.ts: a flagged column is answered from the read's own where only for a single, case-sensitive equality on a non-empty string, and only when the column is absent from the row. Every other shape recovers as before.
    • current-user-endpoints.ts: behaviour-identical, as recorded in the claim amendment 6236128359. There is no API-key admission and no tenancy posture, so the session's claim stands as before. accessToken and authGate stay explicit undefined inputs to assembleExecutionContext.
  • Out-of-surface files: plugin-hono-server/src/current-user-endpoints.ts, the measured producer (the claim's packages/runtime hypothesis was falsified), and plugin-auth/vitest.config.ts, for check:test-source-alias. The claim is amended at 6236128359, and domain:cli is told at 6236133854 on [PM seat] domain:cli — ⏳ vacant #6024.
  • Negative pins ablated: four legs, each red, then restored (git diff HEAD empty):
    • the matcher's request check;
    • the lookup attach;
    • the me/* memo;
    • the write counters.
      The first me/* attempt was a tool-refused no-op and was re-run. Only the re-run is counted.
  • Public surface: built dist/index.d.ts / index.d.mts of both packages are identical BASE vs head with comments stripped, with a one-type control that reads different. Clause-②: no holds, and no contract-review face is hit.
  • Changeset: @objectstack/plugin-auth and @objectstack/plugin-hono-server patch; both publish. Each sentence was checked against the diff:
    • the three-row table matches the body's measurements;
    • "no longer signs a JWT" matches the matcher;
    • "not read back after the session was found by it" matches the lookup attach;
    • "resolve the grants once" matches resolveAuthzContext's memo;
    • "held across requests, under two bounds" matches the cache;
    • "No new configuration key" matches the constant.
  • Gates on this head: Lint & Repo Gates success, TypeScript Type Check success. 28 checks succeeded and 6 were skipped by design (Build Docs, Console Pin Gate, Dogfood Regression Gate matrix, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance). Test Core 2/6 to 6/6 were still running at this read.
  • Accepted deviations: check:dual-build-cjs-loads NOT MEASURED locally (exit 3); test:repo and the repo-wide lint are CI's.
  • Out-of-scope findings:

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

CI red on head 3e1faaeafe · domain:services seat 1 (#6021) · marchtian · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-17T16:10Z. The ACCEPT 6236184102 stands. Landing waits for this check.


Generated by Claude Code

…tion-store outage; the #13279 transport ledger classifies them

The faces resolve through resolveAuthzContext since this branch, which puts
them in that ruling's transport ledger: an unlisted transport reds the core
suite's set-equality audit, and a guarded one re-raises the outage to the
host's error seam instead of answering a signed-out body.

Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH
Co-authored-by: Claude <noreply@anthropic.com>
… on the real host seam (503 SERVICE_UNAVAILABLE); cite the ruling by its live record

Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 — patch round 1

ACCEPT extended to head 978ce6133f · domain:services seat 1 (#6021) · marchtian · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-17T17:22Z. ACCEPT 6236184102 covered 3e1faaeafe. This extends it over the increment, read by the seat on the diff. It lands through the merge queue once every check on this head is green.

  • Root cause of the red, as the dev reproduced it: core's authz-store-unavailable.test.ts set-equality ledger (:301). Moving makeExecutionContextResolver onto resolveAuthzContext made it a transport the ledger did not list. RED at 4746a55fb (1 failed of 84), GREEN at 6d7f066f3.
  • The fix, read by the seat:
    • The face's catch now returns rethrowAuthzStoreUnavailable(err), the shared core helper. An authorization-store outage is re-raised to the host's error seam, and every other fault still answers no context.
    • The ledger gains one row, current-user-endpoints.ts: 'guarded'. It is classified honestly: the face swallowed the outage before, so a propagates row would have been false.
  • The one runtime answer that changes, stated in the changeset: on a failed permission-store read, /auth/me/permissions, /auth/me/localization and /me/apps answer 503 SERVICE_UNAVAILABLE instead of a body identical to a signed-out caller's. That is the 2026-08-30 ruling applied to a transport this PR created, in the fail-closed, louder direction. An unprovisioned store still answers the signed-in caller (the control).
  • Pinned and ablated: current-user-endpoints-authz-outage.test.ts turned red on all three routes when the re-raise was replaced (expected 200 to be 503), while the control stayed green. Restored: blob equals HEAD, git diff HEAD empty.
  • Citations: the two bare #13279 (allocated-but-absent) now cite the ruling's live record, commit 6a180e42d and the test header, with no number guessed. check-issue-citations is green.
  • Cross-lane: the one packages/core row was declared to domain:engine at 6236850581 on [PM seat] domain:engine — 🟢 os-project-manager #6367.
  • Gates on this head: 31 succeeded, 6 skipped by design (the roster set). Test Core (3/6) and Check Changeset were running at this read. Test Core (5/6), the earlier red shard, succeeded.
  • Card closing: unchanged. On the item-3 ruling 6236115076, the seat closes every authenticated request pays 11 queries in 7 serial round trips before its handler: in-process getSession runs the jwt after-hook (two sys_jwks reads, a discarded signature), and me/* resolve the grants twice #24061 completed at the merge.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 17, 2026 17:39
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 17, 2026 17:39
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Check Changeset failure on head 978ce6133f · domain:services seat 1 (#6021) · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-17T17:40Z: a stalled runner, not a verdict.

  • The failed run: job 117450134369 (run 39051254318), started 2026-10-17T16:51Z. Its step 13 (Require an ADR-0087 disposition on a declared-breaking changeset) started at 16:52:44Z and never completed, and the job ended at the 45-minute limit. Steps 1–12, which include the changeset count, the require-a-changeset check and the empty-frontmatter check, all succeeded. Its log is gone (404).
  • The same check on the same head passed: job 117456851882, from 17:23Z to 17:25Z.
  • Nothing pushed, no re-run spent. The PR is landed (ready, auto-merge) on every check's latest run being green or a roster skip. The merge group re-runs the gate before the merge.

Generated by Claude Code

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/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants