Skip to content
Merged
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
4a7fb2c
Record the cause of each turn and who changes a task
alex-clickhouse Oct 16, 2026
be8de6d
Show who changed a task status, and for whom, in the timeline
alex-clickhouse Oct 16, 2026
0f3ae55
Document actor and cause attribution
alex-clickhouse Oct 16, 2026
dddf581
Bind a turn's cause to the turn and to the work it starts
alex-clickhouse Oct 16, 2026
e460470
Keep a concurrent status change when PATCH saves content
alex-clickhouse Oct 16, 2026
a707ec1
Record who changes the set of task statuses
alex-clickhouse Oct 16, 2026
e0e6633
Document the turn cause binding and task status events
alex-clickhouse Oct 16, 2026
2c2ed9c
Run a turn with no cause while other runtime output can arrive
alex-clickhouse Oct 16, 2026
cc34461
Keep a concurrent move when task_update or task_write saves a file
alex-clickhouse Oct 16, 2026
505b640
Leave a report or watcher turn unknown when causes mix
alex-clickhouse Oct 16, 2026
854deb3
Let the new-turn cause check read state only
alex-clickhouse Oct 16, 2026
3005628
Run file-plus-row changes of one task one at a time
alex-clickhouse Oct 16, 2026
60d9e94
Document who is billed while background work shares a process
alex-clickhouse Oct 16, 2026
756b530
Give each session one fixed cause for all agent work
alex-clickhouse Oct 17, 2026
4052ff7
Document the session cause
alex-clickhouse Oct 17, 2026
7661807
Know every session's stored cause from connect on
alex-clickhouse Oct 17, 2026
d169f91
Describe causes without turns; remove dashes in session docs
alex-clickhouse Oct 17, 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
80 changes: 66 additions & 14 deletions docs/accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,22 +169,74 @@ In external mode:

## Attribution

Sessions store who created them in `created_by_actor_id`. User messages store
who supplied the content in `actor_id`. Both columns contain stable actor IDs;
clients resolve names through `GET /api/actors` — the display name, or the
username when none is set.
Nerve can record two actor IDs for a change:

| Event | Stored actor |
- The **actor** made the change: a person through the web UI, the REST API or
the browser WebSocket, or the system actor when the agent or Nerve did it (a
tool call, a cron job, any internal process).
- The **cause** is the principal on whose behalf the change was made. For a
change that a person made directly, the cause is that person. For work by
the agent, the cause is the cause of its session.

These columns store actor IDs:

| Column | What it records |
|---|---|
| `sessions.created_by_actor_id` | Who created the session |
| `sessions.caused_by_actor_id` | The session's cause: on whose behalf all agent work in the session runs |
| `messages.actor_id` | Who supplied the content of a user message |
| `task_events.actor_id`, `task_events.caused_by_actor_id` | Actor and cause of a task status change |
| `task_status_events.actor_id`, `task_status_events.caused_by_actor_id` | Actor and cause of a change to the set of task statuses |

`nerve/db/actor_columns.py` lists every attribution column. Clients resolve
names through `GET /api/actors`, which returns every actor that these columns
can name: the display name, or the username when none is set. The web UI shows
the system actor with a person as cause as "Nerve for <person>".

### The session's cause

Every session has one cause. Nerve stores it when it creates the session and
never changes it:

| Session | Cause |
|---|---|
| A person creates a session or sends a message | That person's actor |
| Nerve creates a session or prompt, including MCP and Codex sessions | The system actor |
| Telegram, Slack, or imported Codex input | None until a local identity mapping exists |
| Assistant and tool output | None |

Existing records remain unattributed. Nerve does not infer identity from channel
or source metadata. Account and session administration actions are not yet
recorded as audit events. Missing attribution means legacy history, unidentified
external input, or assistant/tool output; autonomous work uses the system actor.
| A person creates or forks a session (web UI, REST API, browser WebSocket) | That person |
| A cron job or a hook session | The system actor |
| A plan implementation session after an approval through the REST API | The person who approved |
| A plan implementation session after the agent approved the plan | The cause of the session in which the agent approved it |
| A planner session that a revision request creates | The person who asked for the revision |
| A workflow leg session | The system actor |
| A Telegram, Slack or other channel session, or an imported Codex session | None until a local identity mapping exists |
| An external MCP client's session | None |

