Skip to content

Record who answers notifications and reviews plans - #518

Merged
pufit merged 20 commits into
alex/attr-03-session-changesfrom
alex/attr-04-notifications-plans
Oct 17, 2026
Merged

pufit merged 20 commits into
alex/attr-03-session-changesfrom
alex/attr-04-notifications-plans

Conversation

@alex-clickhouse

@alex-clickhouse alex-clickhouse commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

What

Nerve records who answered, snoozed or dismissed a notification, who created a silence rule, and who wrote and who reviewed a plan. Approval dispatchers get the person who decided. Plan decisions that change a plan's status become atomic.

How

  • Migration v055 adds eight nullable foreign keys to actor_refs. Old rows stay empty, and the attribution column registry lists the new columns:
    • notifications.answered_by_actor_id, notifications.dismissed_by_actor_id;
    • notification_silences.created_by_actor_id, notification_silences.caused_by_actor_id;
    • plans.reviewed_by_actor_id, plans.reviewed_caused_by_actor_id, plans.created_by_actor_id, plans.caused_by_actor_id.
  • Atomic decisions. A plan decision that changes the plan's status applies only while the plan still has the status that the decision read. This covers approve, decline, PATCH, and the planner replacing its plan. Of two such decisions, one applies and the other gets 409. So a plan starts one implementation, and a decline cannot overwrite an approval.
  • Revisions. A revision request leaves the plan pending, as before. A revision and an approval of the same plan can still both proceed. This is existing behaviour and stays a follow-up.
  • Plan authors. plan_propose and plan_update record the system actor and the session's cause as the writer of the new version.
  • Approvals.
    • The person who decides an approval is written in the same update that claims it, so an approval that expires during its decision still names who decided.
    • A web snooze records who snoozed.
  • Web routes. Answer, dismiss, dismiss-all and the silence routes record the signed-in person. Nerve relays the answer into the asking session as a message written by that person. The agent's work in that turn has the session's cause, like all its work in that session.
  • Channel answers. Answers through Telegram, Slack or a hosted channel keep the provider user ID in metadata and record no person.
  • Approval dispatchers receive the person who answered as the keyword argument actor (None for a channel answer). The mechanical-action audit log names the person's actor ID.
  • Plan decisions through REST record the person as the reviewer, also on the task status change that an approval or a decline makes, and on the task note of a revision request. Plan tools and the silence tool record the system actor and the session's cause, so a plan that the agent approves in a session Alice started shows "Nerve for Alice".
  • Implementation session. Its cause is the approver (REST) or the approving session's cause (tool).
  • The WebSocket events notification_answered and answer_injected carry actor_id.
  • UI. The notifications page shows who answered, who snoozed, who dismissed and who created each silence. The plan view shows who wrote and who reviewed the plan, also right after a decision in the same tab.

Why

