Skip to content

ADR-0011: Snapshot/Version model consolidation

Status: Proposed Date: 2026-05-30 Deciders: Ramón Kamibayashi + Claude Opus 4.7 Supersedes parts of: ADR-0010 WP-Pulse (F4 visual snapshots table, F-versions naming) Technical story: WP-Pulse v1.0 shipped with three distinct database tables and conceptual surfaces around the word "snapshot". The current tab labelled "Snapshots" in the web UI points at the wrong one. Operators are confused. Backend has dead code carrying weight.


Context

Across the v1.0 build cycle, the word "snapshot" got attached to three independent things that grew at different times:

Table Born Original intent Reality today
snapshots Alembic 001 (F0 boot) Placeholder for Playwright visual snapshots ("screenshot the prod page, compare pixel-by-pixel against local") Empty. Never populated. The F4 visual diff that actually shipped writes to pulls.visual_diff JSONB instead. The table is dead weight.
snapshot_ingests Alembic 002 (F1 pull pipeline) Receipt + chunk tracker for the agent's DB dump + wp-content tar upload. Verifies SHA256 before assembly. Working. Pure plumbing for the per-pull upload protocol. Operators never see it.
site_versions Alembic 006 (F-versions, 2026-05-30) Operator-facing immutable backups: load locally, diff, push to prod, delete. Auto-created per pull, plus manual wpdev version save. Working. This is what the operator means by "backup".

The UI's "Snapshots" tab is wired to snapshots (empty forever) instead of site_versions (where the actual backups live). The operator clicks → expects backups → sees empty state → confused.

Root cause: the codebase grew by phase without ever returning to consolidate the conceptual vocabulary. F0 modelled "snapshots" as a Playwright artefact; F1 needed an upload-receipt table and reused the prefix; F4 quietly stored its results elsewhere (JSONB on pulls); F-versions invented yet another word for what is, operationally, the same primitive.


What the operator actually means

Step back to a one-operator-many-WordPress-sites mental model. The vocabulary that maps cleanly to that mental model:

Operator concept What it is Frequency
Site One real WordPress install enrolled in WP-Pulse static
Pull "Fetch prod state to local now" — an event with a timestamp + status manual or scheduled
Push "Ship local file edits to prod" — an event with file diff + rollback link rare, high-stakes
Backup / "version" / "snapshot" An immutable, point-in-time, restorable copy of the site (DB + wp-content) auto on each pull, plus manual saves
Visual diff A 3-up image (prod | diff-mask | local) attached to a specific pull. Used to verify "did anything visually drift?" per-pull, optional

Notice: the operator's vocabulary has ONE concept of "backup", not three. The other two backend rows are implementation details that should not surface in the UI vocabulary.


Proposed model

Single operator-facing primitive: Version