Everything the agent does in a session (a tool call, a task it changes, a plan
it approves, a session it starts) records the system actor as actor and the
session's cause as cause. A change that a person makes directly records that
person as both. `messages.actor_id` keeps naming who wrote each message; there
is no cause per message or per turn.

This is an approximation in two places:

- In a session that several people use, the agent's work in every turn is
attributed to the person who started the session, also in turns that
another person sent.
- A deferred message ("run later") or a wakeup that runs in an existing session
is attributed to that session's cause.

| Work | Actor | Cause |
|---|---|---|
| A person sends a message, or changes a task through the web UI or the REST API | The person | The person |
| The agent works in a session (tool calls, task changes, sessions it starts) | The system actor | The session's cause |
| Telegram, Slack, or imported Codex input | None until a local identity mapping exists | None |
| Assistant and tool output | None | None |

Existing records remain unattributed: Nerve does not backfill these columns.
Nerve does not infer identity from channel or source metadata. The free-text
origin fields, such as `task_events.actor`, stay as they are; they record where
a change came from, not who made it. Account and session administration actions
are not yet recorded as audit events. Missing attribution means legacy history,
unidentified external input, or assistant/tool output; autonomous work uses the
system actor.

## Session-signing secret

Expand Down
23 changes: 17 additions & 6 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -713,9 +713,9 @@ writes no configuration.
### 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.
principal. Sessions, messages and task events 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", "username": "alice" }
Expand Down Expand Up @@ -781,13 +781,18 @@ Create a new session.

