Skip to content

ADR-0021: Declarative preview→prod sync (retire captured-snapshot replay)

Status: Accepted (largely implemented 2026-06-18) Date: 2026-06-18 Deciders: Ramón Kamibayashi + Claude Opus 4.8 Related: ADR-0010 WP-Pulse, ADR-0016 WP operator console, ADR-0019 Elementor cockpit, ADR-0020 apply-pipeline convergence

Technical story: the DB/builder apply channel replays a CAPTURED OP-LOG (agent_wpcli / builder_mutation rows). ADR-0020 made that log behave better (net-effect compaction, slug-resolve, apply-or-drop), but the root model is still imperative: each row is a frozen snapshot of an intent from the moment it ran on the preview. That produces a class of bugs that no amount of op-log patching fully removes — most visibly the orphaned captured snapshot: after monxas was rebuilt MÈCHE→VOLTÁICA, a post meta update 6 _elementor_data <MÈCHE json> row from the old session still sat pending and, because its slug inicio resolves to a real prod page, would have clobbered prod with the old design on apply. The operator's mental model is simply "the test env is the source of truth; one click makes prod match it" — which is declarative, not a replay of historical commands. This ADR sets that direction.


Context

Today's preview→prod channels, by paradigm:

Channel Paradigm Source of truth at apply
FILE (worktree git diff) declarative current worktree vs prod
settings-drift (wp_options md5-diff) declarative current preview options vs prod
DB agent_wpcli / builder_mutation imperative op-log a FROZEN snapshot captured when the op ran

The imperative DB channel's failure modes (all seen this session):

  1. Orphaned snapshots — a captured intent for an entity the operator later abandoned/replaced still applies the OLD content (the post-6 case).
  2. Intermediate-snapshot staleness — N captures of the same _elementor_data hold N frozen blobs; compaction keeps the "last captured", not the current preview state, which can differ if the preview changed by another path.
  3. id-divergence workarounds — slug-resolve + attachment-remap exist purely because the log carries preview ids (ADR-0020 #1).
  4. "apply or discard" complexity — we had to bolt on net-effect compaction, auto-discard, and panel-hiding to keep an append-only log from accreting noise. A declarative diff has none of that: it computes the delta fresh.

The FILE and settings-drift channels don't have these problems because they read current desired state and diff it against prod at apply time.


Decision (direction)

Move the DB/builder channel from "replay captured ops" to "sync current desired state, resolved by stable key". Two modes:

Mode A — Selective declarative (default; customer-safe)

At apply time, for the set of entities the operator/agent TOUCHED in the preview (the touch-set, still derived from pending rows or a per-entity dirty marker), read the entity's CURRENT preview state and upsert it to prod by stable key (slug / upload-path / option-name — the ADR-0020 identity layer). It never deletes prod entities the operator didn't touch. This kills #1 and #2: the apply always reflects the latest preview state, never a frozen snapshot, so an abandoned-then-re-edited entity syncs its current value and an obsolete captured blob is simply never read.

Mode B — Mirror (opt-in PER SITE; for test/throwaway sites like monxas)

prod is made to EXACTLY match the preview — full declarative sync of the in-scope entity classes INCLUDING deletions of prod entities absent from the preview. This is the literal "any change in the test env → one click → prod, nothing more, nothing less" contract the operator wants for monxas. It is dangerous on a customer site (it would delete customer-created content / orders / users) → gated behind an explicit per-site flag (site.mirror_prod = true), never a default, with a loud confirm.

Invariant (unchanged)

Never synchronise preview-specific ids/URLs — resolution stays by stable key via the ADR-0020 identity-map layer. Declarative-sync REINFORCES this: it reads current state and maps keys, it never copies an integer.


What this does and does NOT solve (honest scope)

  • Solves: orphaned/obsolete captured snapshots (#1), intermediate-snapshot staleness (#2), and the bulk of the op-log housekeeping (#4). The post-6 class disappears in Mode A (no old blob is replayed) and is intended in Mode B (prod mirrors preview).
  • Does NOT solve by itself — preview hygiene: a stray page LEFT in the preview is real preview state. Mode A syncs it if it's in the touch-set; Mode B syncs the whole preview (so prod = preview, stray page included). The honest fix for "I don't want this page on prod" is to remove it from the PREVIEW (then Mode B removes it from prod too). Declarative sync faithfully reflects the preview — it can't read the operator's mind about which preview content is "real".

Phased plan

  1. Phase 1 — builder content declarative (smallest valuable slice). At apply, read the target page's CURRENT preview _elementor_data (by the row's resolved slug/id) instead of the captured after.state_snapshot. Kills intermediate-snapshot staleness for builder edits; the captured row becomes a touch marker (which page to sync), not a content snapshot.
  2. Phase 2 — posts/pages declarative. Sync current preview post_content / fields for touched posts by slug.
  3. Phase 3 — options. Already declarative via settings-drift; fold the option-write op-log into it so there's one path.
  4. Phase 4 — Mirror mode. Per-site opt-in full declarative sync with delete-on-prod for entities absent from the preview (in-scope classes only). Ship for test sites (monxas) first.

Each phase is independently shippable and gated; the op-log replay stays as the fallback until a phase proves out (shadow-compare, à la ADR-0020 #1 identity map).


Consequences

Positive - The operator's "test is source of truth, one click syncs it" model becomes literally how the system works. - Eliminates orphaned/stale captured-snapshot bugs and most op-log housekeeping. - Mode B gives test sites a true one-button mirror.

Negative / risks - Customer-data deletion (Mode B) — the headline risk; gated per-site, off by default, loud confirm, never on a real customer site. - Full-state read cost — reading current preview state per touched entity at apply is heavier than reading a frozen blob; bounded by the touch-set in Mode A, full-scan in Mode B (acceptable for small test sites). - Depends on the identity-map layer (ADR-0020 #1) for stable-key resolution at scale. - A migration period where both the op-log and declarative paths exist; shadow-compare before cutover per phase.


Implementation status

  • Accepted / largely implemented (2026-06-18).
  • Phase 1 (builder content reads current preview state) — shipped.
  • Phase 4 / Mode B (mirror) — shipped + RUN on monxas. Dry-run plan (mirror-plan), gated destructive apply (mirror-apply, sites.mirror_prod
    • confirm + expected_delete drift guard), full CREATE/UPDATE/DELETE (creates draft→replay→publish-last, slug-collision-safe, trashed-slug revive; deletes trash-not-force; never-sync-ids; origin translation; media sideload incl. chunked transfer), pre-apply snapshot + mirror-restore (undo updates/deletes/creates). All behind 4 adversarial code reviews; E2E'd on monxas (incl. idempotent re-run = no duplicates).
  • Mode A (sync) — shipped (sync-to-prod): customer-safe create+update, NEVER deletes, no per-site flag. Reuses the Mode B machinery.
  • PRs #9–#15. ~594 tests.
  • Remaining / follow-ups: changed-only filtering for Mode A (re-syncs all matched pages today; robust builder-content diff needs the dormant identity-map, ADR-0020 #1, to normalise remapped media ids); Phases 2–3 (posts/options folded fully into the declarative path) are partially covered by the existing FILE + settings-drift channels.