Skip to content

fix(ui-server): commit authentication's per-request principal writes on the write worker - #2992

Merged
schlessera merged 6 commits into
mainfrom
fix/2850-auth-sessions-offloop-044
Oct 26, 2026
Merged

schlessera merged 6 commits into
mainfrom
fix/2850-auth-sessions-offloop-044

Conversation

@schlessera

@schlessera schlessera commented Oct 26, 2026 •

Copy link
Copy Markdown
Owner

Refs #2850

Fifth slice of #2850: authentication's per-request principal writes (group 3, "auth sessions").

What changed

Authentication no longer commits a principal row on the app connection on the request path. Four writes now commit on the ui.db write worker at synchronous=FULL:

  • the last_seen_at touch on a signed-in request;
  • pruning terminal principals, at most once an interval;
  • resolving an ambient principal, created or relabelled in the none, tailscale and proxy modes and on WebSocket upgrades;
  • a verified login's new principal, after its prune.

The procedures are the synchronous writers in principals.ts, run on the write connection (packages/ui-server/src/db/principal-writes.ts). The app registers them for its database (registerPrincipalWrites). Without a registration (tests, the cron runner) the same writers run inline.

What changes for a caller:

  • Touch: never waited for. One still pending is not submitted again.
  • Ambient principal: one that is current is read at once, with no write.
  • Login: answers only once its principal has committed, so the cookie it sets names a row that exists. Its refusals (PrincipalLimitError, PrincipalCredentialNotFoundError, PrincipalCreatorNotUsableError) keep their classes across the worker.

Revocations and administrative principal writes (agent creation, rename) still commit on the app connection. A revocation must be in force for every request from the moment it returns, so it moves with an overlay in a later slice.

Why this shape

authGuard, isWsAuthorized and issueLoginSession keep their signatures, and so do the many tests that call them. The writer is looked up per database, as the per-database prune throttle it replaces was (the unused prunePrincipalsIfDue is removed). The alternative, threading a writer through every auth entry point, would have touched every auth test without changing behaviour.

Proof