These decisions start or stop work: an implementation session, a mechanical action, a review-loop leg. Recording the person completes the audit trail and gives that work the right cause, which is also the principal on its model requests (#516).

Behaviour changes

  • Approve and PATCH return 409 when another status-changing decision changed the plan first.
  • REST plan approval and decline record the task status change with the origin web instead of system.
  • A relayed channel answer runs with no actor instead of the system actor.
  • request_plan_revision takes a required actor argument. Every approval dispatcher must accept the actor keyword argument.
  • Plan statuses and the plan pages are as on main, plus the reviewer and author labels.

Testing

  • New tests for every path, including concurrent decisions and an approval that expires during its decision. External-mode tests check the principal on implementation and planner sessions.
  • Full suite on this branch: 5879 passed, 39 skipped. Web: build ok, 732 tests passed.

Stack

  1. Record each session's cause and who changes a task #515 Actor and cause plumbing; task attribution (base main)
  2. Name the session's cause on each model request in external mode #516 Model requests name the session's cause
  3. Record who changes a session, and who deletes it #517 Session change attribution
  4. Record who answers notifications and reviews plans #518 (this PR): Notification answers, plan reviews and approval decisions
  5. Record who starts, kills and decides workflow runs and review loops #519 Workflow runs and review loops
  6. Record who schedules wakeups and starts cron runs, and run them for that person #520 Schedules: run-now, run-later and wakeups

🤖 Generated with Claude Code

alex-clickhouse and others added 20 commits October 17, 2026 09:22
Answers, dismissals, silence rules and plan reviews did not record a
person: the web answer route dropped the signed-in actor, an injected
answer turn ran as the system actor, and the REST plan approval moved
the task as "system".

Migration v055 adds six nullable actor_refs columns, appended to the
registry: notifications.answered_by_actor_id and
dismissed_by_actor_id, notification_silences.created_by_actor_id and
caused_by_actor_id, plans.reviewed_by_actor_id and
reviewed_caused_by_actor_id. Old rows stay NULL.

- The web answer, dismiss and dismiss-all routes record the person.
  Telegram, Slack and hosted answers keep the provider user ID in
  metadata and record no actor.
- An injected answer turn runs as the person who answered, on that
  person's behalf; a channel answer gives actor and cause NULL. The
  expiry note stays the system actor's turn.
- Approval dispatchers take the answering actor as the keyword
  argument "actor", so a dispatcher can record the decision on its
  target. The mechanical-action audit record names the actor ID.
- WS notification_answered and answer_injected carry actor_id.
- Plan approve, decline, revise and PATCH through REST record the
  person as reviewer; the approval's task transition and the decline's
  task_done record the person too. Plan tools record the system actor
  and the turn's cause. request_plan_revision takes the requesting
  actor in addition to the cause.
- Silences created through the web record the person; the tool
  records the system actor and the turn's cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The notifications page names the person who answered ("by Alice via
web") and who dismissed a notification, and the silences panel names
who created each rule ("Nerve for Bob" for a rule that the agent made
in Bob's turn). The plan view names who reviewed the plan. A
chat-channel answer and older rows keep the transport text only.

The answer broadcast updates the answerer in place, and the injected
answer bubble carries the answerer's actor ID, as the stored message
does. Names resolve through the actor store and ActorAttribution.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
List the new columns and the actor and cause of answers, dismissals,
silences, plan reviews and relayed answer turns in the accounts guide.
The plans guide lists the reviewer columns and what each review path
records. The architecture overview says what an approval dispatcher
receives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A plan decision read the status, checked it, and then wrote with no
status condition, so two concurrent decisions could both pass the check:
both approvals started an implementation, and a decline could overwrite
an approval and close the task while the implementation ran.

- compare_and_update_plan writes only while the plan has the status the
  decision read, and reports whether it changed the row. REST approve,
  PATCH and revise return 409 when they lose; the plan tools answer
  that the plan is no longer pending. Only the winner starts an
  implementation, a revision turn or a task change. update_plan no
  longer records a reviewer.
- v055 adds plans.created_by_actor_id and plans.caused_by_actor_id:
  plan_propose and plan_update record the system actor and the turn's
  cause as the writer of the version they create. plan_update still
  records no reviewer on the version it replaces, because the planner
  replacing its own plan is not a review.
- A web snooze of an approval stores the person in
  answered_by_actor_id, so the row names who deferred it after the
  audit log is gone or the card expires. A later decision replaces it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plan view names who wrote the version ("Proposed by Nerve for
Alice"). A snoozed approval names who snoozed it, also when the snooze
arrives through the WebSocket. The live-update tests change store state
inside act().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plans guide lists the writer columns and says that of two
concurrent decisions only one applies. The accounts guide lists the
writer columns and the snoozer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A revision request stored its feedback but left the plan pending, so an
approval, a decline or a second revision made from the same pending read
still matched the compare-and-update and also applied: a revision turn
and an implementation could start for one plan.

A revision request now moves the plan from pending to the new status
"revising" in its compare-and-update. While the plan is revising,
approve, decline (REST and tools) and another revision refuse with 409
or "no longer pending". plan_update accepts a pending or revising plan,
because the planner's new version is the answer to the revision. When
the revision turn ends, or its dispatch fails, a plan that it did not
replace is pending again; at startup Nerve reopens plans that a stop
left revising. A task with a revising plan takes no new proposal, and
plan_list shows revising plans by default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plan view says that a revision runs and offers no approve, decline
or revision while the plan is revising. The plan list has a "Revising"
filter and badge tone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plans guide lists "revising", says which decisions refuse while a
revision runs, and when the plan is pending again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PATCH compared against the status it read, so a request that read
"revising" could reopen or decline the plan while its revision turn ran,
and an approval could then start an implementation beside it. At startup
every revising plan was reopened, also when resume-after-restart
continued its revision session.

- PATCH refuses to change a revising plan (409) and to set "revising"
  (400). Only plan_update or the end of the revision turn leaves the
  status.
- At startup, recover_revising_plans reopens only plans whose revision
  session is not enrolled for resume. finish_revisions_after_resume
  awaits the resume and then reopens the held plans that are still
  revising. The lifespan still calls resume_enrolled_sessions
  synchronously, so a failure there still stops startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plans guide says that PATCH cannot change or set "revising" and how
startup treats a revising plan whose revision session is resumed. The
answer routing entry of the architecture guide uses a colon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A REST approval creates the implementation session for the approver, an
approval by the agent creates it for the cause of the approving session,
and a revision that has no planner session yet creates it for the person
who asked. These tests check the "created" events of those sessions and,
in external mode with the CLI replaced by a recorder, that each session's
model requests name its cause: a relayed answer names the cause of the
session that asked, not the person who answered, and a relayed Telegram
answer in a session with no cause names no principal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The project decided against a separate status for a plan under revision
(rework decision R3). A revision request again leaves the plan pending,
as before this change set; a revision and an approval of the same plan
can both proceed, which is a known limitation outside this change.

Removed: the "revising" status and its compare-and-update, the PATCH
refusals (409, 400), the reopen at the end of a revision turn, the
startup recovery (recover_revising_plans, finish_revisions_after_resume),
the block on new proposals and the default listing in plan_list.
compare_and_update_plan takes one expected status again.

Kept: approve, decline, PATCH, revise and plan_update apply only while
the plan has the status they read, so a lost decision changes nothing
and the reviewer is the person whose decision applied. The created event
of an implementation or planner session keeps its cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plan view no longer shows a revision note or hides the decision
buttons for a revising plan, and the plan list has no "Revising" filter
or badge tone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plans guide no longer lists "revising". Its concurrency paragraph
names approve, decline, PATCH and plan_update as the decisions of which
only one applies, and says that a revision and an approval can both
proceed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only decisions that change the plan's status exclude each other: a
feedback-only PATCH or a revision request keeps the status, so two of
them can both apply. The plans guide and the compare_and_update_plan
docstring say so.

Web tests check that a pending plan with revision feedback keeps its
Approve, Decline and Request Revision buttons, and that the plan list
has no "Revising" filter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under rework R1 (plan Part 0R) a session has one fixed cause, and there
is no cause per turn or per message.

- WS answer_injected carries only actor_id: the relayed message is the
  answerer's, and the agent's work in that turn has the asking session's
  cause. The web type drops caused_by_actor_id.
- Docstrings, comments and docs name the session's cause where they
  named the cause of the calling turn: plan tools, the silence tool, plan
  authorship, and the sessions that an approval or a revision creates.
- Tests use session-cause wording in their names and comments, and
  check that the injected event has no cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
request_plan_revision wrote the task note through a context with no
actor and no cause, so the task row write was unattributed for a REST
revision and for the plan_revise tool. The note context now carries the
actor and cause that the caller supplies: the person for REST, the system
actor and the session's cause for the tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The answerer was written only after the dispatcher finished. When the
expiry sweep took the claimed row during a slow dispatch, the action ran
and its audit named the person, but the expired row had no
answered_by_actor_id.

claim_pending_approval now writes answered_by_actor_id in the same
UPDATE that claims the row, so the person stays on the row whatever
happens after the claim. The service also checks the result of the
snooze write, as it did for the answer write, and logs when the row
stopped being pending. A test expires the row while an approve or a
snooze dispatch waits, and checks the row and the audit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After a decline, an approval or a revision request, the plan store
changed only the fields it sent, so the reviewer stayed hidden until a
reload, and a status that another decision had set stayed stale. The
store now fetches the plan after each successful decision and installs
the stored row. Interaction tests decline and request a revision in the
plan view and check that "Reviewed by" shows at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alex-clickhouse
alex-clickhouse force-pushed the alex/attr-04-notifications-plans branch from 68f7466 to 2570271 Compare October 17, 2026 11:42
@alex-clickhouse
alex-clickhouse marked this pull request as ready for review October 17, 2026 12:06
@pufit
pufit merged commit 8d7c5d9 into main Oct 17, 2026
6 checks passed
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.

2 participants