Revision history: change sets + revisions + revert (the undo substrate) #48

Closed
opened 2026-07-21 04:16:41 +00:00 by steve · 0 comments
Owner

Design: DESIGN.md

Context

The agent is going to act freely — "empty the garlic bed and plant cucumbers" runs without a confirmation prompt. That is only a defensible default if the result is easy to roll back, so undo is a prerequisite for the agent write path, not a follow-up to it.

The unit of undo must be the operation, not the row. Emptying a bed and replanting it touches one object and many plantings; undoing half of that is worse than useless. So: a git-like model — a change set groups the row-level revisions it produced, and revert replays their inverses.

Two properties worth stating up front because they shape everything:

  • Revert is itself a change set (reverts_id points at the target). History is append-only and never rewritten, so an undo can be undone. This is git revert, not git reset.
  • Revert is version-guarded. If an entity has been touched since the change set that's being reverted, blindly restoring the old snapshot silently discards the newer edit. Pansy already has optimistic concurrency everywhere; revert should honour it and report per-entity conflicts rather than clobber.

Schema

CREATE TABLE change_sets (
    id           INTEGER PRIMARY KEY,
    garden_id    INTEGER NOT NULL REFERENCES gardens (id) ON DELETE CASCADE,
    actor_id     INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
    source       TEXT    NOT NULL CHECK (source IN ('ui', 'agent', 'api')),
    summary      TEXT    NOT NULL DEFAULT '',
    agent_run_id TEXT,                                    -- executus run id when source='agent'
    reverts_id   INTEGER REFERENCES change_sets (id),      -- set when this change set reverts another
    created_at   TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX idx_change_sets_garden ON change_sets (garden_id, created_at DESC);

CREATE TABLE revisions (
    id            INTEGER PRIMARY KEY,
    change_set_id INTEGER NOT NULL REFERENCES change_sets (id) ON DELETE CASCADE,
    seq           INTEGER NOT NULL,                        -- order within the change set
    entity_type   TEXT    NOT NULL CHECK (entity_type IN ('garden', 'object', 'planting')),
    entity_id     INTEGER NOT NULL,
    op            TEXT    NOT NULL CHECK (op IN ('create', 'update', 'delete')),
    before        TEXT,                                    -- JSON row snapshot; NULL for create
    after         TEXT                                     -- JSON row snapshot; NULL for delete
);
CREATE INDEX idx_revisions_change_set ON revisions (change_set_id, seq);

JSON snapshots rather than shadow tables per entity: the rows are small, household scale makes storage a non-issue, and one code path covers all three entity types. The snapshot includes version, which is what the revert guard compares against.

Scope

  • Migration 0006_revisions.sql with the two tables above.
  • internal/store/revisions.go: append a change set, append revisions, list change sets for a garden (paginated, newest first), load a change set with its revisions.
  • internal/service/revisions.go:
    • WithChangeSet(ctx, actor, gardenID, source, summary, fn func(context.Context) error) (*ChangeSet, error) — opens a scope, carries the change set on the context, commits on success.
    • recordRevision(ctx, entityType, entityID, op, before, after any) — no-op-safe; called by the mutating service methods after a successful store write.
    • Auto-scope: when a mutation runs with no change set on the context, open a single-op change set for it (summary derived from the op). This is what keeps the change invasive-but-shallow — REST handlers need no edits at all, and every UI mutation lands in history for free. Only the agent explicitly wraps a whole turn.
    • RevertChangeSet(ctx, actor, changeSetID) (*ChangeSet, []Conflict, error) — applies inverse ops in reverse seq inside one new change set. Per-entity version guard; returns conflicts instead of clobbering. Requires editor role on the garden.
  • Thread recordRevision through the existing mutations: object create/update/delete, planting create/update/delete, CreatePlantings (batch), ClearObject, FillRegion, garden metadata update. The before-state is already in hand — the version-guarded update path loads the current row before writing.
  • API: GET /gardens/:id/history (paginated change sets with op counts) and POST /change-sets/:id/revert201 + the new change set, or 409 + the conflict list.

Design notes / sharp edges

  • Garden deletion is deliberately out of scope for undo. DELETE FROM gardens cascades objects and plantings in SQLite without the service ever seeing them, so making it revertible means snapshotting the whole subtree first and keeping the history alive after its garden_id is gone (the ON DELETE CASCADE above would take the history with it). Both are solvable, neither is worth it now: garden delete already has a confirm dialog, and #46's copy gives a cheap "keep the old one" path. Note the limitation in the history UI rather than pretending it's covered.
  • Restoring a deleted row reuses its id. SQLite permits an explicit INTEGER PRIMARY KEY insert once the row is gone. Within a change set, reverse-seq ordering happens to restore parents before children — but assert it rather than rely on it, since a planting whose object was also deleted will fail its FK otherwise.
  • Don't revision the plant catalog in this issue. Undo here is about garden content; catalog edits are a different (and much rarer) concern.
  • Retention is unbounded for now. Worth a prune policy only if it ever actually grows.

Out of scope

  • The history/undo UI (separate issue).
  • Reverting a garden deletion, per above.
  • Branching or merging. This is linear history with revert, not a DAG.

Key files

internal/store/migrations/ · internal/service/objects.go, plantings.go, ops.go, gardens.go (mutation call sites) · internal/api/api.go (routes) · executus audit/ is the precedent for the run-level sink, and agent_run_id is the join back to it.

Dependencies

None, but it blocks the agent runtime — the agent's "act freely" default is conditional on this existing.

Acceptance criteria

  • Every mutation through the service layer produces a change set, whether or not the caller opened one.
  • A FillRegion of N plops produces one change set containing N revisions; reverting it removes all N and leaves the bed exactly as it was, byte-for-byte on the affected columns.
  • Reverting a revert restores the change.
  • If an object is edited after a change set touched it, reverting that change set reports a conflict for that object and leaves it alone — the rest of the change set still reverts.
  • A viewer gets ErrForbidden from revert; a stranger gets ErrNotFound.
  • go test ./... green, including a test that asserts the auto-scope path (a plain REST-shaped mutation with no explicit change set still lands in history).
Design: [DESIGN.md](https://gitea.stevedudenhoeffer.com/steve/pansy/src/branch/main/DESIGN.md) ## Context The agent is going to act freely — "empty the garlic bed and plant cucumbers" runs without a confirmation prompt. That is only a defensible default if the result is **easy to roll back**, so undo is a prerequisite for the agent write path, not a follow-up to it. The unit of undo must be the **operation, not the row**. Emptying a bed and replanting it touches one object and many plantings; undoing half of that is worse than useless. So: a git-like model — a *change set* groups the row-level *revisions* it produced, and revert replays their inverses. Two properties worth stating up front because they shape everything: - **Revert is itself a change set** (`reverts_id` points at the target). History is append-only and never rewritten, so an undo can be undone. This is `git revert`, not `git reset`. - **Revert is version-guarded.** If an entity has been touched since the change set that's being reverted, blindly restoring the old snapshot silently discards the newer edit. Pansy already has optimistic concurrency everywhere; revert should honour it and report per-entity conflicts rather than clobber. ## Schema ```sql CREATE TABLE change_sets ( id INTEGER PRIMARY KEY, garden_id INTEGER NOT NULL REFERENCES gardens (id) ON DELETE CASCADE, actor_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, source TEXT NOT NULL CHECK (source IN ('ui', 'agent', 'api')), summary TEXT NOT NULL DEFAULT '', agent_run_id TEXT, -- executus run id when source='agent' reverts_id INTEGER REFERENCES change_sets (id), -- set when this change set reverts another created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); CREATE INDEX idx_change_sets_garden ON change_sets (garden_id, created_at DESC); CREATE TABLE revisions ( id INTEGER PRIMARY KEY, change_set_id INTEGER NOT NULL REFERENCES change_sets (id) ON DELETE CASCADE, seq INTEGER NOT NULL, -- order within the change set entity_type TEXT NOT NULL CHECK (entity_type IN ('garden', 'object', 'planting')), entity_id INTEGER NOT NULL, op TEXT NOT NULL CHECK (op IN ('create', 'update', 'delete')), before TEXT, -- JSON row snapshot; NULL for create after TEXT -- JSON row snapshot; NULL for delete ); CREATE INDEX idx_revisions_change_set ON revisions (change_set_id, seq); ``` JSON snapshots rather than shadow tables per entity: the rows are small, household scale makes storage a non-issue, and one code path covers all three entity types. The snapshot includes `version`, which is what the revert guard compares against. ## Scope - [ ] Migration `0006_revisions.sql` with the two tables above. - [ ] `internal/store/revisions.go`: append a change set, append revisions, list change sets for a garden (paginated, newest first), load a change set with its revisions. - [ ] `internal/service/revisions.go`: - `WithChangeSet(ctx, actor, gardenID, source, summary, fn func(context.Context) error) (*ChangeSet, error)` — opens a scope, carries the change set on the context, commits on success. - `recordRevision(ctx, entityType, entityID, op, before, after any)` — no-op-safe; called by the mutating service methods after a successful store write. - **Auto-scope**: when a mutation runs with no change set on the context, open a single-op change set for it (summary derived from the op). This is what keeps the change invasive-but-shallow — REST handlers need no edits at all, and every UI mutation lands in history for free. Only the agent explicitly wraps a whole turn. - `RevertChangeSet(ctx, actor, changeSetID) (*ChangeSet, []Conflict, error)` — applies inverse ops in reverse `seq` inside one new change set. Per-entity version guard; returns conflicts instead of clobbering. Requires editor role on the garden. - [ ] Thread `recordRevision` through the existing mutations: object create/update/delete, planting create/update/delete, `CreatePlantings` (batch), `ClearObject`, `FillRegion`, garden metadata update. The before-state is already in hand — the version-guarded update path loads the current row before writing. - [ ] API: `GET /gardens/:id/history` (paginated change sets with op counts) and `POST /change-sets/:id/revert` → `201` + the new change set, or `409` + the conflict list. ## Design notes / sharp edges - **Garden deletion is deliberately out of scope for undo.** `DELETE FROM gardens` cascades objects and plantings in SQLite without the service ever seeing them, so making it revertible means snapshotting the whole subtree first *and* keeping the history alive after its `garden_id` is gone (the `ON DELETE CASCADE` above would take the history with it). Both are solvable, neither is worth it now: garden delete already has a confirm dialog, and #46's copy gives a cheap "keep the old one" path. Note the limitation in the history UI rather than pretending it's covered. - **Restoring a deleted row reuses its id.** SQLite permits an explicit `INTEGER PRIMARY KEY` insert once the row is gone. Within a change set, reverse-`seq` ordering happens to restore parents before children — but assert it rather than rely on it, since a planting whose object was also deleted will fail its FK otherwise. - **Don't revision the plant catalog** in this issue. Undo here is about garden content; catalog edits are a different (and much rarer) concern. - Retention is unbounded for now. Worth a prune policy only if it ever actually grows. ## Out of scope - The history/undo UI (separate issue). - Reverting a garden deletion, per above. - Branching or merging. This is linear history with revert, not a DAG. ## Key files `internal/store/migrations/` · `internal/service/objects.go`, `plantings.go`, `ops.go`, `gardens.go` (mutation call sites) · `internal/api/api.go` (routes) · executus `audit/` is the precedent for the run-level sink, and `agent_run_id` is the join back to it. ## Dependencies None, but it **blocks the agent runtime** — the agent's "act freely" default is conditional on this existing. ## Acceptance criteria - Every mutation through the service layer produces a change set, whether or not the caller opened one. - A `FillRegion` of N plops produces **one** change set containing N revisions; reverting it removes all N and leaves the bed exactly as it was, byte-for-byte on the affected columns. - Reverting a revert restores the change. - If an object is edited after a change set touched it, reverting that change set reports a conflict for that object and leaves it alone — the rest of the change set still reverts. - A viewer gets `ErrForbidden` from revert; a stranger gets `ErrNotFound`. - `go test ./...` green, including a test that asserts the auto-scope path (a plain REST-shaped mutation with no explicit change set still lands in history).
steve added the backend label 2026-07-21 04:16:41 +00:00
steve closed this issue 2026-07-21 05:07:31 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: steve/pansy#48