feat(curator): pending_curator_actions schema + service (C2/5)
The backend foundation for curator-proposed mutations awaiting user
approval. No tools route to this yet — that's C3's job. This commit
just lands the schema and the service API everything else will use.
Migration 0051 — new table:
- id, user_id (CASCADE), conv_id (SET NULL — survives conv deletion).
- action_type (the tool name to replay), target_type/target_id/
target_label (display hints).
- payload (jsonb — the curator's proposed args, replayed verbatim
on approval).
- current_snapshot (jsonb — the target's state at proposal time, so
the review UI can render an honest diff even if other work modified
the entity between proposal and review).
- status ('pending' / 'approved' / 'rejected') + CHECK constraint.
- created_at / reviewed_at.
- Partial index ix_pending_curator_actions_user_pending narrowed to
status='pending' — the Needs Review panel hits this constantly,
history rows just accumulate.
Model: PendingCuratorAction with to_dict() for API serialization.
Service services/pending_actions.py:
- create_pending(...) — called from the curator interceptor (C3).
Accepts an already-fetched current_snapshot so each mutating tool
can capture target state in its own way (notes vs milestones vs
profile have different shapes).
- list_pending(user_id, limit=50) — what the Needs Review panel reads.
- approve(action_id, user_id) — replays via execute_tool and marks
approved on success. Stays pending on replay error so the user
can retry. NOTE: approve passes the request through execute_tool
unchanged for now; C3 will add authority='user' so the upcoming
curator interceptor doesn't re-intercept the replay and loop.
- reject(action_id, user_id) — marks rejected with no execution.
C3 next: wires the curator interceptor (authority='curator' on
execute_tool routes mutating tools to create_pending instead of
running them), adds the mutating tools back to the curator's
allowlist, and updates approve() to pass authority='user'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
"""pending_curator_actions — curator-proposed mutations awaiting user approval
|
||||
|
||||
Revision ID: 0051
|
||||
Revises: 0050
|
||||
Create Date: 2026-05-23
|
||||
|
||||
Architecture: the curator runs unattended and can be confidently wrong.
|
||||
Additive operations (create_*, record_moment, log_work, save_person)
|
||||
land directly because adds are easily undone. Mutating operations
|
||||
(update_*, delete_*) are proposed to this table instead — the user
|
||||
sees them in a "Needs Review" panel and approves or rejects each one.
|
||||
|
||||
A proposed action stores:
|
||||
- The action type (`update_task`, `delete_note`, etc.) — drives which
|
||||
handler gets re-executed on approval.
|
||||
- The target id + type for display ("update task 42", "delete note 17").
|
||||
- The proposed arguments (`payload`) — what the curator wanted to do.
|
||||
- A snapshot of the target's state at proposal time (`current_snapshot`)
|
||||
— so the review UI can render a real before/after diff even if other
|
||||
work modified the entity since.
|
||||
- The status (`pending` / `approved` / `rejected`) and review timestamp.
|
||||
|
||||
Approval replays the original tool call via execute_tool with
|
||||
authority="user" so the request bypasses the curator interceptor and
|
||||
just runs.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
revision = "0051"
|
||||
down_revision = "0050"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"pending_curator_actions",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"conv_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("conversations.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("action_type", sa.Text, nullable=False),
|
||||
sa.Column("target_type", sa.Text, nullable=True),
|
||||
sa.Column("target_id", sa.Integer, nullable=True),
|
||||
sa.Column("target_label", sa.Text, nullable=True),
|
||||
sa.Column("payload", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column(
|
||||
"current_snapshot",
|
||||
JSONB,
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::jsonb"),
|
||||
),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.Text,
|
||||
nullable=False,
|
||||
server_default=sa.text("'pending'"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('pending', 'approved', 'rejected')",
|
||||
name="pending_curator_actions_status_check",
|
||||
),
|
||||
)
|
||||
# Pending-only index — the Needs Review panel fetches "user's pending"
|
||||
# constantly, history rows just accumulate.
|
||||
op.create_index(
|
||||
"ix_pending_curator_actions_user_pending",
|
||||
"pending_curator_actions",
|
||||
["user_id", sa.text("created_at DESC")],
|
||||
postgresql_where=sa.text("status = 'pending'"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_pending_curator_actions_user_pending",
|
||||
table_name="pending_curator_actions",
|
||||
)
|
||||
op.drop_table("pending_curator_actions")
|
||||
Reference in New Issue
Block a user