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):
- Orphaned snapshots — a captured intent for an entity the operator later abandoned/replaced still applies the OLD content (the post-6 case).
- Intermediate-snapshot staleness — N captures of the same
_elementor_datahold N frozen blobs; compaction keeps the "last captured", not the current preview state, which can differ if the preview changed by another path. - id-divergence workarounds — slug-resolve + attachment-remap exist purely because the log carries preview ids (ADR-0020 #1).
- "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¶
- 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 capturedafter.state_snapshot. Kills intermediate-snapshot staleness for builder edits; the captured row becomes a touch marker (which page to sync), not a content snapshot. - Phase 2 — posts/pages declarative. Sync current preview post_content / fields for touched posts by slug.
- Phase 3 — options. Already declarative via settings-drift; fold the option-write op-log into it so there's one path.
- 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_prodconfirm+expected_deletedrift 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.