Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/session-catalog-off-event-loop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@schlessera/brain-ui-server": patch
---

Changed: the hosted server commits the session catalog on its SQLite write worker at `synchronous=FULL`, in submission order, instead of on the event loop (#2850): the session row, accepted work and turn boundaries, message sources, turn failures, the state room, Claude usage accounting, handoff links, labels and session costs, and the worker-profile and policy-proposal binds a new session makes. Reads on the turn path see a write from its submission; the session list, history replays and retry reads wait for the writes submitted before them.
2 changes: 1 addition & 1 deletion docs/decisions/answer-delivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ carries, no longer by position.

The host also re-sends a session's pending questions after `session_resume`
(`resendPendingAsks(host, ws, msg.sessionId)`,
`packages/ui-server/src/ws/dispatch.ts:641`). Before this, a resume replaced
`packages/ui-server/src/ws/dispatch.ts:648`). Before this, a resume replaced
the transcript and left only a history card behind, which was the second
symptom.

Expand Down
2 changes: 1 addition & 1 deletion docs/decisions/async-collaboration.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ permission and unwinds the turn. Already completed side effects are not undone
by an abort. Attempt staging and cleanup therefore need idempotent recovery,
with filesystem compensation outside SQLite transactions. Ordinary permission
parking still exists (`requestPermission: (req) => {`,
`packages/ui-server/src/ws/bridge.ts:354-447`); it is not a durable Action store.
`packages/ui-server/src/ws/bridge.ts:358-451`); it is not a durable Action store.

Resolution validates the stored effect again, checks current authority, records
one resolution and applies the guarded state transition in one transaction.
Expand Down
10 changes: 5 additions & 5 deletions docs/decisions/governed-writable-workers.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ interactive work. Voice shows its policy read-only; dictation is interactive.
The current worker never changes profile because a later save succeeded.
Unknown enforcement facts remain unknown, and requested preference is separate
from the actual admitted worker, including fallback
(`createSessionProfiles`, `packages/ui-server/src/ws/session-profiles.ts:12-89`;
(`createSessionProfiles`, `packages/ui-server/src/ws/session-profiles.ts:16-132`;
`begin`, `packages/ui-server/src/brain/governed-turn.ts:141-306`).

**Where a turn falls back to `strict`.** The launcher refuses to start
Expand All @@ -163,7 +163,7 @@ still holds.
**Voice.** Ruling 1 makes voice `governed` too. Voice keeps its narrower
membership (`VOICE_ALLOWED_TOOLS`, `packages/ui-backend-claude/src/tool-policy.ts:120`):
no Bash, no raw `Write`/`Edit`, no archive, and approval returns false
(`const apply = createBrainApplication`, `packages/ui-server/src/ws/bridge.ts:658-663`).
(`const apply = createBrainApplication`, `packages/ui-server/src/ws/bridge.ts:662-667`).
A voice turn therefore writes only through curated capture and update, now
with a journal behind it. The writable mount gives voice no tool it lacked.
Pi still refuses voice turns.
Expand Down Expand Up @@ -1072,7 +1072,7 @@ principal authority remain the caller's responsibility. The policy publisher
creates, writes, fsyncs and removes its operation-owned staging name entirely
inside that synchronous callback, between the two serialized observations
(`await options.observeUserPolicy`,
`packages/ui-server/src/brain/policy-proposal.ts:198-225`). First publication
`packages/ui-server/src/brain/policy-proposal.ts:210-237`). First publication
also removes the temporary hardlink before the final observation. No filename
prefix is ignored; a similarly named real agent change is still retained.
Failed staging preserves the original policy, while failed recording after
Expand Down Expand Up @@ -1660,7 +1660,7 @@ The brain's git history stays the durable record of its content.
- **The Activity recorder and its rollups** price those events under the
[cost-tracking rules](cost-tracking.md). An unknown price is never counted as
zero. The session total that counts unknown as zero
(`msg.costUsd ?? 0`, `packages/ui-server/src/ws/session-catalog.ts:368`)
(`msg.costUsd ?? 0`, `packages/ui-server/src/ws/session-catalog.ts:375`)
must not feed any budget.

**Enforcement:**
Expand Down Expand Up @@ -2587,7 +2587,7 @@ every turn without a proven recipient:
- **The handoff summary.** Drafting a handoff summary refuses when the
policy has provider rules and the source chat's lineage does not cover
every provider folder, before its history is read (`unprovenHistory`,
`packages/ui-server/src/ws/handoff.ts:303-306`). The run has no proven
`packages/ui-server/src/ws/handoff.ts:309-312`). The run has no proven
recipient, so every provider folder is denied to it. A policy with a type
selector always refuses: the type's folders follow the current taxonomy,
which this check does not translate, and a remembered translation may be
Expand Down
8 changes: 4 additions & 4 deletions docs/decisions/http-api-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,11 @@ explicit supported rows, not silently excluded by this distinction.

The source audit on 2026-09-30 used
`e0875269b21b707482545be9e94901acb897893b`. The public health mount precedes auth
(`createHealthRoutes`, `packages/ui-server/src/app.ts:860`), the API guard follows
public ceremonies (`authGuard`, `packages/ui-server/src/app.ts:881`), and the
WebSocket admission is separate (`"/ws"`, `packages/ui-server/src/app.ts:1070`).
(`createHealthRoutes`, `packages/ui-server/src/app.ts:864`), the API guard follows
public ceremonies (`authGuard`, `packages/ui-server/src/app.ts:885`), and the
WebSocket admission is separate (`"/ws"`, `packages/ui-server/src/app.ts:1075`).
Static serving is conditional (`if (options.staticRoot)`,
`packages/ui-server/src/app.ts:1090-1111`). That order establishes access;
`packages/ui-server/src/app.ts:1095-1116`). That order establishes access;
it does not select compatibility status.

A hermetic real-app launch, with temporary brain root, in-memory database,
Expand Down
2 changes: 1 addition & 1 deletion docs/decisions/session-handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ its distinct retry treatment. Nothing archives, purges or automatically sends.
The notice's presentation and actions are (`StateRoomNotice`,
`packages/ui-react/src/components/chat/state-room-notice.tsx:22-100`); the host
keeps the measurement and first quiet event (`if (msg.type === "result") {`,
`packages/ui-server/src/ws/bridge.ts:275-285`).
`packages/ui-server/src/ws/bridge.ts:279-289`).

### Historical state directories remain untouched

Expand Down
6 changes: 3 additions & 3 deletions docs/decisions/session-principals.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,9 @@ Both reviewers verified every row below against the source.
| Cookie verify | `auth.ts:265-296` | strict `^(\d+)\.(\d+)$`, safe integers, future/expired rejection, `cookieEpoch === sessionsEpoch(db)` |
| Global epoch | `auth.ts:302-318` | `settings` row; missing = 0; non-integer **throws** |
| Global revoke | `auth.ts:321-330` | `setSetting` (overflow-guarded) + `clients.closeAll(1008, …)` |
| Auth routes | `authRoutes`, `app.ts:866` | mounted **before** the guard at `authGuard(authMode`, `app.ts:881`; logout guards itself (`resolveCookiePrincipal(c, auth, deps.db)`, `auth.ts:808`) |
| Passkey management | `passkeyManagementRoutes`, `app.ts:891` | mounted **after** the guard ("Mount AFTER the auth guard") |
| WS guard | `"/ws"`, `app.ts:1070-1087` | origin → `isWsAuthorized` (returns a boolean, `auth.ts:221-237`) → capacity → upgrade |
| Auth routes | `authRoutes`, `app.ts:870` | mounted **before** the guard at `authGuard(authMode`, `app.ts:885`; logout guards itself (`resolveCookiePrincipal(c, auth, deps.db)`, `auth.ts:808`) |
| Passkey management | `passkeyManagementRoutes`, `app.ts:895` | mounted **after** the guard ("Mount AFTER the auth guard") |
| WS guard | `"/ws"`, `app.ts:1075-1092` | origin → `isWsAuthorized` (returns a boolean, `auth.ts:221-237`) → capacity → upgrade |
| WS upgrade | `ws/connection.ts:262-264` | `createWsUpgrade` ignores the request context |
| WS admission | `ws/connection.ts:84` | `clients.add(ws)` — the socket's identity is unknown |
| Turn record | `ws/run-session.ts:136-144` | recorder built from `{turnId, sessionId, billing}`; `RunningTurn` (`ws/turns.ts:55-77`) has no actor |
Expand Down
44 changes: 37 additions & 7 deletions docs/decisions/sqlite-write-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,13 @@ thread waits for it.

## The exceptions

- **A write inside the caller's transaction runs there.** The session catalog
writes title and unstreamed-usage spans inside its own transaction on the app
connection. The off-loop Activity store sees `db.inTransaction` and runs that
write synchronously on the caller's connection, so it stays atomic with the
caller's rows. That commit is the caller's, on the event loop, as before.
- **A write inside the caller's transaction runs there.** The off-loop
Activity store sees `db.inTransaction` and runs such a write synchronously on
the caller's connection, so it stays atomic with the caller's rows. Claude
usage accounting, which writes title and unstreamed-usage spans inside its
own transaction, now runs that whole transaction on the worker, with a store
on the worker's connection (`workerActivity`,
`packages/ui-server/src/activity/store-writes.ts`).
- **The in-memory database has no file** for a second connection. Its writer
runs the same procedures inline, in the same order, behind the same promises.
- **Rollup pricing in the worker** reads the same on-disk table and bundled
Expand Down Expand Up @@ -121,9 +123,37 @@ thread waits for it.
at once; its warnings and any block follow the commit. A relayed provider
request is admitted only after every budget write submitted before it has
committed, and a turn's outcome waits for its budget writes.
- **The session catalog followed** (#2850). The session row, accepted work,
turn boundaries, message sources, turn failures, the state room, Claude
usage accounting, handoff links, labels and session costs commit on the
worker (`packages/ui-server/src/ws/session-catalog-writes.ts`), and so do
the two writes `session_info` makes that reference the new row: binding a
draft's worker profile and recording the admitted one
(`packages/ui-server/src/ws/session-profile-writes.ts`), and binding the
turn's policy proposals (`packages/ui-server/src/brain/policy-proposal-writes.ts`).
The worker's order puts each after the row it references. Readers see the
catalog's writes in two ways. A synchronous read on the turn path (whether
a session exists, its backend, provider and label, its latest accepted
work, a session's worker profile and whether a draft is still unconsumed)
answers from the committed row with every pending write's own effect
applied, so a write counts from its submission, as it did when it
committed inline. Each effect stops counting when its write settles, so a
failed one stops at once. A reader that joins committed rows onto history
(`publishHistory`, the session list and history routes, handoff sources,
the policy proposal route) or reads retained retries first awaits
`settled()` (`historySettled`, `packages/ui-server/src/ws/host.ts`). An
acceptance's revision is assigned where it is accepted, one above the
highest the catalog has issued or seen committed, so it is known at once
and a failed write's revision is never issued again; only the newest
acceptance's outcome orders or unorders the session. Retained retries and
local exchanges still commit on the app connection, so a failed retry
invalidation still refuses the input that needed it; a failure retained
before its session's row has committed is stored once it has, unless an
input invalidated it meanwhile.
- **Other app-connection writers stay on the loop for now**: the writer gate,
the session catalog and message sources, the Activity
notifier and push ticks, auth sessions, inbox and settings. Each commits at
retained retries and local exchanges, saved worker profiles, policy
proposals, the Activity notifier and push ticks, auth sessions, inbox and
settings. Each commits at
FULL on the loop, and while a worker procedure holds its turn, such a write
also waits there for the worker's commit to finish, including a journal
procedure that writes only `journal.db`, because the ledger's
Expand Down
14 changes: 7 additions & 7 deletions docs/decisions/voice-permission.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@ exchange over a security decision is a second chance for noise to produce a
grant.

**"Always allow" cannot be given by voice.** It is a persistent policy change
(the block computing `remembers`, `packages/ui-server/src/ws/dispatch.ts:439-445`)
(the block computing `remembers`, `packages/ui-server/src/ws/dispatch.ts:446-452`)
and it is the one decision on
the card with no keyboard shortcut, by D37's ruling 5, and the reason given
there is exactly the one that applies here: *"a letter that grants standing
Expand All @@ -405,8 +405,8 @@ permission by reflex is the one footgun in the vocabulary"*
A microphone is a reflex surface with worse recognition than a keyboard. This
costs nothing measurable:
the server already refuses `always` for kind `command` requests
(the block computing `remembers`, `dispatch.ts:439-445`, and the lookup computing
`remembered`, `ws/bridge.ts:364-384`), and 192 of 192 measured
(the block computing `remembers`, `dispatch.ts:446-452`, and the lookup computing
`remembered`, `ws/bridge.ts:368-388`), and 192 of 192 measured
approvals were kind `command`.

### When the announcement actually fires
Expand Down Expand Up @@ -463,7 +463,7 @@ the request kind, and it patches the span
(`onApprovalDecision`, `packages/ui-server/src/activity/recorder.ts:674-689`),
fed from the bridge's
`recorded()` wrapper
(`const recorded`, `packages/ui-server/src/ws/bridge.ts:406-421`).
(`const recorded`, `packages/ui-server/src/ws/bridge.ts:410-425`).

One thing is missing and is a follow-up: **the event does not record the
modality.** A denial decided by a phrase a microphone heard and one decided by a
Expand All @@ -475,7 +475,7 @@ provenance.

The wire needs nothing new: a spoken refusal is an ordinary `tool_denial`
(in `handleClientMessage`, the arm `case "tool_denial"`,
`packages/ui-server/src/ws/dispatch.ts:486-509`) with a message naming the
`packages/ui-server/src/ws/dispatch.ts:493-516`) with a message naming the
phrase that produced it.

## Containment: shared with #51, deliberately not identical
Expand Down Expand Up @@ -608,9 +608,9 @@ matters because a reader cannot otherwise tell a live hazard from a fixed one.
purpose, so a grant that exists and is deliberately not applied can be logged
— but refuses to answer from it or add to it for a tool the enforced allowlist
left out (the lookup computing `remembered`,
`packages/ui-server/src/ws/bridge.ts:364-384`,
`packages/ui-server/src/ws/bridge.ts:368-388`,
and the block computing `remembers`,
`packages/ui-server/src/ws/dispatch.ts:439-445`).
`packages/ui-server/src/ws/dispatch.ts:446-452`).
The evaluation happens
before the lookup, which is what this record asked for.

Expand Down
4 changes: 2 additions & 2 deletions docs/investigations/live-conversation-investigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,12 +279,12 @@ matcher. An isolated ambiguous refusal denies all **current** pending requests;
an exact refusal can name one. With none pending, it is ordinary input. Bind
each denial to existing IDs, echoed turn and `channel:"voice"`; the server still
rejects voice grants
(`if (msg.channel === "voice")`, `packages/ui-server/src/ws/dispatch.ts:399-422`).
(`if (msg.channel === "voice")`, `packages/ui-server/src/ws/dispatch.ts:406-429`).
Never add a spoken approve or always-allow path.

On the evidence date the denial handler removed/resolved a matched request but
provided no dedicated denial receipt at that branch; #957 added one
(`case "tool_denial"`, `packages/ui-server/src/ws/dispatch.ts:486-509`). The
(`case "tool_denial"`, `packages/ui-server/src/ws/dispatch.ts:493-516`). The
integration therefore needs host-authoritative resolution/replay: denied,
already granted, expired, or unknown/disconnected. Speak a denial confirmation
only for a confirmed denied outcome. A visual grant winning the race remains
Expand Down
6 changes: 3 additions & 3 deletions docs/plans/async-collaboration-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ session. Three consequences:

