Skip to content

ADR-0020: Apply-pipeline convergence — net-effect compaction + identity-mapping direction

Status: Accepted (compaction, shipped) · Proposed (convergence direction) Date: 2026-06-17 Deciders: Ramón Kamibayashi + Claude Opus 4.8 Related: ADR-0010 WP-Pulse, ADR-0015 Reversibility, ADR-0016 WP operator console, ADR-0019 Elementor cockpit

Technical story: WP-Pulse promotes changes from a per-site preview to prod across several channels. One of them — the external-agent wp-cli intent log (agent_wpcli) — is an imperative chronological op-log. When an agent does-and-redoes work (creates 3 test posts then deletes them; writes an option 6 times while iterating), a faithful step-by-step replay reproduces the churn, wastes signed commands, and can leave prod transiently wrong. This ADR (a) ratifies the net-effect compaction model now shipped for that channel, and (b) steps back to a bird's-eye view of the apply pipeline as a whole and sets a convergence direction for the recurring structural pressures (id-divergence, channel fragmentation, a god-module).


Context

The apply pipeline today — ~6 channels

Promotion preview → prod happens through channels that have grown organically:

Channel Source Paradigm id/origin translation
FILE worktree git diff declarative (diff = net state) n/a (path-scoped)
DB agent_wpcli external-agent wp-cli log imperative op-log slug-resolve eval
DB option/user/post/db_query/htaccess structured preview mutations imperative rows per-kind
builder builder_mutation cockpit (ADR-0019) op-list replayed via REST attachment-ID remap + origin translate
builder agent_wpcli _elementor_data agent builder edits hybrid routed to the builder REST path
settings drift wp_options md5-diff (deps lifecycle) declarative env-exclusions

Two channels are already declarative (FILE, settings-drift). The agent_wpcli channel is the imperative one this ADR addresses.

The recurring pain — id-divergence

Preview and prod are independent WordPress installs with independent auto-increment id-spaces. The agent works in preview-id space. Replaying a preview id verbatim mis-wires prod — this broke prod ≥3 times (page_on_front 404, theme_mods nav/CSS, builder snapshot hotlink; see the apply-divergence invariant). The standing invariant: the preview→prod apply must NEVER synchronise preview-specific ids/URLs.

There are currently two independent solutions to id-divergence: slug-resolve (DB channel) and attachment-ID remap (builder channel). Same concept — a preview↔prod identity map — built twice.

Why faithful replay of the op-log is wrong

The agent_wpcli log records what happened, including throw-away work. We do not want fidelity to the sequence; we want the net desired state. (juanmartin-uy: the agent created test blog posts, eyeballed the layout, deleted them, wrote a _pdf_test option 6 times — 93 rows whose net effect is ~85 real ops.)


Decision

Part A — Net-effect compaction (Accepted, shipped 2026-06-17)