(We pick "Version" because "Snapshot" is overloaded with "screenshot" in tech vernacular. "Backup" is fine in Spanish; the noun in the UI can be "Snapshot" if that's what reads better — what matters is internal consistency.)

Every Version is: - Immutable. Once created, never modified. Delete is the only mutation. - Restorable. Has a DB dump + a wp-content tarball + a manifest (PHP/WP/plugin versions, table prefix, origin host). - Sourceable. Either source=pull (auto-tagged from a successful Pull) or source=save (manual operator trigger). - Lineageable. Optional parent_version_id link — for "I saved a version before this edit, then made changes, then saved again".

Operations on a Version: - Load → tear down current local Docker stack, restore DB + files, bring up. Updates sites.current_version_id. - Diff → file-level git diff --no-index between two versions' wp-content. Truncated for UI. - Push to prod → load locally + then run normal F3 push flow against this version's state. - Delete → physical rm + DB row removal. Rejected if version is currently loaded.

Backend plumbing renamed for clarity

Old (current) New (proposed) Why
site_versions unchanged already the right name
snapshot_ingests pull_uploads it's per-pull chunk receipts, not "snapshots"
snapshots table DROPPED dead since v1.0; visual diff already lives in pulls.visual_diff
pulls.visual_diff JSONB unchanged it's a property of the pull event, not a separate row
Web tab /snapshots renamed to /versions (route alias) matches the primitive name
CLI wpdev version * unchanged already correct
Web VersionsPanel.tsx unchanged already correct

Visual diff stays on Pull

Playwright visual diffs are tied to a specific pull event ("when this pull landed, here's how prod vs local rendered"). They are NOT a separate kind of snapshot. The pulls.visual_diff JSONB is the right home; the dead snapshots table was never used for this.

Future v2 idea: add a "compare two versions visually" feature that re-runs Playwright across two loaded versions. That would still write to pulls.visual_diff (or a new version_diffs table if it grows), not revive the dead snapshots table.


Why this consolidation matters

  1. Less cognitive load. The operator opens the UI and sees ONE list of backups. They don't have to translate between "Snapshots", "Versions", and "the snapshot_ingests on disk".
  2. No more empty-state confusion. The "Snapshots" tab pointing at an empty table that nobody populates is a UX foot-gun. Removing it (or aliasing to versions) closes the loop.
  3. Backend simpler. One dead table (snapshots) dropped. One renamed for clarity (snapshot_ingestspull_uploads). No data lost, no behavior changed.
  4. Documentation honest. The ADR-0010 phase list still mentions "F4 visual snapshots" as if they live in snapshots table — they don't. This ADR corrects the record.
  5. Names match implementation. "Version" is what the CLI calls it, what the model file calls it, what the UI panel calls it. Renaming the URL tab from /snapshots to /versions (or aliasing) closes the last inconsistency.

Migration plan

Phased to minimise blast radius. Each phase ships independently and is reversible.

Phase 1 — UI alias (zero risk)

  • ✅ ALREADY DONE today (2026-05-30, commit cbf0b25): /sites/[slug]/snapshots/page.tsx now mounts VersionsPanel. So clicking "Snapshots" tab shows the real backups.
  • Add a /sites/[slug]/versions alternate route mounting the same panel for URL clarity. Both URLs work; canonical is /versions.

Phase 2 — Drop dead table (low risk)

  • Alembic 011: DROP TABLE snapshots. No code reads or writes it.
  • Update ADR-0010 phase list to note F4 visual diff actually lives in pulls.visual_diff.
  • One-line change in api/models/__init__.py removing the export.

Phase 3 — Rename for clarity (medium risk)

  • Alembic 012: ALTER TABLE snapshot_ingests RENAME TO pull_uploads.
  • Rename file + class: api/models/snapshot_ingests.pyapi/models/pull_uploads.py, SnapshotIngestPullUpload.
  • Rename router: api/routers/snapshots_ingest.pyapi/routers/pull_uploads.py (or fold into pulls.py since they only matter during a pull).
  • Endpoint path stays the same OR becomes /api/v1/sites/{slug}/pulls/{pull_id}/uploads/{kind}/chunk for clarity. Decide based on agent compat — if we change endpoints, agent v0.4.0 needs to know both paths during transition.

Phase 4 — Tab label sweep (cosmetic)

  • Rename web/app/sites/[slug]/snapshots/page.tsxweb/app/sites/[slug]/versions/page.tsx.
  • Add server-side 301 from /snapshots/versions.
  • Update SiteTabNav.tsx label "Snapshots" → "Versions" (or whatever reads best in Spanish, e.g. "Copias").

Trade-offs

Decision Pros Cons
Keep "site_versions" name Already shipped, no migration pain "Version" connotes git-like linear history; backups are more like Git tags
Drop snapshots table Removes dead code Closes the door on revival; if future visual diff wants its own table it has to invent a new name
Rename snapshot_ingestspull_uploads Clarity in code review + new operator onboarding Agent compatibility: existing plugins talk to /snapshots/<id>/chunk URLs. Either keep the URL path with the old name and only rename the DB table, or bump plugin to v0.4 with new URL support.
Keep visual_diff on pulls.visual_diff JSONB Simplest, already works, matches "diff is a property of a pull event" If we ever want version-vs-version visual compare, this column won't carry it; need a new table

Open questions

  1. Spanish UI wording. "Snapshots" vs "Versiones" vs "Copias de seguridad". Operator is Spanish-native; pick one and stick to it. Recommendation: "Versiones" because it matches the model + CLI commands (wpdev version *) + is what the operator already calls them in conversation.
  2. Phase 3 endpoint compatibility. Renaming the upload routes affects agent v0.3.5 which is live on hotelaldamagolf-com. Either:
  3. (a) Keep both URL paths during transition (legacy /snapshots/... + new /pull-uploads/...), drop legacy in agent v0.5.
  4. (b) Don't rename the URL paths — only rename the database table + model class internally. Operator-facing tools don't notice. (Recommended for v1.x.)
  5. Visual diff future. If we add "compare any two versions visually", do we (a) expand pulls.visual_diff to also live on site_versions.visual_diff, or (b) create a new version_diffs table? Probably (a) — JSONB columns are cheap and the entity that owns a visual diff is the entity being captured.
  6. Auto-version retention. Currently wpdev version save keeps forever. Pull-auto versions also keep forever. The scheduler (F6) has retention_days + max_versions for auto-pulls only. Should manual saves also age out? Recommendation: no — manual saves are operator intent, never expire without explicit operator delete.

Decision

Proceed with all 4 phases, starting with Phase 1 (already shipped) + Phase 2 (drop dead table) in the next maintenance window.

Phase 3 (rename) is non-urgent — defer to v1.1 to avoid agent compat headaches.

Phase 4 (tab label sweep) blocks on phase 3 naming finalisation.


Implementation status

  • Phase 1: Snapshots tab shows real versions (commit cbf0b25, 2026-05-30).
  • Phase 2: Drop snapshots table via Alembic 011. Update ADR-0010.
  • Phase 3: Rename snapshot_ingestspull_uploads (deferred to v1.1).
  • Phase 4: Rename URL + tab label (depends on phase 3).