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:
@@ -50,3 +50,4 @@ from fabledassistant.models.moment import ( # noqa: E402, F401
|
||||
moment_notes,
|
||||
)
|
||||
from fabledassistant.models.generation_tool_log import GenerationToolLog # noqa: E402, F401
|
||||
from fabledassistant.models.pending_curator_action import PendingCuratorAction # noqa: E402, F401
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
|
||||
|
||||
class PendingCuratorAction(Base):
|
||||
"""Curator-proposed mutation awaiting user approval.
|
||||
|
||||
The curator can be confidently wrong, and the user is not in the loop
|
||||
when it runs. Additive operations land directly; mutating operations
|
||||
(update_*, delete_*) write a row here instead. The user reviews each
|
||||
one from the journal's Needs Review panel.
|
||||
|
||||
On approval, the original tool call is replayed via execute_tool with
|
||||
authority="user" — bypassing the curator interceptor so the request
|
||||
just runs.
|
||||
"""
|
||||
|
||||
__tablename__ = "pending_curator_actions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
# SET NULL so a curator-proposed action survives if its source
|
||||
# conversation is later deleted — the user might still want to
|
||||
# review and approve it.
|
||||
conv_id: Mapped[int | None] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("conversations.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
# The tool name the curator wanted to call (`update_task`, `delete_note`,
|
||||
# etc.). Used at approval-replay time to dispatch through execute_tool.
|
||||
action_type: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# Display hints — what the UI shows in the card header.
|
||||
target_type: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
target_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
target_label: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# The curator's proposed arguments — replayed verbatim on approval.
|
||||
payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
# State of the target at proposal time. Lets the UI render an honest
|
||||
# diff (current → proposed) even if other work modifies the entity
|
||||
# between proposal and review.
|
||||
current_snapshot: Mapped[dict] = mapped_column(
|
||||
JSONB, nullable=False, default=dict
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
Text, nullable=False, default="pending", server_default="pending"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, default=None
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"user_id": self.user_id,
|
||||
"conv_id": self.conv_id,
|
||||
"action_type": self.action_type,
|
||||
"target_type": self.target_type,
|
||||
"target_id": self.target_id,
|
||||
"target_label": self.target_label,
|
||||
"payload": dict(self.payload or {}),
|
||||
"current_snapshot": dict(self.current_snapshot or {}),
|
||||
"status": self.status,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"reviewed_at": (
|
||||
self.reviewed_at.isoformat() if self.reviewed_at else None
|
||||
),
|
||||
}
|
||||
Reference in New Issue
Block a user