feat(curator): authority routing — mutating tools queue for review (C3/5)

The interceptor that closes the loop on the curator review queue.
With this commit, the curator can call update_note / update_milestone
/ update_project / update_profile / delete_note — those calls are
caught by execute_tool's authority='curator' path, snapshotted, and
written to pending_curator_actions for the user to approve or reject
later. Additive tools still run immediately.

services/tools/_registry.py:
- New _CURATOR_MUTATING_TOOLS frozenset: {update_note, update_milestone,
  update_project, update_profile, delete_note}. update_event /
  delete_event intentionally excluded — calendar events should always
  be explicit user intent.
- execute_tool gains a keyword-only  parameter, defaulting
  to 'user'. Default behaviour is unchanged; existing callers keep
  working without changes.
- When authority='curator' AND tool is in _CURATOR_MUTATING_TOOLS,
  _queue_for_review captures a snapshot of the target via a per-tool
  helper and writes a pending action. Returns {success:true,
  pending:true, action_id:N, message:...} so the curator sees the
  call as 'completed' for its bookkeeping.
- Per-tool snapshot helpers: _snapshot_note (covers update_note +
  delete_note — uses the same fuzzy match update_note_tool uses, so
  the snapshot reflects what'd actually be mutated), _snapshot_milestone,
  _snapshot_project, _snapshot_profile. Snapshot capture is best-effort
  — failure logs but still queues with empty snapshot so a curator
  proposal never silently drops.

services/curator.py:
- Allowlist now includes the five mutating tools. They're safe to expose
  because execute_tool intercepts them; the curator can propose without
  being able to actually mutate.
- The execute_tool call now passes authority='curator'.
- System prompt explicitly authorizes the proposal pattern:
  'update_note', 'update_milestone', 'update_project', 'update_profile',
  'delete_note' are described as proposing tools that wait for user
  approval. 'Don't try to update or delete anything' line removed.

services/pending_actions.py:
- approve() now passes authority='user' on the replay so the curator
  interceptor doesn't re-route the replay back into pending and create
  an infinite loop.

What's left in the queue:
- C4: API routes (list/approve/reject endpoints).
- C5: Frontend Needs Review panel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 22:37:55 -04:00
parent 6be7328d8c
commit 3a316551be
3 changed files with 236 additions and 13 deletions
@@ -131,17 +131,15 @@ async def approve(action_id: int, user_id: int) -> dict[str, Any]:
from fabledassistant.services.tools import execute_tool
try:
# NOTE (C2 → C3 hand-off): execute_tool gains an `authority` kwarg
# in commit C3 that the curator interceptor checks. Approval replay
# MUST pass authority="user" once that lands, otherwise the
# interceptor re-routes the replay back into pending and loops.
# Until C3 lands, execute_tool is identity (no interceptor exists
# yet), so this call runs cleanly.
# authority="user" bypasses the curator interceptor — required so
# the replay actually executes the mutating tool instead of
# creating another pending row (which would infinite-loop).
result = await execute_tool(
user_id,
row.action_type,
row.payload,
conv_id=row.conv_id,
authority="user",
)
except Exception as e:
logger.exception(