feat(api): undo an agent write by proposing its inverse (ADR-0031) #355

Merged
james merged 1 commit from 352-agent-write-undo into main 2026-06-29 12:46:19 +00:00
Owner

Closes the last core exit criterion of epic #47 — every agent-driven write is now undoable. Backend + design; the activity-screen undo button is a follow-up.

Design — ADR-0031 (in this PR)

An undo is not a new mutator path. It loads an audit event, computes the inverse tool + input, and runs that tool's execute, which persists a normal agent_proposals row. The client confirms it through the existing proposal read (#342) + commit/reject (#51) flow. So an undo is itself confirmed, audited, and undoable, and gets conflict re-validation for free at commit time — no second mutator, no per-entity reversal code.

Inverse keyed by entity_type + the original action class (the tool name isn't a clean verb_nounadd_note_to_person has entityType person_note; the factory names CRUD tools delete_<entityType>/update_<entityType>, so the inverse resolves by that key):

  • safe_createdelete_<entityType>({ id })
  • safe_update / destructive_updateupdate_<entityType>({ id, ...changed fields restored to before }) (the update tool's zod parse strips server-managed keys like updated_at)
  • destructive_deletenot undoable → 409 not_undoable (the snapshot doesn't capture the cascading children a delete removed, and the original id can't be restored through create — deferred until subtree-capture lands)

What's in it

  • docs/adr/0031-agent-write-undo.md
  • lib/agent/undo.ts (buildUndoProposal) + AuditEventsRepository.findById
  • POST /api/agent/audit/{id}/undo → returns the inverse proposal as ProposalDto; per-user 404; 409 not_undoable
  • zod/openapi + api-client types regenerated

Verification (local)

  • typecheck ✓ · lint
  • test against both engines (ephemeral Postgres): 1230 passed, 0 skipped (+13). Dual-engine undo suite drives the real note tools + commit: undo a create → delete (and committing it removes the note); undo an update → restore the changed fields (and committing puts the value back); delete refused (not_undoable); missing/cross-user → 404; and the undo round-trips — it writes its own audit event, and undoing that re-applies. Plus HTTP route tests (401, 201→ProposalDto, cross-user 404).
  • @carol/api-client test ✓ 42 · openapi:check ✓ · openapi:coverage 118 → 119 · api-client check
  • semgrep (CI pack set) ✓ 0 findings on the new files

Out of scope (follow-ups)

  • The activity-screen undo button + confirmation UI (consumes this endpoint; reuses the proposal confirmation card). Will also update the activity lede that currently says "nothing here can be undone".
  • Undoing deletes (full-subtree audit capture + restore-by-id).

Part of epic #47.

Closes #354

🤖 Generated with Claude Code

Closes the last core exit criterion of epic #47 — every agent-driven write is now undoable. Backend + design; the activity-screen undo button is a follow-up. ## Design — ADR-0031 (in this PR) An undo is **not a new mutator path**. It loads an audit event, computes the inverse tool + input, and runs that tool's `execute`, which persists a normal `agent_proposals` row. The client confirms it through the existing proposal read (#342) + commit/reject (#51) flow. So an undo is itself **confirmed, audited, and undoable**, and gets conflict re-validation for free at commit time — no second mutator, no per-entity reversal code. Inverse keyed by `entity_type` + the original `action` class (the tool name isn't a clean `verb_noun` — `add_note_to_person` has entityType `person_note`; the factory names CRUD tools `delete_<entityType>`/`update_<entityType>`, so the inverse resolves by that key): - `safe_create` → `delete_<entityType>({ id })` - `safe_update` / `destructive_update` → `update_<entityType>({ id, ...changed fields restored to before })` (the update tool's zod parse strips server-managed keys like `updated_at`) - `destructive_delete` → **not undoable** → 409 `not_undoable` (the snapshot doesn't capture the cascading children a delete removed, and the original id can't be restored through create — deferred until subtree-capture lands) ## What's in it - `docs/adr/0031-agent-write-undo.md` - `lib/agent/undo.ts` (`buildUndoProposal`) + `AuditEventsRepository.findById` - `POST /api/agent/audit/{id}/undo` → returns the inverse proposal as `ProposalDto`; per-user 404; 409 `not_undoable` - zod/openapi + api-client types regenerated ## Verification (local) - `typecheck` ✓ · `lint` ✓ - `test` against **both engines** (ephemeral Postgres): **1230 passed, 0 skipped** (+13). Dual-engine undo suite drives the real note tools + commit: undo a create → delete (and committing it removes the note); undo an update → restore the changed fields (and committing puts the value back); delete refused (`not_undoable`); missing/cross-user → 404; and the undo **round-trips** — it writes its own audit event, and undoing that re-applies. Plus HTTP route tests (401, 201→`ProposalDto`, cross-user 404). - `@carol/api-client test` ✓ 42 · `openapi:check` ✓ · `openapi:coverage` 118 → **119** · api-client `check` ✓ - semgrep (CI pack set) ✓ 0 findings on the new files ## Out of scope (follow-ups) - The activity-screen **undo button** + confirmation UI (consumes this endpoint; reuses the proposal confirmation card). Will also update the activity lede that currently says "nothing here can be undone". - Undoing deletes (full-subtree audit capture + restore-by-id). Part of epic #47. Closes #354 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(api): undo an agent write by proposing its inverse (ADR-0031)
All checks were successful
Commits / Conventional Commits (pull_request) Successful in 6s
PR / OpenAPI (pull_request) Successful in 2m50s
PR / Static analysis (pull_request) Successful in 2m54s
PR / pnpm audit (pull_request) Successful in 3m7s
PR / Lint (pull_request) Successful in 4m6s
PR / Client (web export smoke) (pull_request) Successful in 4m55s
PR / OSV-Scanner (pull_request) Successful in 2m21s
PR / Test (sqlite) (pull_request) Successful in 5m2s
PR / Package age policy (soft) (pull_request) Successful in 1m5s
PR / Test (postgres) (pull_request) Successful in 5m16s
PR / Typecheck (pull_request) Successful in 5m24s
PR / Build (pull_request) Successful in 5m36s
PR / E2E (Playwright) (pull_request) Successful in 5m48s
Secrets / gitleaks (pull_request) Successful in 54s
PR / Coverage (soft) (pull_request) Successful in 2m45s
PR / Trivy (image) (pull_request) Successful in 3m5s
9a6a34e414
Closes the last core exit criterion of epic #47: every agent-driven write
is now undoable. An undo is NOT a new mutator path — it loads an audit
event, computes the inverse tool + input, and runs that tool's execute,
which persists a normal agent_proposals row. The client confirms it
through the existing proposal read (#342) + commit/reject (#51) flow, so
an undo is itself confirmed, audited, and undoable, and gets conflict
re-validation for free at commit time.

- docs/adr/0031-agent-write-undo.md: the design + the rationale for the
  inverse-proposal approach and why deletes aren't undoable yet.
- lib/agent/undo.ts (buildUndoProposal): inverse keyed by entity_type +
  the original action class (the tool name isn't a clean verb_noun).
  safe_create → delete_<entityType>; safe/destructive_update →
  update_<entityType> restoring changed fields; destructive_delete →
  409 not_undoable (snapshot lacks cascading children; original id can't
  be restored through create).
- POST /api/agent/audit/{id}/undo → returns the inverse proposal as
  ProposalDto (the shape the confirmation UI consumes); per-user 404.
- AuditEventsRepository.findById; zod/openapi + api-client regenerated.

Dual-engine tests drive the real note tools + commit: undo a create →
delete; undo an update → restore; deletes refused; cross-user 404; and
the undo round-trips (it's itself audited + undoable).

Activity-screen undo button is a follow-up.

Closes #354

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

📊 Test coverage

Patch coverage: no testable lines changed.

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 79.3% ≥ 50%
Branches 71.1% ⚠️ ≥ 75%
Functions 80.5% informational

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 79.3% ✅ | ≥ 50% | | Branches | 71.1% ⚠️ | ≥ 75% | | Functions | 80.5% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james merged commit dcb1e19f0a into main 2026-06-29 12:46:19 +00:00
james deleted branch 352-agent-write-undo 2026-06-29 12:46:19 +00:00
Sign in to join this conversation.
No description provided.