From e7da36e7e7aa9dd1ba2940cbca7a30e9828398a7 Mon Sep 17 00:00:00 2001 From: lorentz Date: Fri, 17 Jul 2026 18:46:02 +0000 Subject: [PATCH] docs: capture in-progress design for task change log & restore feature Paused mid-brainstorm at user request. Records the approved architecture shape (DB-trigger-based change capture, run-scoped restore, hourly rollup for oversight) and the open items to pick up on resume. Co-Authored-By: Claude Sonnet 5 --- ...7-17-task-change-log-and-restore-design.md | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 ondeck/docs/superpowers/specs/2026-07-17-task-change-log-and-restore-design.md diff --git a/ondeck/docs/superpowers/specs/2026-07-17-task-change-log-and-restore-design.md b/ondeck/docs/superpowers/specs/2026-07-17-task-change-log-and-restore-design.md new file mode 100644 index 0000000..945f88c --- /dev/null +++ b/ondeck/docs/superpowers/specs/2026-07-17-task-change-log-and-restore-design.md @@ -0,0 +1,118 @@ +# Task Change Log & Restore — Design (paused, in progress) + +**Status:** Paused mid-brainstorm at user's request. Overall architecture shape is +approved. Rollout specifics (which existing code paths need wrapping, and the +hourly rollup query) are NOT yet designed — that's the resume point. + +## Problem + +Horizon has no way to see the history of a task's state over time, or to undo +the effect of a specific automation run (sync, auto-generate) or a bad manual +edit. This came up directly after a manual backfill script created ~86 tasks +for two clients (Sultan Trans, Dagostino Electronic Services) by writing raw +SQL — a change with no audit trail beyond a generic `audit_logs` row. + +Goal: an oversight system that (a) snapshots open-task state hourly for +trend/impact visibility per advocate/client/etc, and (b) lets an Admin +literally restore tasks to a prior state, scoped to a specific run or trigger +— covering both automation-triggered changes and manual UI edits. + +## Decisions made so far + +| Question | Decision | +|---|---| +| Primary use case | Literal restore capability, not just diffing | +| Restore scope | Scoped to a specific run/trigger, not whole-system point-in-time | +| Coverage | Both automation (sync, auto-generate, admin backfills) AND manual UI edits | +| Retention | 1 year, then prune | +| Restore access | Admin-only, via a page listing runs (pick one, restore just those tasks) | +| Capture mechanism | Postgres DB trigger (not app-level-only helper) — see rationale below | + +**Why a DB trigger over an app-level helper:** an app-level helper (a shared +TS function all mutation code paths call) is simpler and more idiomatic for +this codebase, but silently misses any write that doesn't go through it — like +today's raw-SQL backfill script. A trigger on the `tasks` / `task_assignments` +tables fires on every INSERT/UPDATE/DELETE regardless of what wrote the row, +so nothing is invisible by construction. Run-tagging (which run caused a +given change) is layered on top via a Postgres session variable +(`SET LOCAL app.run_id`) that callers set before writing; if unset, the event +is still captured, just tagged `UNTRACKED` instead of getting a rich label. + +## Architecture (approved shape) + +### Data model + +**`changes_runs`** — groups events from one logical action: +- `id`, `trigger_type` (`MANUAL_EDIT` / `SYNC` / `AUTO_GENERATE` / `CRON` / + `ADMIN_BACKFILL` / `RESTORE` / `UNTRACKED`), `triggered_by` (nullable FK → + users), `started_at`, `completed_at`, `description` + +**`task_change_events`** — one row per actual field change on a `tasks` or +`task_assignments` row (not per-request, per-column): +- `id`, `task_id`, `run_id` (nullable FK → `changes_runs`), `change_type` + (`INSERT`/`UPDATE`/`DELETE`), `old_values` (jsonb, null for INSERT), + `new_values` (jsonb, null for DELETE), `changed_at` + +**`task_rollup_snapshots`** — the hourly oversight/trend view from the +original ask, kept separate from restore data since it's just for +trend-watching, not restore: +- one row per (hour, advocate | client | department | status) combo with an + open-task count. Cheap aggregate, not full-row duplication. + +### Capture mechanism +`AFTER INSERT OR UPDATE OR DELETE` trigger on `tasks` and `task_assignments` +writes to `task_change_events`. Skips no-op updates (`OLD` = `NEW`). `run_id` +comes from the session variable set by the caller; unset → `UNTRACKED`. + +### Restore flow +Admin-only page lists recent runs (trigger type, who/what, timestamp, task +count affected). Picking a run and confirming: +- For each task touched in that run, take the *earliest* event in that run + (state immediately before the run started). +- `INSERT` → delete the task. `DELETE` → recreate from `old_values`. + `UPDATE` → write `old_values` back. +- The restore itself runs through the same capture path, becoming its own new + run (`trigger_type = RESTORE`) — so a restore is itself visible in the run + list and can be undone. + +### Retention +Nightly prune job (same systemd-timer + `x-cron-secret` pattern as the +existing `ondeck-sync.timer` → `/api/cron/sync`) deletes +`task_change_events` and `task_rollup_snapshots` older than 1 year. + +## Existing patterns this builds on +- Scheduling: `ondeck-sync.timer` (systemd) → `curl -X POST + http://localhost:3000/api/cron/sync -H 'x-cron-secret: ...'`. New cron + endpoints (hourly rollup, nightly prune) should follow this exact pattern. +- `audit_logs` (generic action log) and `task_audits` (an unrelated + document-matching audit feature) already exist — this new system is + distinct from both; avoid name collisions (`task_audits` is taken). +- Task mutation surface to eventually wrap/verify against the trigger: + task edit modal, complete/NA/cancel dialogs, bulk assign/transfer routes, + `src/lib/sync/auto-generate.ts`, `src/lib/sync/sync-engine.ts` + (`runPostSyncAutomation`), the three admin `generate-*-tasks` routes, + `/api/tasks/generate-and-assign`. + +## Open / not yet designed (resume point) + +1. **Rollout specifics** — exact list of every existing task-mutation code + path, and whether/how each needs to set `SET LOCAL app.run_id` (vs relying + on the trigger's `UNTRACKED` fallback for lower-value paths). +2. **Hourly rollup query** — the exact aggregation query/schema for + `task_rollup_snapshots` (dimensions: advocate, client, department, status; + need to confirm which combos matter for the oversight dashboard). +3. Prisma modeling of trigger-based tables (Prisma doesn't manage triggers + natively — will need a raw SQL migration for the trigger + trigger + function, with the tables themselves modeled normally in + `prisma/schema.prisma`). +4. UI for the Admin run-list/restore page — not discussed at all yet. +5. Whether restoring a task whose *client or policy itself* was deleted since + the run needs special handling (FK now dangling). +6. Spec self-review and user sign-off on the written spec (paused before this + step — do this when resuming, before moving to implementation planning). + +## Next step on resume +Continue the brainstorming flow from "rollout specifics" (item 1 above) — +enumerate every task-mutation code path and decide the wrapping approach — +then finish the spec self-review and get explicit sign-off before invoking +the writing-plans skill.