```json
Request: { "title": "My Session" }
Response: { "id": "a1b2c3d4", "title": "My Session", "source": "web", "created_by_actor_id": "…" }
Response: { "id": "a1b2c3d4", "title": "My Session", "source": "web", "created_by_actor_id": "…", "caused_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.

Session payloads also carry `caused_by_actor_id`: the session's cause, on
whose behalf all agent work in the session runs. Nerve sets it when it creates
the session and never changes it (an actor id, or `null` when unknown). See
[Attribution](accounts.md#attribution).

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

Expand Down Expand Up @@ -996,11 +1001,17 @@ A task's status history, oldest first.

```json
Response: { "events": [
{ "id": 1, "task_id": "...", "from_status": null, "to_status": "pending", "actor": "system", "created_at": "..." },
{ "id": 2, "task_id": "...", "from_status": "pending", "to_status": "in_progress", "actor": "impl-abc123", "created_at": "..." }
{ "id": 1, "task_id": "...", "from_status": null, "to_status": "pending", "actor": "web", "actor_id": "…", "caused_by_actor_id": "…", "created_at": "..." },
{ "id": 2, "task_id": "...", "from_status": "pending", "to_status": "in_progress", "actor": "impl-abc123", "actor_id": "…", "caused_by_actor_id": "…", "created_at": "..." }
] }
```

`actor` is the origin of the change (`web`, a session id, `system`,
`backfill`). `actor_id` is who made the change and `caused_by_actor_id` on
whose behalf, both actor ids for [`GET /api/actors`](#actors) and `null` on
rows from before they were recorded. See
[Status History](tasks.md#status-history).

#### `POST /api/tasks/{id}/move`
Place a task in a lane — the board's drag-and-drop write. Send *intent*
("between these two cards"), not a computed rank: the server resolves the
Expand Down
40 changes: 33 additions & 7 deletions docs/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,9 @@ Two rules keep ranks meaningful:
### Status History

Every status transition is appended to `task_events` (`task_id`,
`from_status`, `to_status`, `actor`, `created_at`), added in migration v044
and seeded with one origin row per existing task. `from_status` is NULL on
`from_status`, `to_status`, `actor`, `actor_id`, `caused_by_actor_id`,
`created_at`), added in migration v044 and seeded with one origin row per
existing task. `from_status` is NULL on
the row recording a task's creation, so an aging calculation can tell
"created here" from "moved here".

Expand All @@ -134,11 +135,28 @@ rewrites every row with the status it already had. The one case where
reindex *does* change a status, resetting an orphaned row to match its
directory, is a real correction and gets a row.

`actor` is the session id for agent-driven changes, `web` for the HTTP API,
`backfill` on the origin rows v044 seeded for tasks that predate the table,
and `system` otherwise. `backfill` is load-bearing rather than cosmetic: a
real creation also records a NULL `from_status`, so the actor is the only
thing distinguishing a synthesized origin from one that actually happened.
`actor` is the origin of the change, not an identity: the session id for
agent-driven changes, `web` for the HTTP API, `backfill` on the origin rows
v044 seeded for tasks that predate the table, and `system` otherwise.
`backfill` is load-bearing rather than cosmetic: a real creation also records
a NULL `from_status`, so the actor is the only thing distinguishing a
synthesized origin from one that actually happened.

`actor_id` and `caused_by_actor_id` (migration v053) name who made the change
and on whose behalf, as actor IDs (see
[Attribution](accounts.md#attribution)). A change through the HTTP API records
the person as both. A change by an agent tool call records the system actor
and the cause of its session: the person who started the session, or the
system actor for a cron job. Rows written before v053 have NULL in both
columns. The `GET /api/tasks/{id}/events` rows include both columns, and the
timeline shows "Nerve for <person>" when the agent changed the status in a
session that the person started.

Changes to the set of statuses (create, update, reorder, delete, through the
HTTP API or the `task_status_create` tool) append a row to
`task_status_events` (`change`, `status_name`, `detail`, `actor_id`,
`caused_by_actor_id`, `created_at`), added in migration v053. `status_name` is
NULL for a reorder, and `detail` is a JSON object with the new values.

This powers the aging indicator on board cards and the timeline in the task
detail view. Tasks whose last transition predates v044 report no entry time
Expand All @@ -151,6 +169,14 @@ Every write to a task row advances `tasks.revision` by one, added in
migration v052. The row routes return it, and `task_read` ends with a
`<!-- nerve: revision=N ... -->` line that `task_write` strips again.

Changes of one task that write its file and its row run one at a time: the
`PATCH` and `move` routes, `task_update`, `task_write`, `task_done` and the
reopen path hold a lock per task from their first read of the row to their
last write (`nerve/tasks/locks.py`). So an edit always lands in the file that
the row names, also when another change moves the file into or out of
`done/` at the same time. Changes of different tasks do not wait for each
other.

Pass the value you read as `expect_revision` to `task_update` or `task_done`
to make the write conditional: if the row has changed since, nothing is
written and the tool reports a conflict, so re-read and retry. Without it,
Expand Down
40 changes: 36 additions & 4 deletions nerve/agent/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
from nerve.agent.tools import init_tools
from nerve.config import NerveConfig, RESUME_QUEUE_FILE, load_mcp_servers
from nerve.db import Database
from nerve.identity import Actor
from nerve.identity import CAUSE_FROM_ACTOR, Actor, CauseArg, resolve_cause
from nerve.observability.langfuse import attributes as lf_attrs
from nerve.setup_writer import (
TASK_ONBOARDING_MARKER,
Expand Down Expand Up @@ -375,12 +375,28 @@ def get_active_channel(self, session_id: str) -> str | None:
"""Return the channel name currently driving ``session_id`` (or None)."""
return self._active_channel.get(session_id)

def session_cause(self, session_id: str) -> str | None:
"""The session's stored cause: who all agent work in it runs for.

That is the person who started the session, the system actor for a
session the instance started for itself, or ``None`` when unknown
(channel input with no known person, an imported or MCP satellite
session). The value is set once when the session is created and never
changes. See :meth:`nerve.db.sessions.SessionStore.session_cause`.
"""
return self.db.session_cause(session_id)

# ------------------------------------------------------------------ #
# Backend resolution (sticky per session) #
# ------------------------------------------------------------------ #

def _build_tool_context(self, session_id: str) -> ToolContext:
"""Fresh per-session ToolContext for backend MCP servers."""
"""Fresh per-session ToolContext for backend MCP servers.

The agent's tool calls are the system actor's changes, made for the
session's stored cause. The value never changes, so the context
keeps the one read here (the client build has just read the row).
"""
return ToolContext(
session_id=session_id,
workspace=self.config.workspace,
Expand All @@ -391,6 +407,8 @@ def _build_tool_context(self, session_id: str) -> ToolContext:
skill_manager=self._skill_manager,
engine=self,
notification_service=self.notification_service,
actor=self.db.system_actor,
cause=self.session_cause(session_id),
)

def _gateway_port(self) -> int | None:
Expand Down Expand Up @@ -2494,6 +2512,7 @@ async def run(
bump_updated_at: bool = True,
*,
actor: Actor | None,
cause: CauseArg = CAUSE_FROM_ACTOR,
) -> str:
"""Run the agent for a user message and return the final text response.

Expand All @@ -2507,6 +2526,11 @@ async def run(
reply, whose authorship is its ``role``. Required
keyword-only: an autonomous caller must say so rather
than inherit a person's identity by omission.
cause: The session's cause, used only when this run creates the
session: an :class:`Actor`, an actor ID, or ``None``
for unknown. The default is the actor. An existing
session keeps its stored cause (see
:meth:`session_cause`), whoever sends the message.
internal: If True, the user_message is a system-generated trigger
(e.g., background task completion) and won't be stored in
DB or shown in the UI.
Expand All @@ -2523,6 +2547,9 @@ async def run(
trigger message", and plenty of internal turns (a
run-later deferral, the star hook) are user-requested.
"""
# Resolved before the lock: an invalid cause fails here, before the
# session is marked as running.
cause_id = resolve_cause(actor, cause)
# Serialize runs per session — messages for the same session wait
# in order instead of failing with "already running".
lock = self._session_locks.setdefault(session_id, asyncio.Lock())
Expand Down Expand Up @@ -2554,6 +2581,7 @@ async def run(
image_refs=image_refs,
bump_updated_at=bump_updated_at,
actor=actor,
cause_id=cause_id,
)
finally:
self.sessions.mark_not_running(session_id)
Expand Down Expand Up @@ -2600,9 +2628,13 @@ async def _run_inner(
bump_updated_at: bool = True,
*,
actor: Actor | None,
cause_id: str | None,
) -> str:
# Ensure session exists in DB
await self.sessions.get_or_create(session_id, source=source, actor=actor)
# Ensure session exists in DB. ``cause_id`` applies only if the row
# is created here.
await self.sessions.get_or_create(
session_id, source=source, actor=actor, cause=cause_id,
)

session = await self.db.get_session(session_id)

Expand Down
9 changes: 8 additions & 1 deletion nerve/agent/plan_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
if TYPE_CHECKING:
from nerve.agent.engine import AgentEngine
from nerve.db import Database
from nerve.identity import ActorOrId

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -57,6 +58,8 @@ async def request_plan_revision(
engine: "AgentEngine",
plan_id: str,
feedback: str,
*,
cause: "ActorOrId | None",
) -> dict:
"""Persist revision feedback and dispatch a revision prompt to the planner.

Expand All @@ -65,6 +68,10 @@ async def request_plan_revision(
engine: AgentEngine instance (used to ensure session + dispatch run).
plan_id: The pending plan to revise.
feedback: Free-text feedback explaining what should change.
cause: Who asked for the revision: the person for the HTTP route,
the calling session's cause for the tool. It becomes the cause of
the planner session if this call creates it; an existing planner
session keeps its own cause.

Returns:
``{"plan_id", "task_id", "session_id", "status"}`` on success.
Expand Down Expand Up @@ -146,7 +153,7 @@ async def request_plan_revision(
)
await engine.sessions.get_or_create(
session_id, title=session_title, source="cron",
actor=engine.db.system_actor,
actor=engine.db.system_actor, cause=cause,
)
asyncio.create_task(
engine.run(
Expand Down
Loading
Loading