Head c31daf063: the change 6f34086a2, a merge of origin/main 4c97b68dc (#2916), one test-only commit, one dead-code removal (prunePrincipalsIfDue, no caller left), the re-pointed http-websocket-admission citations in docs/http-api-coverage.md (5dcc26ced), and a merge of origin/main 2d4671ac1 (#2985, #2983; no conflicts). Every heavy run went through the host slot wrapper.

Gate Result
bun run check:pr --base origin/main on c31daf063 passed (local preflight)
bun run test ./tests on c31daf063 1966 pass, 0 fail
bun run test packages/ui-server on c31daf063 4076 pass, 0 fail. (An earlier run on the merge 886211a91 failed 4 http-websocket-admission real-upgrade cases: they asserted with expect(...).resolves, whose nested loop never delivers the worker's reply now that the upgrade resolves its ambient principal there, the trap in docs/process/testing.md. The test-only commit awaits plainly.)
focused on 8f89d7046: principal-writes-offloop plus the 13 auth, principal, cookie, passkey, login and WebSocket files 159/159
tsc --noEmit, bun run lint, decision-citations on c31daf063 clean; 44/44
native hosted proof (prove-native-host.ts, all four cells, under flock on the fixture lock, dd … conv=fsync writers outside the lock) on 886211a91 no load: passed; 3 fsync writers: passed (both with the deliberate retained-index-namespace failure verified)

Focused tests (packages/ui-server/tests/principal-writes-offloop.test.ts, new). The app connection refuses writes to principals; the worker refuses any below FULL. The cases:

  • The touch, prune, ambient resolution (through authGuard) and login commit on the worker.
  • A touch submitted while a slow worker commit holds the queue returns at once. It is not committed on the loop, and a second one while it is pending is not submitted.
  • A signed-in request through resolveCookiePrincipal is answered without waiting for its touch.
  • A login refusal keeps its class.

Mutations (each applied, its named test run, restored)

# Mutation Fails
P1 principal writes on the app connection APP_CONNECTION_WRITE principals
P2 touch commits below FULL SQLITE_WRITE_BELOW_FULL
P3 a pending touch submitted again PRINCIPAL_PENDING_TOUCH_SUBMITTED_ONCE
P4 the cookie path waits on its touch PRINCIPAL_COOKIE_REQUEST_DOES_NOT_WAIT_FOR_TOUCH
P5 a login refusal loses its class PRINCIPAL_LOGIN_REFUSAL_CLASS_KEPT
P6 the guard resolves ambient principals on the app connection APP_CONNECTION_WRITE principals

P4 and P6 survived the first pass: no test drove the cookie path, and the guard's principal already existed. The cookie case and a reordered guard case now catch them. A duplicate touch at the same instant matches no row in SQL, so the dedupe case submits the second touch 61 s later.

Lag attribution (#2416 method with the turn wait joined, as on #2884–#2949; diagnostic not committed). Native pi UID 1000 cell, 3 fsync writers, 3 runs each.

  • Before (origin/main 2577504d2): 5 windows ≥ 250 ms after ready.
    • The cookie path's last_seen_at touch: 287 ms, its own commit (principals.ts:365 ← auth.ts:438).
    • The same touch again: 393 ms, waiting behind a worker commit.
    • The notifier tick: 376, 810 and 350 ms (next PR).
    • App-connection turns > 20 ms from principals.ts: 5 (max 402 ms).
  • After (this change): no app-connection turn from principals.ts at all. One window remains after ready, 361 ms. It overlaps only a worker upsertSession commit, with no app-connection turn, so it is recorded as unattributed.

Checklist

  • Focused local preflight/behavioral receipts and complete selected hosted proof passed; actual head/base/job checkout evidence is in Proof.
  • Acceptance criteria from the issue are all met, or the gap is named below. (Met for authentication's request-path writes; the remaining groups are listed below.)
  • The PR and completed/advanced issues have verified release milestones under docs/process/github.md.
  • A changeset is included, or this changes nothing a consumer can see. (.changeset/principal-writes-off-event-loop.md)
  • No personal data anywhere in the diff — the leakage gate covers the whole tree.
  • The integration contract index or relevant component is updated in this same commit, or nothing in the machine surface moved. (Nothing moved; principal-writes.ts is internal.)
  • Docs under docs/ reflect the change, or nothing documented moved. (docs/decisions/sqlite-write-worker.md; shifted and re-pointed citations.)
  • Provider history stays append-only (docs/decisions/provider-history.md): the provider-history guard passes, or nothing on a turn's path changed.

Anything left open

Still on the app connection:

  • the Activity notifier and push ticks (next PR; 3 windows of 350–810 ms after ready in the before-runs here);
  • principal revocation and administration, and passkeys;
  • retained retries, local exchanges, saved worker preferences, policy proposal inserts and saves;
  • history-store, governed-manifest, schedules, inbox and settings.

🤖 Generated with Claude Code

schlessera and others added 3 commits October 26, 2026 02:08
…on the write worker

Authentication no longer commits a principal row on the app connection
on the request path (#2850). A principal's last_seen_at touch,
terminal-principal pruning, ambient principal resolution and a login's
new principal commit on the ui.db write worker at synchronous=FULL,
through the synchronous writers in principals.ts run on the write
connection (db/principal-writes.ts).

A touch is never waited for, and one pending is not submitted again; an
ambient principal that is current is read at once; a login answers once
its principal has committed, and its refusals keep their classes across
the worker. Revocations and administrative principal writes still commit
on the app connection.

Refs #2850

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The upgrade resolves its ambient principal on the write worker (#2850);
expect().resolves waits in a nested loop that never delivers the
worker's reply (docs/process/testing.md).

Refs #2850

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@schlessera schlessera added this to the 0.44.0 milestone Oct 26, 2026
schlessera and others added 3 commits October 26, 2026 05:06
Authentication prunes through principalWrites(db).pruneIfDue now, so
prunePrincipalsIfDue and its per-database map have no caller.

Refs #2850

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant