Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
cbf9d64
Make room for who created a session and who sent a message
alex-clickhouse Sep 12, 2026
dbf5d1d
Carry the actor from every ingress down to the row it writes
alex-clickhouse Sep 12, 2026
369d1bf
Let a browser turn a stored actor id into a name
alex-clickhouse Sep 12, 2026
d42381c
Prove two people sharing one agent stay two people
alex-clickhouse Sep 12, 2026
4b8bc01
Write down who gets recorded, and who deliberately does not
alex-clickhouse Sep 12, 2026
36ec1e4
Pin that attribution is written once and never moved
alex-clickhouse Sep 12, 2026
894cf15
Fail an autonomous run rather than record it under nobody (r1: F1)
alex-clickhouse Sep 12, 2026
876078c
Checkpoint the Codex sync at what landed, not at what was read (r2: B1)
alex-clickhouse Sep 12, 2026
2ead0ec
Resolve a wakeup's actor before the claim consumes it (r2: B2)
alex-clickhouse Sep 12, 2026
847f28d
Resolve before approving a plan, on both approval paths (r2:…
alex-clickhouse Sep 13, 2026
d0a7f18
Resolve before an answer stops being answerable (r2:…
alex-clickhouse Sep 13, 2026
3da1da8
Resolve before the resume queue is drained (r2:…
alex-clickhouse Sep 13, 2026
44aadf3
Sweep the rest of the consumption boundaries (r2: sweep)
alex-clickhouse Sep 13, 2026
22746d4
Refuse an MCP tool call that cannot be attributed (r3: M1)
alex-clickhouse Sep 13, 2026
4f47642
Cover the three sweep fixes that only had success paths (r3: m1)
alex-clickhouse Sep 13, 2026
9207cdb
Align attribution with causal ownership
alex-clickhouse Sep 13, 2026
d176181
Align attribution tests with narrow identity API
alex-clickhouse Sep 13, 2026
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
72 changes: 71 additions & 1 deletion docs/accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,77 @@ name no account, so they are handled narrowly:
The acceptance is temporary and is removed in a later release. Nothing mints
that shape any more.

## Session-signing secret and backups
## What gets attributed, and what does not

Two nullable columns hold the answer, and both hold an **actor id** — never a
name:

| Column | Means |
|---|---|
| `sessions.created_by_actor_id` | who caused this session to exist |
| `messages.actor_id` | whose input this message records |

A name is a snapshot. Storing one would freeze it the moment somebody is
renamed, so the id is what is stored and the name is looked up when something
is displayed (`GET /api/actors`). Renaming an account changes every label and
rewrites nothing.

**Who ends up on a row:**

| The row | The actor |
|---|---|
| A session you created — from the sidebar, a fork, an approved plan | you |
| A message you typed — in the browser, over the WebSocket, through `/api/chat`, or composed and deferred with "run later" | you |
| A session or a prompt the instance produced for itself — a cron generation, a scheduled wakeup, a webhook, a workflow leg, a plan's implementation prompt, a relayed notification answer | the agent's system principal |
| A Telegram or Slack message, or imported Codex human input | `NULL` until a provider-person mapping exists |
| An MCP satellite session, or a Codex session the sync creates | the system principal |
| Anything the assistant or a tool produced, including Nerve's own status lines written in the assistant's voice | nobody — the column stays `NULL` |

The dividing line for a message is **who supplied the content**. A person
clicking "approve" on a plan gets the implementation session, because they
caused it; the implementation prompt itself is the instance's, because nobody
typed it. Recording the person on the *earlier* action — the approval, the
schedule, the task edit — is a separate row of the attribution map and is not
implemented yet.

Assistant and tool rows stay unattributed on purpose. Their authorship is
`role`, and turning "the model answered" into "a person wrote this" would be
false. Linking a turn back to whoever prompted it is a separate column
(`caused_by_actor_id`) that this release does not add.

**External people stay unidentified.** Telegram, Slack, and imported Codex
messages do not carry a Nerve account identity. Their `actor_id` is `NULL`
until a provider-person mapping exists; transport metadata remains provenance.

**History from before this version says nothing about who wrote it**, and that
is deliberate. Both columns are `NULL` on every pre-existing row, nothing is
backfilled, and no actor is inferred from a session's `source` or a message's
`channel`: those are legacy provenance strings, not identity bindings, and
turning them into attribution would invent an audit trail that never existed.
Anything reading these columns has to treat `NULL` as "not recorded" rather
than as an error.

**A stored id always resolves.** Both columns reference `actor_refs(id)`, so an
id that names nobody cannot be written — attribution nobody can look up would
render as a blank name. That is safe precisely because actor rows are never
deleted and accounts are tombstoned rather than removed (above). `NULL` is
exempt, so unattributed history is unaffected.

**`NULL` means attribution was not recorded.** That includes legacy history,
unidentified external people, and assistant/tool output. Autonomous Nerve work
uses the migration-guaranteed system actor cached by its database.

**Ids are permanent.** If an install later moves to an external identity
provider, work done before the move keeps the local actor ids it has now and
new work gets the provider's, with nothing merged and no history rewritten. The
same person can legitimately appear as two actors, and both are real.

What is *not* attributed yet, and is known to be missing: renaming, archiving,
deleting, stopping or resuming a session; task and schedule changes; who
answered a notification; and who requested or decided a workflow or a review.
Those are the remaining rows of the attribution map.

## The signing secret

Session tokens are signed with `auth.jwt_secret` when it is configured. When it
is not, the first start generates a secret and keeps it in `nerve.db`
Expand Down
41 changes: 39 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,28 @@ nobody.
Disabling takes effect at the account's **next** request, not retroactively, and
an open WebSocket keeps the identity it was accepted with until it reconnects.

### Actors

An actor is *who* something is attributed to: a person, or the agent's system
principal. Sessions and messages store an actor id, never a name, so this is
where a name is looked up at render time — a rename changes every label and
moves no stored row.

```json
{ "id": "…", "kind": "human", "display_name": "Alice" }
```

| Endpoint | Does |
|---|---|
| `GET /api/actors` | `{ "actors": [...] }`, oldest first. One row per person plus the system principal, so a single call labels a whole list |

`kind` is `human` or `system`.

This is an identity, not an account: nothing from `accounts` appears here (no
username, no `enabled`, no `has_password`), and an actor need not have an
account at all — the system principal does not. A disabled person's actor is
still readable, because their history stays in the UI after their access ends.

#### `GET /api/auth/check`
Verify current authentication.

Expand Down Expand Up @@ -182,19 +204,30 @@ Create a new session.

```json
Request: { "title": "My Session" }
Response: { "id": "a1b2c3d4", "title": "My Session", "source": "web" }
Response: { "id": "a1b2c3d4", "title": "My Session", "source": "web", "created_by_actor_id": "…" }
```

`created_by_actor_id` is an actor id for [`GET /api/actors`](#actors), not an
account id. It is `null` for legacy rows and sessions caused by an unidentified
external person. Every session payload carries the field.

#### `GET /api/sessions/{id}`
Get session details.

#### `GET /api/sessions/{id}/messages?limit=100`
Get messages for a session.

```json
Response: { "messages": [{ "id": 1, "role": "user", "content": "...", "channel": "web", "created_at": "..." }] }
Response: { "messages": [{ "id": 1, "role": "user", "content": "...", "channel": "web", "created_at": "...", "actor_id": "…" }] }
```

`actor_id` is who supplied the message: the signed-in person who typed it, or
the system principal for a prompt Nerve composed itself. It is `null` on
Slack, Telegram, and imported Codex human input until identity mappings exist;
on assistant/tool output; and on legacy history. `channel` stays transport
provenance and is never identity. See
[Accounts and identity](accounts.md) for what is and is not attributed.

#### `DELETE /api/sessions/{id}`
Delete a session (cannot delete "main"). Disconnects any active SDK client before deletion.

Expand Down Expand Up @@ -737,6 +770,10 @@ fresh.
// Error occurred
{ type: "error", session_id: "main", error: "..." }

// Another client of this session sent a message (the sender sees its own
// optimistically; actor_id is who sent it, matching the stored row)
{ type: "user_message", session_id: "main", content: "Hello", blocks: null, actor_id: "…" }

// Session switch confirmed (includes running state, lifecycle status, buffered events for reconnect)
{ type: "session_status", session_id: "abc123", is_running: true, status: "active", buffered_events: [...] }
{ type: "session_switched", session_id: "abc123" }
Expand Down
15 changes: 8 additions & 7 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,15 +172,16 @@ idle-stream support, cache-TTL policy) gate engine behavior — never

### User Message (Web UI)
1. User sends message via WebSocket
2. Gateway's WebSocket handler receives it
3. `AgentEngine.run()` called in background task
4. Streaming events sent back via WebSocket
5. Client renders tokens in real-time
2. Gateway's WebSocket handler receives it — the connection's actor was resolved once, at accept
3. `AgentEngine.run()` called in background task, carrying that actor
4. The user message is stored under it; the assistant's reply is stored unattributed
5. Streaming events sent back via WebSocket
6. Client renders tokens in real-time

### Cron Job
1. APScheduler triggers at schedule
2. Isolated per-run session created (`cron:{job_id}:{timestamp}`)
3. Agent runs with job prompt and cron model
3. Agent runs with job prompt and cron model, acting as the system principal — the session and the prompt are attributed to it, not to whoever wrote the schedule
4. Agent uses `notify`/`ask_user` tools to communicate with user
5. Run logged in `cron_logs` table

Expand Down Expand Up @@ -222,8 +223,8 @@ To add a new migration: create `nerve/db/migrations/v017_your_feature.py` with a
### Schema

SQLite with WAL mode (schema version 16):
- `sessions` — Session metadata with lifecycle columns (`status`, `sdk_session_id`, `connected_at`, `parent_session_id`, `forked_from_message`, `last_activity_at`, `archived_at`, `message_count`, `total_cost_usd`)
- `messages` — Conversation messages with tool call data and ordered `blocks` JSON column (preserves interleaving of text/thinking/tool_call blocks across page reloads)
- `sessions` — Session metadata with lifecycle columns (`status`, `sdk_session_id`, `connected_at`, `parent_session_id`, `forked_from_message`, `last_activity_at`, `archived_at`, `message_count`, `total_cost_usd`) and `created_by_actor_id` → `actor_refs`: who caused the session to exist
- `messages` — Conversation messages with tool call data and ordered `blocks` JSON column (preserves interleaving of text/thinking/tool_call blocks across page reloads), plus `actor_id` → `actor_refs`: whose input the row records. NULL means attribution was not recorded (legacy history, unidentified external people, and assistant/tool output). Nothing is backfilled — see [Accounts and identity](accounts.md)
- `session_events` — Append-only lifecycle audit log (created, started, idle, stopped, archived, error)
- `channel_sessions` — Persistent channel-to-session mapping (survives restarts)
- `session_file_snapshots` — Pre-modification file content captured via `PreToolUse` hook for session-scoped diff computation. Keyed by `(session_id, file_path)`, first-touch only. Cleaned up on session delete.
Expand Down
Loading