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 --porcelainmints an id) AND deleted in the same bundle cancels: drop both, plus theupdate/metaops 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 onlyupdate/deleteare terminal (anaddafter 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:
- 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.
- 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 dedicatedwpcli_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. - 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.
- 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.
- Long-term: move the DB channel toward declarative, as already done for settings-drift (
wp_optionsmd5-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: shipped —
feat/wp-pulse-apply-compaction(commit0aaf14a), live on LXC 281. - Part B: proposed — to be scheduled; items are independently deliverable.