ADR-0012 — Automated Wix→WooCommerce replatform lane (URL in, store out)¶
Status: Proposed (draft 2026-07-15 — written the same day the manual dry-run shipped)
Date: 2026-07-15
Deciders: Cathal Dempsey
Related: ADR-0009 (target output architecture — this is a separate destination lane, not a
change to the Astro lane), the wordpress repo (crawler + Elementor JSON deploy, Feb–Mar 2026),
the wix-product loader (product/category translation between Ecwid/Wix/Woo/Shopify).
Context¶
The forcing event¶
bmpartsandtools.ie (Wix chrome + embedded Ecwid store, 19 pages, ~750 products, 258 categories) was replatformed onto WordPress/WooCommerce/Elementor Pro in one working day (2026-07-15), manually orchestrated in a single Claude Code session. End state: branded header/footer/homepage with tabbed FAQ, dropdown nav with dynamic active states, filtered category archives (price + category sidebar), variable products with working options, category images and descriptions recovered from the Ecwid API, product JSON-LD, all nav URLs verified 200.
Figma was skipped entirely. The historical "site → Figma → build" leg was never automated — Figma added a lossy manual round-trip and no information the crawler doesn't already measure (tokens, structure, bounding boxes, screenshots). Figma remains the surface for redesigns; for 1:1 replatforms it is out of the loop.
What the day actually decomposed into¶
Roughly 80% of the work was deterministic script execution using tooling that already existed
(wordpress/crawler + generated deploy.php + wp-cli over SSH). The remaining 20% was judgment:
a visual polish pass, and seven escalation-grade platform bugs none of which were visible in
advance and all of which are now captured as recipes
(memory: reference_wp_elementor_deploy_gotchas.md — eval-file long-line no-op, Elementor Pro 4
document-type rename, widget renames + __dynamic__ title bindings, condition-slug rename,
Woo JSON-LD suppression on theme-builder templates, Wix CDN full-res URL surgery, Ecwid public
token harvesting).
Products were handled by the separately-owned wix-product loader; coordination converged on one
contract: the loader reproduces Ecwid's tree by id/parentId with exact names; the
replatform side enriches idempotently on top (fill-empties-only importers for category
images/descriptions, run-time URL resolution by exact name — never cached slugs).
Decision¶
Build an automated lane: drop in a URL → pick destination → supply credentials → crawl → deploy → feed → iterate → gated handover. Orchestrated by Claude Code headless initially.
Job spec (the input)¶
{
"source_url": "https://www.example.ie",
"destination": { "type": "woocommerce", "ssh": "host-alias", "wp_path": "/var/www/siteN",
"base_url": "https://..." },
"products": { "loader": "wix-product", "wc_keys": "auto-generate" },
"fidelity_target": 0.9,
"max_iterations": 3
}
Stages¶
| # | Stage | Nature | Notes |
|---|---|---|---|
| 1 | Detect + crawl | Script | tag-scanner platform detect; harvest Ecwid public_… token from page source; crawl (3-viewport screenshots, assets, tokens, DOM structure); intercept embedded-app APIs (FAQ server, Ecwid initial-data) for content invisible to DOM crawls |
| 2 | Provision target | Script | verify WP/plugins/theme, file perms, generate WC REST keys, DB backup |
| 3 | Structural deploy | Script | generated deploy.php via wp eval 'include …' (NEVER eval-file); child theme, kit, header/footer/pages, Woo archive + single-product templates with the Pro-4 fixes baked in |
| 4 | Products + enrichment | Loader + script | wix-product push (owner-run or API-triggered); then the enrichment chain: category images → descriptions → nav rebuild → link/thumbnail fixups. All idempotent; triggered by a stability watcher (N identical product-count polls above a floor) |
| 5 | Polish iteration | Models | see Iteration below |
| 6 | Gate | Script + model | deterministic verification matrix (URL matrix 200s, schema present, variations render, filters compose, add-to-cart enabled) + evaluator verdict and punch list |
Loop taxonomy — and why loops don't have to eat tokens¶
The day's work was four different loop types with different machinery. Naming them matters because three of the four consume zero model tokens, and the one that does only converges if its prompt contract produces closeable claims rather than open-ended "improve this":
| Loop | Iterates over | Runs on | Stop condition | Token cost |
|---|---|---|---|---|
| 1. Fix-verify (inner) | one surface's visual/functional deltas | checker + fixer models | deltas empty, or budget | the only model spend; bounded below |
| 2. Site sweep (middle) | whole-site verification matrix | curl + wp-cli | all checks pass | zero |
| 3. External convergence | someone else's process (product feed, DNS) | stability watcher → idempotent enrichment | N identical polls above a floor | zero |
| 4. Playbook accretion | escalated defects across sites | written once at root-cause time | never (it's the asset) | amortizes to negative — each entry deletes future iterations |
A loop burns tokens when its exit test is a vibe. The guards that keep loop 1 cheap:
- The checker outputs structured deltas (id, surface, claim, severity, verify-method) — each one is independently closeable, so "done" is a set-emptiness test, not a model's opinion.
- Dedupe against everything seen (including
wontfix), not just what's open — otherwise accepted differences re-enter every round and the loop oscillates at full price. - Two failed fix rounds ⇒ stop retrying, escalate for root-cause — retries are where token budgets die; every one of today's seven platform bugs was un-retryable by construction.
- Hard budgets (rounds, tokens, wall clock); exhaustion ships a punch list, not more rounds.
Iteration (stage 5) — the fix-verify loop in detail¶
render → screenshot → check → structured deltas → fix → redeploy → re-check, per surface (header, homepage, archive, product page).- The checker and the fixer are different models and the fixer never closes its own delta —
a delta closes only when the next check or a deterministic verifier confirms it. (Forcing
example: the archive template "deployed" three times while silently not rendering; only an
independent
elementor-14818-in-HTML check caught it. Kin to ADR-0011's lesson: don't let the thing being graded hold the pen.) - Deltas that survive two fix rounds escalate to the heavy tier for root-cause diagnosis — all seven of today's platform bugs were this shape; cheap retries would never have opened the plugin source.
- Budgets are hard stops (max rounds, token ceiling, wall clock). Exhaustion ships the punch list as "known gaps" for human review — the loop degrades to a report, never to silent failure.
- State lives on disk per job (
state.json,deltas.json); each iteration is a fresh headless invocation reading state — no long-lived context accumulating stale beliefs.
Model routing¶
| Role | Now | Later |
|---|---|---|
| Orchestrator / builder (fix passes) | Claude Code (Opus-class) | open-model slot (GLM-class — 4.2/5.x, whichever is current at swap time) — lowest-risk swap because the role consumes a playbook + structured deltas, not open-ended judgment; open weights also buy cost-per-iteration at portfolio scale and self-hosting for client data |
| Visual checker (every round) | Sonnet-class vision — outputs structured deltas only, never fixes | same |
| Escalation + final gate | Opus 4.8 | same |
| Stages 1–4, 6 matrix | no model | no model |
The compounding asset: the playbook¶
Every escalated defect ends as a symptom → root cause → recipe entry, written at resolution time by the tier that found the root cause. The playbook is loaded into the builder's context at job start. Expected shape of the cost curve: ~2 escalation-grade problems per new source→destination pair, near-zero on repeats — so iteration budget is front-loaded on the first site of each pair.
Where it runs¶
replatform-dashboard already has the skeleton: job queue + API (crawler/server.js,
lib/job-queue.js) and the EC2. Dashboard form posts the job spec; the queue runs stages; the
polish loop is a spawned headless session with budget caps.
First milestone (deliberately not full autonomy)¶
"One command, one site; a human reviews the evaluator's punch list before DNS." Today's site cost one day including all discovery; the pipelined version of the same site is plausibly 1–2 hours wall-clock, dominated by crawl + product feed.
Concrete first steps:
1. Extract wordpress/scripts/bmparts/* into a parametrized toolkit (site-specifics — IDs,
palette, URLs — hoisted into a config block; they are ~80% generic already).
2. Define the job-spec JSON and per-stage report JSON.
3. Wire stages 1–4 + 6-matrix into the dashboard queue (no models yet — this alone is most of
the wall-clock win).
4. Add the checker/fixer loop for stage 5 last, seeded with the gotchas playbook.
Build progress¶
The lane now has a repo: DCathal/replatform-woo
(private), local ~/replatform-woo. This ADR stays the design source of truth; that
repo is its implementation, and carries the playbook as docs/playbook.md.
| # | First-milestone step | Status |
|---|---|---|
| 1 | Parametrize wordpress/scripts/bmparts/* into a toolkit |
Done 2026-07-16 — see below |
| 2 | Job-spec + per-stage report JSON | Planned (next) |
| 3 | Wire stages 1–4 + the 6-matrix into the dashboard queue | Planned |
| 4 | Checker/fixer loop for stage 5, seeded with the playbook | Planned |
Step 1 (2026-07-16). The chain ported into replatform-woo/scripts/bmparts/ and
lost every per-site literal: Ecwid store id + public token, a nine-role palette,
the brand name, and — the one that mattered — six hardcoded Elementor post ids.
All now read from a gitignored config/<site>.json, except the ids, which resolve
at run time (below). Remaining site-specific: the $cards label→category map in
post-feed-fixups.php, which is content, not identity — whether card mappings
come from the crawl or the job spec is a step-2 question, deliberately not
guessed at here.
Each change was proven, not assumed: a reverse-substitution check re-derives the
pristine originals byte-for-byte from the parametrized files, and a read-only
harness against live WP has every resolver return the exact id it replaced (6/6).
Both nav scripts' nowdoc → heredoc conversion was proven by rendering both
versions on PHP 8.2 and diffing (byte-identical, 1557 bytes) rather than by
reasoning about interpolation rules.
Post ids resolve by name, and the key is the platform's, not ours. _resolve.php
keys templates on _elementor_template_type (header/footer/product/
product-archive), the homepage on page_on_front, images on filename. Config would
have been the wrong fix — the ids do not exist when a config is written, because the
deploy creates them. A first pass planned to join on post_title (Site Header,
{brand} Product Archive); measuring the live box first killed it — Site Header is
authored by no script we own, so that join key was one we'd have to keep in sync,
while Elementor's type taxonomy is one it maintains itself. Every lookup exits
non-zero on a miss or an ambiguity; nothing falls back to an id. See known-patterns
"An id the destination assigned is an INSTALL fact".
Correction (2026-07-16) — there is no wordpress repo, and the .ppk was never committed¶
This ADR's §Context and §Related call the pilot's tooling "the wordpress repo", and
known-issues carried "the wordpress repo has cathal.dev.ppk committed at root and
live WC ck_/cs_ keys in config/api-credentials.md" as a security item to rotate
before handover.
Correction:
~/wordpressis not a git repository. No.gitat its root, no remote, no history — so nothing in it has ever been committed or pushed, and the.ppkand WC keys have never left local disk. The exposure is real but it is local-disk only, not a published secret.Why it was believable, and why it still cost something:
~/wordpress/crawleris a repo with a real remote (DCathal/Replatform), so "the wordpress repo" was true of the part that mattered most to stage 1 — and a folder full of scripts, config and a committed-looking.ppkreads exactly like a repo. The inference (a secret is in git) never followed from the observation (a secret is in the folder). Rotation is still wise; the urgency was not what the wiki implied.The half nobody had noticed is worse than the half that was wrong. Because it was not a repo, the pilot's bmparts scripts had no version control at all — one
rmfrom gone — andcrawler/lib/layout-analyser.js+lib/tag-scanner.js, the two files this ADR names as load-bearing for stage 1, were untracked and existed only on one laptop. Both are now committed and pushed (DCathal/Replatforma28ac81).
Open questions¶
- Credentials handling — job spec references host aliases + secret store, never inline
secrets; exact mechanism TBD (the leaked
.ppkin thewordpressrepo is the cautionary tale; rotate it regardless). - Elementor Pro licensing per target — programmatic deploys ride an activated install; per-client production hosts need their own licenses.
- Open-model swap criteria (GLM or otherwise) — define an eval set (replay today's seven gotchas as fixtures) before routing builder traffic to it; the version pinned is whatever passes the evals at swap time, not whatever is newest.
- Which destinations next — this ADR covers Wix/Ecwid → WooCommerce. The Astro lane (ADR-0009) is untouched; a shared job spec across lanes is desirable but not designed here.