1. **Background work cannot ask.** Cron runs (`sync`, `validate`, `maintain`, module jobs)
have no human attached. `bridge.requestPermission` parks a promise that nobody will
resolve (`requestPermission: (req) => {`, `packages/ui-server/src/ws/bridge.ts:354-448`), so autonomous work is confined to
resolve (`requestPermission: (req) => {`, `packages/ui-server/src/ws/bridge.ts:358-452`), so autonomous work is confined to
whatever is pre-approved, and anything requiring judgment is not attempted.
2. **Inbound material has no path.** A forwarded email, a shared link, a captured note has
nowhere to land that the agent will act on later. The PWA share target stages into
Expand Down Expand Up @@ -226,7 +226,7 @@ Stated before the requirements because five of them derive from it.
that is the shape to copy, including its `close()` lifecycle.
- R19. **The cron backstop has independent authorization before the general guard.**
Mount it on the existing listener before
(`app.use("/api/*", authGuard(`, `packages/ui-server/src/app.ts:881`).
(`app.use("/api/*", authGuard(`, `packages/ui-server/src/app.ts:885`).
Authorize a boot-minted ephemeral token, rotated each boot and stored in a
0600 runtime file, with the actual socket address as an additional check.
Proxy headers cannot authorize it. The poke succeeds in every auth mode
Expand Down Expand Up @@ -263,7 +263,7 @@ Stated before the requirements because five of them derive from it.
threshold (~20s of the 30s lock budget). Below it nothing yields; above it the
holder checkpoints/unwinds/releases. Test both edges and independent paths.
- R26. **Abort-and-redo needs a checkpoint primitive.** `requestPermission` parks a bare
promise (`return new Promise<PermissionDecision>((resolve) => {`, `packages/ui-server/src/ws/bridge.ts:398-456`) — while blocked on it the model cannot write anything, so
promise (`return new Promise<PermissionDecision>((resolve) => {`, `packages/ui-server/src/ws/bridge.ts:402-460`) — while blocked on it the model cannot write anything, so
"writes its findings, then aborts" has nowhere to run. The autonomous bridge must, in one
server-side step: capture the checkpoint, create the Action and block the item, unwind without a live approval promise, preserving the tested timeout
path's abort-then-drain order (`abortController.abort();`, `packages/ui-server/src/ws/run-session.ts:393-399`). Aborting does **not** undo completed tool side
Expand Down
6 changes: 3 additions & 3 deletions docs/plans/async-collaboration.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ than last.
## Problem Frame

Collaboration is synchronous-only: background work cannot ask (`requestPermission` parks a
promise nobody resolves — (`requestPermission: (req) => {`, `packages/ui-server/src/ws/bridge.ts:354-448`)), inbound material
promise nobody resolves — (`requestPermission: (req) => {`, `packages/ui-server/src/ws/bridge.ts:358-452`)), inbound material
has no path that survives until the user is present, and no decision accumulates into
standing authority. See origin for the full frame.

Expand Down Expand Up @@ -131,10 +131,10 @@ system gate. It is not a prerequisite to rebuild or a new independent package.
owns manual triggers/history; it does not tick the inbox.
- **Two connections:** (`export function createUiDb(`, `packages/ui-server/src/db/client.ts:27-44`) sets WAL,
foreign keys and a 5-second busy timeout. Claims are immediate transactions.
- **Auth mounting:** (`app.use("/api/*", authGuard(`, `packages/ui-server/src/app.ts:881`) follows public routes;
- **Auth mounting:** (`app.use("/api/*", authGuard(`, `packages/ui-server/src/app.ts:885`) follows public routes;
(`export function authGuard(`, `packages/ui-server/src/middleware/auth.ts:189-249`) binds principals in each auth mode.
An internal poke needs independent token authorization before this guard.
- **Permission parking:** (`requestPermission: (req) => {`, `packages/ui-server/src/ws/bridge.ts:354-448`). Timeout unwind is
- **Permission parking:** (`requestPermission: (req) => {`, `packages/ui-server/src/ws/bridge.ts:358-452`). Timeout unwind is
(`abortController.abort();`, `packages/ui-server/src/ws/run-session.ts:393-399`). Durable escalation must checkpoint
before unwinding; the existing ordinary bridge does not do that.
- **Tool enforcement:** (`const enforcementHook: HookCallback`, `packages/ui-backend-claude/src/permission-hooks.ts:200-229`) closes measured bypasses.
Expand Down
Loading
Loading