A pure module api/services/wpcli_compaction.py collapses the ordered agent_wpcli log to its net desired state. Four rules + one gating invariant:

  • R1 — lifecycle annihilation. A post created in-bundle (post create --porcelain mints an id) AND deleted in the same bundle cancels: drop both, plus the update/meta ops that only touched it.
  • R1b — orphan preview-id delete. A post delete/trash <id> with no in-bundle create AND no slug-resolve hint can't be mapped to prod — blind-deleting prod by preview id is the forbidden id-sync. Net effect on prod is nil → consume net-zero instead of leaving it stuck-pending forever.
  • R2 — last-write-wins. Option add/update/delete: last op per name wins (names case-sensitive). Post meta is multi-value, so only update/delete are terminal (an add after the terminal appends and survives).
  • R3 — dependency gate (runs AFTER R2 so a superseded reference doesn't protect). Never annihilate a post a surviving op still references (page_for_posts <id>, neve_page_settings_<id>).

Side-effecting one-shot ops (media import, term/user create, db query) are never collapsed.

Integration invariant: both summarise_db_category (panel) and replay_pending_db (apply) run the same plan, over rows ordered created_at, id (the id tiebreak is load-bearing: created_at = now() ties intra-transaction, and the two queries MUST produce an identical total order or the panel and the apply diverge). Dropped rows are consumed net-zero — stamped applied, no command emitted.

Slug-before-delete (id-divergence fix for deletes). The CP runs wp-cli server-side, so the slug-resolve hint for post delete/trash is now stamped pre-execution (the post is gone post-exec). A real deletion then propagates to prod by slug; a preview-only test post resolves to an absent slug. Delete/trash resolve with miss_ok: a miss on prod = the desired end state (absence) already achieved = idempotent success, not a WPP_MISS/exit(1) that would re-fail every apply.

This is accepted and live (LXC 281). Tests: test_wpcli_compaction.py (24) + extended slug-resilient replay; full suite green; verified read-only against the live juanmartin-uy bundle.

Part B — Convergence direction (Proposed)

The bird's-eye view surfaced structural pressures that compaction adds to rather than resolves. The direction, in decreasing leverage:

  1. Unify the preview↔prod identity map. Replace the two parallel solutions (slug-resolve + attachment-remap) with a single identity-mapping layer both channels consume. This is the root cause of the whole id-divergence saga.
  2. Decompose pending_db_replayer.py (1269 LOC god-module). Extract the PHP eval codegen (_php_resolve_post, _slug_resolve_eval — 15 codegen sites) into a dedicated wpcli_eval_builder.py, and the builder-content routing into its own module. The string-built-PHP surface is the riskiest concern and deserves isolation + focused tests.
  3. Persist the compaction plan on the push row. Today "what was collapsed" lives only in a log line; there is no forensic trail if compaction drops something it shouldn't.
  4. TTL / "discard stale" for pending rows. Pending rows live forever until applied/discarded; orphan-deletes accumulated precisely because of this. Compaction now drains them, but the unbounded-growth pattern remains.
  5. Long-term: move the DB channel toward declarative, as already done for settings-drift (wp_options md5-diff). A desired-state diff (snapshot preview → diff prod → apply) would eliminate compaction, ordering, last-write-wins and id-divergence at once. Heavier to capture; the destination, not the next step.

Non-goals

  • Rewriting the working channels (FILE, builder, settings-drift). They are not broken.
  • Cross-site features. Each site is an independent client.
  • Forcing Part B before it earns its place — nothing is on fire; this is consolidation, not correction.

Consequences

Positive - Do-and-redo churn now collapses to the correct net state automatically and generically (not a per-case fix). - Deletes finally propagate correctly to prod (real → by slug; test → no-op), closing the last id-divergence gap for the DB channel. - Panel and apply provably agree (same plan, total order); net-zero work is surfaced, never silently dropped. - A written direction for the structural debt so future features converge instead of adding a 4th solution to id-divergence.

Negative / accepted - Compaction is a 4th pass that inspects argv (alongside the guardrail and slug-resolve); the three must stay in agreement (mitigated: pure modules, shared POST_ID_VALUE_OPTIONS, tests). Part B item 1 is the real fix. - The model still depends on capture: out-of-band preview mutations (wp-admin UI, direct SQL) are invisible to the agent_wpcli channel. Other declarative channels partially cover this; full coverage needs Part B item 5. - db_work (toast count) includes net-zero compactions for an all-compacted bundle — cosmetic; the log/summary clarify.

Risks - Compaction wrongly annihilating/superseding a needed op would silently change prod. Mitigated by R3 (dependency gate), the never-sync-ids invariant, conservative scope (only well-defined op algebra), and 24 tests. Part B item 3 (persist the plan) adds forensics.


Implementation status

  • Part A: shippedfeat/wp-pulse-apply-compaction (commit 0aaf14a), live on LXC 281.
  • Part B: proposed — to be scheduled; items are independently deliverable.