Known Issues¶
Current as of 2026-05-07. Fixed issues get deleted, not archived — git log is history.
Component-library Tailwind content scan¶
Per-site static builds don't scan packages/components-v3 for utilities¶
Tailwind v4 (@tailwindcss/vite) auto-detects content rooted at the build's
own dir and ignores node_modules. @fcr/components-v3 is reached via a
node_modules symlink, so any utility class that appears only in the
component library (section tone bands bg-muted/bg-secondary/bg-foreground/
bg-primary, plus bg-card, bg-destructive, …) is never generated and
silently doesn't paint. Verified 2026-07-05 via production build CSS grep.
Fixed for the CMS (apps/cms): emitGlobalCss(theme, { sources }) now
emits an @source directive and emit-theme-css.mjs passes the components-v3
path (relative to apps/cms/src/styles/global.css). The per-site static
build lane (lib/assembler-fulldev/emit.js:323 → emitGlobalCss(theme))
still calls it without sources, so it carries the same latent gap. Fix: pass
a sources path computed relative to that lane's global.css output location.
The fidelity harness (lib/fidelity-harness.js) also omits it — lower priority
(it only checks theme tokens, not component utilities).
ADR-0009 measured near-match¶
Old-generation Wix "mesh" markup carries no <section> elements — the whole chain runs and honestly emits an EMPTY site (first blind corpus finding, 2026-07-18)¶
Measured on sweeneyofwexford.ie, the first never-before-crawled site through the
CMS-lane batch runner: the crawl succeeded (5 pages, 37–102 KB body.html of real
content), but every page has 0 <section> elements — content lives in
data-mesh-id containers under SITE_PAGES/PAGES_CONTAINER (5/1/1 markers on
home; wixui-section 0). buildSections and the capture plane both key on
<section>, so: live capture 0 sections, seed 0 blocks on all 5 pages, our
render is empty chrome, S=0 / composite 33.3 / HOLD (chromeDisagree). The
instrument behaved correctly at every step — the verdict is honest; the pipeline
has a segmentation capability gap for this template generation. (Contrast the
same batch's other blind site, salttherapysolutions.ie — new-generation markup,
22 pages, S 87.8 / composite 89.7, above τ_ship on axes, veto-held on assets.)
Detectable signature at seed time: body.html with 0 <section> = this class.
A loud warn in seed-from-build naming it (instead of silently emitting 0 blocks)
is a chore, not a hypothesis.
Deliberately NOT scoped here: a mesh-generation segmenter. The scoping number
is prevalence — what fraction of the portfolio is old-mesh vs section markup.
n=1 of 2 blind sites is not a distribution. Reopen: when the 30–50-site corpus
batch lands, count the 0-section sites in builds/cms-batch-report.json +
seed block counts; that number decides whether mesh support is a must-have slice
or a manual-tier routing rule.
#2 found 2026-07-19: bluestarsearlyyears.ie — mesh count now 2 of 9 blind
sites scored. Measured: live capture index has 0 sections (schemaVersion
8 artifact), ours 0, seed 0 blocks. Same class, different verdict shape, and
the difference is an instrument bug: sweeney (5 pages) scored S=0 → composite
33.3 HOLD (honest); bluestars (1 page) got S=null / G_det=null → composite
100 from T alone → MANUAL-POLISH — verdictReasons shows the floors blocked
the ship ("composite 100 >= tau_ship but floor failed: S=missing"), but a
flattering composite-100 row now exists for a site that renders empty chrome. A
null axis must never inflate the composite. FIXED 2026-07-21 — composite()
(lib/scorecard-gate.js) now returns null when fewer than two axes are
present (a single surviving axis is that axis, not a composite); the gate then
holds such a site honestly instead of banking a flattering manual-polish. And
batch-scorecard was verified to already exclude these rows on missingAxes
before it reads composite — so the fit was never fed them (both mesh sites sit
in calibration/feature-matrix.json#skipped). Locked by the +4 gate-validate
checks. See known-patterns "A composite from a SINGLE surviving axis is that
axis, not a composite". (Note the wrong first read, banked so
it isn't re-derived: composite-100-with-null-axes was initially attributed to
the recorded home/index alias gap in scorecard.js — measurement killed that
in one command: BOTH sides key the page index; the join had nothing to
misalign. The cause is 0 sections, not slug aliasing.)
Mobile is UNMEASURED on both sides — and Wix mobile is a separately AUTHORED view, so the fidelity target itself is undefined (2026-07-18 pt2)¶
The whole instrument (capture, S/T/G_det, gate) runs at 1440×900 only; corpus scores are desktop-fidelity scores. Wix's mobile view is not responsive reflow — it is a separately editable surface (hide/reorder/resize per element, mobile-only widgets), so live-mobile can editorially diverge from live-desktop. Our canonical blocks reflow responsively, so every site has a mobile render today; it has simply never been measured or systematically eyeballed (last deliberate mobile work: May-era static-lane hamburger/topbar fixes). Two distinct open questions, neither scoped: (a) is our responsive render professional on a phone (self-quality — checkable in minutes on the two deployed reference workers); (b) should mobile fidelity target the client's authored mobile view at all — if the FCR team never used the Wix mobile editor, live-mobile is Wix's auto reflow and near-matching it would measure one machine's reflow against another's (Cathal to answer from build practice, same as the Wix-Data nil). Trigger (deferral-trigger rule): after the corpus lands, run a ~10-site probe capturing live at a phone viewport and diffing live-mobile vs live-desktop (sections hidden / reordered / resized); that prevalence number + the build-practice answer decide measured-dimension vs "responsive, professional, unmeasured" tier. Do NOT add a mobile dimension before then ("no new bounded dimension without corpus evidence").
Section-band rhythm capture deferred — needs a classified content band, not nth-of-type(2)¶
Slice 2b shipped the design-token capture (button/card borderRadius +
boxShadow → real design.json for garvanbay + WCP). Section rhythm
(--section-py) is deferred. The rhythm measure reads
section:nth-of-type(2) — a typography-sampling selector, not a content band.
On WCP that section is the 67px trust ribbon, so its inset is meaningless as
page rhythm; it is now gated behind a content-band height floor
(bandPaddingTop returns null below 200px) so a ribbon can't emit a bogus
override, and sectionBand.sectionHeight is recorded for the tuning pass. See
known-patterns "section:nth-of-type(2) is often a trust ribbon". Reopen
with the section-classifier slice (2b-band) — robust representative-band
selection is the same classification work Slice 3 needs; build them together.
~~Brand-detection is nondeterministic across re-crawls (surface can outrank a higher-scored logo cluster)~~ — CLOSED 2026-07-13 pt4 (and the fix this entry proposed would have BROKEN garvanbay)¶
Surfaced 2026-07-06 pt4 re-crawling WCP for Slice 2b: the derived brand flipped
source: logo (confidence high, primary #183a6f) → source: surface
(confidence medium, primary #092b57) between two crawls of the same
live site, with no code change to brand derivation. Both crawls extracted the
same logo cluster (#183a6f, score 16.97); the second also surfaced a
ctaAccent/surface candidate (#092b57, score 9.22) which won primary
despite the lower score.
The entry then concluded: "so the ranker isn't purely score-ordered", with the fix candidate "make the ranker prefer a high-confidence logo cluster over a lower-scored surface candidate".
Correction (2026-07-13 pt4): "the ranker isn't purely score-ordered" is a correct observation with a false inference, and the proposed fix would have regressed garvanbay. The ordering is not the mechanism.
deriveBrandPalettedeliberately gives painted-SURFACE families priority over logo-only families (score orders only within that pool) because a logo's dominant colour is often just an accent — garvanbay's dominant logo cluster is yellow-green (#cdd707, 54.5% of the mark) and its family outscores the cyan 17.49–17.07, yet the brand primary is the cyan#2aa1dbpainted on the button. "Prefer the dominant logo" and "prefer the top score" both hand garvanbay its accent colour. The adversarial case was not hypothetical; it was the other reference site.The real defect: the surface preference was an absolute override, so it also fired where it was never meant to — when the two competing families are the same colour. WCP's two navies are
hueDist2.71° /rgbDist32 apart: one brand navy the clusterer split in two. A 9-point family deposed a 17-point one purely because it was painted.
Fixed in lib/brand-palette.js: the override is now a two-condition tie-break — a
surface family may depose the score leader only if it is a genuinely different colour
(hueDist ≥ 25°) and actually painted (≥ ROLE_WEIGHT['button-bg'] of fill evidence);
otherwise the top scorer wins. It can only depose, never block, so it fails safe, and it
is a no-op whenever the leader is itself a surface family (the common case). WCP now derives
#183a6f / logo / high — restoring the palette it ships live — and garvanbay is
byte-identical (theme.json unchanged; emitted global.css same md5). Old and new WCP
crawls now agree, so the flip is gone as a guarantee, not a coincidence.
Locked by lib/brand-palette.validate.mjs (11 checks, incl. the determinism assertion:
deleting the transient ctaAccent from WCP's sample must yield a byte-identical palette, and
adversarial garvanbay synthetics — mono logo, thin crawl — must still derive #2aa1db).
Two traps recorded in known-patterns ("A wrong winner in a RANKER may be a POOL rule"):
rgbDist/lightness discriminators run backwards on the real data, and a score margin is
structurally biased because logoAgreement awards +4 to the logo-agreeing family precisely
when the logo differs from the painted brand.
Residual, deliberately deferred: clusterFamilies takes the first matching family (not
the nearest) and appends logo candidates last, so the strongest signal can never seed a
cluster — that is what split WCP's navy. Fixing it forces a repScore change whose blast
radius is every site's emitted hex. Reopen with the Slice-6 corpus, not with two sites.
The re-crawl discipline stands: a re-crawl must still diff the derived theme (not just
_raw) before commit.
Card-sample reliability — $card selector can match a brand-colour container¶
theme-extractor.js's $card ([data-testid="container-bg"]) resolved to a
brand-blue container on garvanbay (bg rgb(42,161,219)), not an obvious
panel/card — so --radius derived from it (0rem on both sample sites) is the
least trustworthy of the design tokens. --btn-radius (from the primary
CTA) is the reliable brand-shape signal, as ADR-0009 notes. Low-stakes today
(both sites measured 0rem, plausible for their flat aesthetic), but tune the
$card selector toward a real card/panel before trusting component radius
portfolio-wide. Fold into the 2b-band / classifier work.
Per-site static lane consumes no section-background capture (tone OR section-style) — deferred (Option A, 2026-07-07)¶
ADR-0009 Slice 3's imageSide, alignment and columns dimensions shipped on the
CMS lane only. The per-site static assembler (lib/assembler-fulldev/emit.js)
consumes no section-backgrounds.json — not for tone (never has) and not
for style.imageSide / style.align / style.columns. Its image side is the variant-picker
zig-zag alternation heuristic (variant-picker.js), which is phase-blind (can
be offset-by-one from live — see known-patterns "Measured image-side beats the
alternation heuristic"); alignment there is whatever each block hard-codes. Wiring
capture into that lane is a distinct feature that would bring all captured
styling (tone + every style dim) to the static lane at once, and it is not
verifiable on the two sample sites — garvanbay + WCP are deployed as CMS
workers (garvanbay's static build artifacts live only on EC2). The classifier
(lib/section-style.js) + makeStyleLookup are lane-agnostic, so the static lane
is a drop-in. Reopen trigger: the first per-site static build intended for
launch (same trigger as the noindex,nofollow blocker below) — fold tone + style
capture-consumption into that lane then. Until then the alternation heuristic +
block-default alignment stay as the graceful default.
columns slice — two loose ends from the CMS-only ship (2026-07-07)¶
Both low-stakes, from the columns dimension (commit cf6b55f):
- team-grid + checklist
<Section>migration verified on the CMS lane only. The migration touches shared canonical blocks (packages/components-v3/.../blocks/ {team-grid,checklist}.astro) used by both lanes, but only the CMS lane was built + screenshot-verified live (garvanbay/aboutteam-grid,/payrollchecklist — both zero-regression). The per-site static lane wasn't separately built — no local assembled-site exists (garvanbay's is on EC2; wfpainters hasbody.htmlbut no scaffolded build). The blocks are lane-context-independent (no@container/section-provider dependency, unlike content-2), so compile+render parity is reasoned, not re-built. Reopen: next per-site static build of any site with a team-grid/checklist — eyeball those two blocks once. - columns capture over-fires
cols=on non-grid sections.resolveSectionStylereports a columns count on heroes/CTAs/content-2 splits (any even-width 2-item first row). Harmless today — columns only stamps on identity-matchedCOLUMNS_ELIGIBLEblocks (service-grid/team-grid/gallery), so the noise never reaches a non-grid block. A per-item width floor (≥12% section width) was drafted to filter it but not applied (user call — the gating makes it moot). Reopen: if a future dimension/consumer readsstyle.columnson a non-eligible section, tighten the capture first.
~~Surgical "sync captured-style fields to remote D1" is a scratchpad script~~ — CLOSED 2026-07-09¶
Promoted to the committed apps/cms/scripts/sync-style-to-d1.mjs during the
hero-dims deploy (its reopen trigger — "the next style dimension's deploy" —
fired; the pt-era scratchpad patch-align-d1.mjs had already rotted away,
proving the point). Generic across dimensions: reads a stamps JSON
({slug, match:{_type, heading?}, set:{field: value}}), merges onto live rows
by the stable natural key with order-consumption, writes rows back whole
(divergent native blocks preserved), --dry supported, warns and skips (never
fabricates) when no block matches. First production use: the 8-page hero
scrim/textTone stamp.
Near-match scorecard in progress (Steps 0–3 of 6) + deployed-site gaps it surfaced (2026-07-07)¶
The ADR-0009 §6 near-match scorecard is being built in steps; spec + build
tracker in docs/pipeline/near-match-scorecard-contract.md. Done: Step 0
S (structural, lib/structural-diff.js), Step 1 T (theme, lib/theme-fidelity.js),
Step 2 G_det (masked-perceptual, lib/perceptual-diff.js); combined emitter
lib/scorecard.js; Step 3 gate (lib/scorecard-gate.js + versioned
lib/calibration.json, SEED thresholds fitted:false) — scorecards now carry
verdict: ship|manual-polish|hold (garvanbay SHIP, WCP MANUAL-POLISH on the
S-floor). The seed thresholds are hand-set and uncalibrated — verdicts rank
and queue, but don't trust τ_ship for unsupervised shipping until Slice 6 fits
calibration.json on the labelled batch. Slice 4a done 2026-07-08 — the
matcherGap roll-up (drops/inserts/props-withheld, role-weighted + byRole) is
folded into S through scorecard.js: a reported projection of debt already in
S (S byte-identical, additive only), locked by structural-diff.validate.mjs
(53 checks). See ADR-0009 Slice 4a + known-patterns "A diagnostic that projects
debt already inside a score must NOT re-penalise". Slice 4b-i done 2026-07-08 —
the measured GenericSection renderer shipped both lanes (always-matches
matcher score 0.05 < About's 0.15 floor → zero-regression; new canonical block +
CMS schema/adapter/dispatcher + static translator + transformer fromGenericSection
+ two silent-drop loud-warns), closing the CMS-lane silent-drop and surfacing a
pre-existing FloatingCTA/Social CMS drop (since resolved — registered as globals +
deployed to WCP 2026-07-11). Live-proven on the
demo (throwaway scratch page, reverted). Spec scope-2026-07-08-slice-4b.md +
known-patterns "An always-matches fallback matcher is zero-regression …".
Slice 4b-ii done 2026-07-08 — genericFallbackRate (Σgeneric/Σblocks from a
build section-matchers.json sidecar; null when no sidecar) + the
genericFallbackCritical gate veto (GenericSection at role-weight ≥ 1.3 → HOLD)
now ship, S byte-identical (additive projection). Pending: Step 5 calibrate on
a 30–50-site batch (Slice 6). Latest three-axis:
garvanbay S 88.9 / T 100 / G_det 90.8 → composite 92.2 SHIP (all 10 pages,
2026-07-10 post teaser-CTA slice; re-verified same day after the oklch
capture-probe fix — site axes unchanged, home S 83.5→84.6, book-an-appointment
39.7→37.5); WCP S 70.8 / T 100 / G_det 74.3 → composite 79.0 MANUAL-POLISH
(3 slugs index/gallery/commercial-painting, 2026-07-10 pt4 post hero-1 side-image
variant + full image migration — was 65.5/100/74 → 76.3; the S=70 floor is now
cleared, held below τ_ship 85 only by the gallery Wix-Pro-Gallery case S=44.4).
Artifacts
are gitignored (builds/*) — re-generate with
node lib/scorecard.js <domain> --slugs … after capturing shots
(capture-section-backgrounds.js --base <deployed-url> --shots <dir> --out …).
Slice 6 instrument (corpus-INDEPENDENT half) done 2026-07-17 — the fit is now
one command; the CORPUS + LABELS remain the whole gate. Slice 6 is five steps
(pick 30–50 sites → batch-build on EC2 → scorecard each → human ship/needs-polish/
hold labels → logistic fit); steps 2 and 4 need EC2 runtime + human time and were
NOT done. Steps 3 and 5 — pure code — shipped and are validated:
scripts/batch-scorecard.mjs (aggregate per-site scorecards → calibration/
feature-matrix.json, skips any missing/partial scorecard LOUDLY, --selftest 11
checks), lib/calibration-fit.js (pure logistic P(ship) on {S,T,G_det}, τ_ship at
precision≥0.98 via held-out folds, τ_hold at Youden J, floors/vetoes fit
separately + reported, manual-budget rollup) + scripts/fit-calibration.mjs (CLI;
refuses to write lib/calibration.json — promotion is a reviewed step), locked
by lib/calibration-fit.validate.mjs (synthetic separable → achievable ≥0.98 cut;
degenerate no-ship → fitted:false + loud null; positive control: both anchors
read exactly, clear all seed floors, rank high, vetoes surface — the §4 sanity
anchor). Production calibration.json byte-unchanged (fitted:false). Finding
that sharpens the gate: both local anchors are veto-HELD (garvanbay
criticalSectionDropped, WCP videoDebt), so neither is a clean ship label — a
2-site fit is impossible not just for n but because there are zero ship
positives; the real corpus must include clean ship sites. Reopen trigger
(unchanged): the labelled 30–50-site corpus lands — then batch-scorecard →
label the CSV → fit-calibration → review the candidate → (only if fitted:true
and precision holds) promote to calibration.json + wire the gate to pShipCut.
Labelling harness built 2026-07-21 — the human-label step is now tooled.
scripts/make-label-sheet.mjs (18-check selftest) reads feature-matrix.json +
the committed calibration/scorecards/*.json and emits (a)
calibration/labels.csv seeded in the exact host,verdict,reason format
fit-calibration.mjs#parseLabelsCsv consumes — 38 rows, the 15 machine-SHIP
candidates grouped first (the precision-cut positives), blank verdicts to fill;
and (b) a self-contained calibration/review.html — one card per site with the
machine numbers, a live-site link, and a reporting-only punch-list pulled from
each scorecard (vetoes / variant mis-renders / images-repainted / chrome
disagreements / video shortfall / matcher debt / generic-fallback rate), with
ship/needs-polish/hold controls that export the same CSV. So the only remaining
Slice-6 blocker is Cathal's actual verdicts — the mechanics are done.
Pre-existing red test surfaced 2026-07-21 (test-maintenance nit, NOT a
regression). lib/calibration-fit.validate.mjs's positive-control anchor
fails: the on-disk builds/waterfordcountypainters.ie/fidelity-scorecard.json
(dated 2026-07-18) reads S=82.1 while the test hard-codes the post-Slice-1d
anchor S=82.2 — a 0.1 re-crawl drift below approx()'s tolerance. Present on
clean HEAD, and the suite doesn't import anything the 07-21 composite fix touched.
Fix is either update the hard-coded anchor to 82.1 or regenerate the local
scorecard on the box; deferred (needs a box decision on which is canonical).
Corpus VEHICLE re-specced + built 2026-07-18 (ADR-0013, Proposed). The batch
must score the CMS lane's render, not the static lane's (run-fulldev-batch.sh
is no longer the vehicle). scripts/run-cms-batch.mjs is the replacement —
crawl → seed → local CMS build/serve (miniflare, no deploys, no production
writes) → symmetric capture → scorecard; positive control reproduced WCP's
deployed baseline to 0.1 (S 82.1/T 100/G 84.4 → 87.2, videoDebt). First two
blind sites scored the same day (calibration/corpus-hosts.txt):
salttherapysolutions.ie 89.7 (above τ_ship on axes; veto-held —
inline-left + assets) and sweeneyofwexford.ie 33.3 (the mesh-generation
segmentation gap, see the entry above). Remaining before the full corpus:
Cathal's 30–50 site picks, widening the Wix API key's site access, and the
EC2 port (runbook in scope-2026-07-18-cms-batch-runner.md).
Owner's call 2026-07-18 (pt2): the corpus proceeds WITHOUT the Wix-key
widening; EC2 confirmed as the corpus box. Video is sparsely populated
across the portfolio and the API is video-only in the corpus path (images
need no API — 07-18 handover), so the key-widening is dropped from vehicle
prep. Consequence for reading the corpus: videoDebt rows are "video
shortfall, cause UNATTRIBUTED" (the capture-debt vs key-couldn't-see-site
conflation stands) and video fixes are NOT ranked from them until the key
lands. The premise self-tests — scorecards persist per-site mediaRefs
counts, so the corpus itself measures video prevalence; if it comes back
high, widen the key then and re-attribute (no re-crawl needed). An API-free
attribution probe exists if wanted: the ADR-0010 CDN control
(video.wixstatic.com/video/<id>/480p/mp4/file.mp4 → 206 = Media Manager,
403 = VOD/token-gated). The calibration fit is unaffected either way (human
labels + {S,T,G_det}; vetoes fit separately).
EC2 port COMPLETE same day (pt2). The positive control re-ran on
cathals-demo end-to-end: 87.3 HOLD(videoDebt) vs the deployed baseline
87.2 — within re-crawl drift, same sole veto. One box-specific requirement
(NODE_OPTIONS=--max-old-space-size=2560 — the astro build SIGABRTs at
Node's default heap cap on 2 GB RAM; +4 G swap added) and one corpus-hygiene
trap (the crawl stage's skip rule is presence-only, so the 18 legacy
May-era builds/ dirs on the box need --force crawl,theme or a clear) are
recorded in the scope doc's EC2 runbook.
Run-2 partial scores (2026-07-19, 12 scorecards) — SUPERSEDED 2026-07-20 by the full 38-site matrix below; read that instead. What survives from the partial read: the pipeline generalizes, and the first-ever clean blind SHIP was brianlarkinsolicitor (S 81.8 / T 95.5 / G 92.3 → 87.9, no vetoes), with banesolicitors 91.4 and braylaunderette 93.8 above both reference sites.
Do not carry the partial read's fix ranking forward — the corpus inverted
it. On 12 sites the holds looked like inline-left ×5 and header-bg ×7,
and 07-19 called inline-left "the highest-leverage veto-clearing fix". At
38 sites that is wrong twice over: inline-left is not a veto name in the
matrix at all, and the header-chrome dimension it was ranked against
(chromeDisagree) turns out to be 33 of 38 sites and the sole blocker on
16 — an order of magnitude above anything else. A 12-site sample ranked a
portfolio-wide fix list and got the order backwards; the lesson is that
prevalence ranking needs the full board, which is what the corpus was for.
CORPUS IS IN — run 3 (2026-07-20) scored 41/42; the feature matrix carries
38 sites. Runs 1 and 2 are closed and their failure rows are NOT corpus
data: run 1 (07-18) died to the genuine box network incident (scope doc §
2026-07-19 incident); run 2 (07-19) died to a quick-crawl bug, not the
box — see the dated correction in known-patterns "A batch whose failure MODE
migrates upstream…", which now carries the one-grep discriminator that tells
the two apart. Both bugs fixed in db1bff8 (sitemap fetched via
context.request.get instead of page.goto; cms-build timeout 15→30 min).
Run 3 log ~/corpus-run-0720.log, 10:46→16:32 UTC, one failure
(sreenanandcompany.ie, below). Aggregate: calibration/feature-matrix.json,
38 sites, 4 skipped — and the null-axis loud-skip works as designed
(bluestars is excluded from the fit, not silently folded in).
Corpus headline: chromeDisagree is on 33 of 38 sites, and is the SOLE veto
on 16 of them. Only 2 sites ship clean today (southernhydraulics 90.7,
brianlarkinsolicitor 87.9); clearing this one veto would take that to 18 of
38. The sites it gates are the top of the distribution, not the tail —
qconsulting 96.7, johnrochford 95.9, charlietaxiservices 94.1, braylaunderette
93.8, squareonechildcare 93.3, each held by chromeDisagree and nothing else.
Corpus distribution: composite min 76 / p25 85.8 / median 87.9 / p75 91.4 /
max 96.7; mean S 84.9, T 97.9, G_det 83.9. Residual load after it, across 20
sites: criticalSectionDropped 7, contentDestroyed 6, deadAssetRef 4,
videoDebt 3, systematicVariantMismatch 3, fallbackNavy 2,
genericFallbackCritical 1, fabricatedLink 1.
~~Fix candidate — UNTESTED HYPOTHESIS: that
chromeDisagreeat 87% prevalence is an INSTRUMENT artifact rather than 33 real header defects.~~ ADJUDICATED 2026-07-21 — the hypothesis is WRONG, and the framing was wrong too: it is not one defect, it is FOUR. Priors that pointed the other way (uniform prevalence is an odd defect; thetop-bar-missingcrop precedent) were reasonable and did not survive measurement. Kept visible because the instrument-artifact reflex is a good one and will recur — it is just not what this was.
chromeDisagree adjudicated — four real producer gaps, not one artifact (2026-07-21)¶
The veto name collapses four independent dimensions. Measured over the
38-site matrix by reading each site's globals.summary.disagreements:
| dim | sites | direction |
|---|---|---|
footer.bg |
27 | live coloured / dark (22) or media-backed (5) → ours #ffffff, uniformly |
header.layout |
17 | all inline-left → stacked-center |
header.cta |
10 | live has a CTA → ours null (never the reverse — we drop, never fabricate) |
header.bg |
7 | — |
floating.social |
1 | — |
The instrument is SOUND — both sides were checked, not assumed. The
ours-side probe located the right element (section-backgrounds.ours.json →
footer: {hex:"#ffffff", source:"footer.footer-wcp"}) and read a genuine
white; the same symmetric probe reads 27 real live footer fills on the other
side (#041c42, #743eaf, #008db2, #1c6d2b, and 5 media-backed:
annetaitsolicitors · brayupholstery · rmla · sebca · svco). There is no crop
bug and no top-bar-missing repeat.
footer.bg is a MISSING PRODUCER, and the chain is broken at two points:
packages/components-v3/…/blocks/footer-wcp.astro:99readsbackground: var(--footer-bg, var(--background, #ffffff)).lib/assembler-fulldev/theme.js#emitGlobalCssemits--topbar-bg/--topbar-text(2026-07-13 pt4) and no--footer-bgat all.theme.json#colors.footerBgisnullon every site inspected, WCP included — so the extractor never populates it either.palette.footerBgis referenced attheme.js:161only as an input to the brand-accent picker.
So every CMS site in the fleet paints a white footer regardless of brand. This is the SAME BUG as the topbar-palette one fixed 2026-07-13 pt4, one region over — that fix did topbar only and nothing swept the sibling regions. See known-patterns "A CSS custom property that only ONE lane passes" (this is its degenerate case: a property no lane passes).
Why nothing caught it for a month: WCP's live footer is genuinely white
(#ffffff), so WCP reports footer 7/7 agree. The one site the pipeline was
tuned against is blind to this defect by coincidence. Third instance of
the ADR-0010 second-amendment shape — a contract derived from one reference
site is a hypothesis. See known-patterns "A reference site can be blind to a
whole defect class BY COINCIDENCE".
Correction to this entry's own payoff number. "Clearing this one veto → 18 of 38" is only true if all four dims are fixed; the dims co-occur heavily, so no single fix is high-leverage:
today 2 ships
fix header.cta only 5
fix footer.bg only 4
fix header.layout only 2
fix footer.bg + layout 10
fix footer.bg + layout + cta 14
fix ALL chrome dims 18 (= the 18 sites blocked ONLY by chrome)
header.layout ×17 is not new work. ADR-0009 Slice 3 already records
inline-left as "recognised by the classifier, routing deferred". That
deferral is now measured at 45% of the portfolio — the corpus is the
evidence its deferral-trigger was waiting for.
Scoping consequence: take the chrome dims as ONE slice (footer producer +
inline-left routing + header-CTA threading), because individually they pay
2–3 ships each and together they pay 16. Cathal's labels can now proceed in
parallel — the verdict is "real defects", so no later ruling reclassifies the
16 sites.
Footer producer — design settled 2026-07-21 (and it inverted the obvious answer)¶
The instinctive fix — read the value from the capture plane, which already
measures it correctly — is wrong, and the reason is not visible from the
capture data. section-backgrounds.json is gitignored
(git ls-files | grep -c section-backgrounds.json → 0); the only
committed per-site artifact is theme.json (.gitignore:48). The CMS ship
lane deploys through apps/cms prebuild → emit-theme-css.mjs, which reads
theme.json. An emitter that needed the capture would paint the footer
correctly on the box that ran the crawl and silently white everywhere
else — and emit-theme-css.mjs:68-72 already carries this exact scar for
design.json, with a theme._raw fallback written to work around it.
Measured data reaches the emitter through
theme.jsonor it does not ship. Corollary correction to a rule this repo repeats: "the capture plane is the authority" holds for GEOMETRY, not PALETTE. Header layout / CTA / floating / nav are classified node-side and consumed by the seed, which regenerates per build. Colour goes viatheme.json. The topbar is the precedent and it runs the other way from how it is usually cited:--topbar-bgcomes frompalette.topStripBg(extractor,pickTopStripBgEl), whilechrome.topbarStripis a scorecard probe measuring both lanes. Two walkers coexist deliberately — and that answers the drift objection: you don't need one walker, you need the capture to score the extractor, which it already does.
Shape of the fix (no .astro change — both footer copies already read the
vars):
- lib/theme-extractor.js — add pickFooterBgEl() beside pickTopStripBgEl(),
same largest-painted-descendant walk, scoped to footer, #SITE_FOOTER; store
as _raw.footerBgEl.
- lib/theme-derive.js:126 — prefer sample.footerBgEl and fall back to
sample.footer (mirrors the topStripBg chain).
- lib/assembler-fulldev/theme.js — a footerLines block beside topbarLines,
same omit-when-absent contract.
- --footer-text: derive via the existing contrastingForeground() (returns
only bounded neutrals — a legibility contract over a measured parameter, not
a fabricated brand colour), and emit it only when the measured footer is
dark. Always-emitting would shift text on every light-footer site including
both references.
Two numbers this corrects, both of them mine:
1. A re-crawl IS required. The picker runs in the browser, so _raw has no
footerBgEl and scripts/rebuild-theme-from-raw.js cannot backfill it.
--force theme across the corpus (~1 browser launch/site).
2. Ceiling is 27 → 22, not 27. bgBucket (lib/globals-diff.js:65) tests
rec.media before parsing hex, so live buckets media and no hex we
emit can ever match it — painting underHex would bucket color/dark
and still disagree, while adding a fabrication-adjacent decision for zero
scorecard movement. The 5 media footers need our footer to render a real
background image (so our own capture returns media:true) — a separate
slice, deliberately not built here.
Reference sites do NOT stay byte-identical — the earlier claim in this
entry was wrong. garvanbay's live footer is #fafafa (all 10 pages), not
#ffffff; it agrees today only because both sides bucket light. State the
proof as bounded change: declaration-level CSS diff showing exactly one added
declaration (--footer-bg) per reference, --footer-text absent on both,
nothing else moved; then a re-measured scorecard holding footer.bg.agree ===
true on both (garvanbay #ffffff → #fafafa stays inside paletteClose —
dLuma ≈ 0.02 vs LUMA_GAP 0.3).
Two traps found in the same sweep:
- lib/theme-generator.js:96 fabricates a navy footer —
--color-footer-bg: ${c.footerBg || '#1a3258'}. Since footerBg is null
portfolio-wide this emits navy for every site. Nothing imports it (dead
pre-fulldev CLI), but it violates the never-fabricate rule and is exactly
what a future session greps up as prior art. Delete or gate it in this slice.
- scripts/rebuild-theme-from-raw.js clobbers foreign keys — it writes
{...deriveTheme(json._raw), _raw}, so anything injected into theme.json
from outside deriveTheme is silently deleted on the next rebuild. This
independently kills the tempting "let the live capture patch theme.json
afterwards" variant: the value must flow _raw → deriveTheme.
- Two identical copies of footer-wcp.astro exist
(packages/components-v3/src/components/blocks/ and
apps/cms/src/components/blocks-v3/) with no sync script. Harmless here
(no markup change), latent for the next slice that touches footer markup.
SHIPPED 2026-07-21 (footer-bg half of the chrome slice) — proven on rendered
output, not asserted. pickFooterBgEl() added to lib/theme-extractor.js
(coverage gates lifted from resolveSectionBg, not invented), stored as
_raw.footerBgEl; theme-derive.js prefers it with the sample.footer
fallback; emitGlobalCss emits --footer-bg (omit-when-absent) + a
contrastingForeground-derived --footer-text only on a dark footer
(luminance < 0.55). The navy-fabrication trap in the dead theme-generator.js
CLI gated shut in the same commit. No .astro change — both footer copies
already read the vars.
Proof (box, run-cms-batch --force cms-build,capture-ours,scorecard, three
sites re-crawled first so _raw.footerBgEl is populated):
| site | before | after | proves |
|---|---|---|---|
| banesolicitors | HOLD 91.4, sole veto chromeDisagree |
SHIP 91.4, zero vetoes | footer-only chrome site → real HOLD→SHIP |
| qconsulting | footer.bg disagree; dims header.layout,footer.bg |
footer.bg → agree; only header.layout left |
dim moves independently; veto correctly persists on the OTHER dim |
| WCP (control) | footer white, agree, veto videoDebt |
byte-identical | zero-regression on the reference |
ours now reads #041c42 / #2c353e — our rendered footer paints the
measured brand colour and the symmetric ours-probe confirms it (ADR-0011 §1d
"the lever must move the render"). Zero-regression on the committed reference
theme.json also proven declaration-level: absent a re-crawl (_raw has no
footerBgEl), the patched emitGlobalCss is byte-identical to HEAD on
both references — the change is inert until the capture supplies data.
Still open (the rest of the chrome slice): ~~header.layout ×17~~
SHIPPED 2026-07-21; ~~header.cta ×10~~ SHIPPED 2026-07-21;
~~header.bg ×7~~ SHIPPED 2026-07-21 (5 solid; 2 media deferred). And the
fleet re-crawl: --force theme across the corpus so every site's
theme.json gains footerBgEl and headerBgEl — until then only the
proof sites carry the measured colours; the other dark-chrome sites still emit
no --footer-bg/--header-bg. Payoff ceiling for footer.bg alone is 27 →
22 (the 5 media-backed footers can't be expressed as a hex — see the media
note above); header.bg is 5 solid (fixable) + 2 media (deferred, same
reason). All four chrome dims are now producer/render-complete.
PAYOFF BANKED — fleet re-crawl + regen done 2026-07-21¶
The fleet re-crawl ran (28 sites: 23 with a live theme re-crawl for the new
footerBgEl/headerBgEl, 5 render-only; 10 skipped as already-clean). All
28 completed, no failures. batch-scorecard regenerated the 38-site matrix.
| metric | before | after |
|---|---|---|
| SHIP | 2 | 15 (+13) |
chromeDisagree veto |
33 | 7 (−26) |
The 13 flips (HOLD→SHIP): banesolicitors, braylaunderette, broderickwindows,
charlietaxiservices, johnrochford, petergartlansolicitors, qconsulting,
reganfinance, ryanandassociates, soscleaningservices, southsidemovers,
specialbranchtreeservices, squareonechildcare. Composites are ~unchanged
(e.g. banesolicitors 91.4→91.4) because chromeDisagree is a veto, not an
axis penalty — clearing it flips the verdict without moving the score, which
is exactly the clean signature (no G_det/T drift from the re-crawl).
All 7 remaining chromeDisagree are the deferred MEDIA cases — 5
media-backed footers (annetaitsolicitors, brayupholstery, rmla, sebca, svco) +
2 media-backed headers (bradleyaccountancy, pbaxterandsons). A hex can't express
a media-backed chrome fill; closing them needs our lane to render a background
image (a separate slice). The fixable chrome disagreements went to zero.
Brand-drift check (the re-crawl discipline) — ZERO drift. Every one of the
23 re-crawled sites derived a byte-identical brand primary + source vs
run-3. So the ADR-0009/brand-palette determinism fix held across a 23-site
re-crawl, and every verdict change is attributable to the chrome fix, not
palette non-determinism. (This is also the first real corpus exercise of the
"reopen with the Slice-6 corpus" residual — it did not bite.)
Sites that cleared chromeDisagree but still HOLD on another veto
(criticalSectionDropped, contentDestroyed, deadAssetRef, …) are the
correctly-ranked residual veto load, now the top of the Slice-6 fix queue.
svco additionally shows a header.cta miss (its Book Consultation→calendly
CTA didn't render) — incidental, since it's already media-footer-deferred; note
for a later pass.
header.layout — inline-left routing shipped 2026-07-21 (render-only, NO re-crawl)¶
The re-crawl question answered itself: all 17 disagreeing sites already
carry headerLayout: "inline-left" in the run-3 captures (the classifier
measured it; classifyHeaderLayout has emitted the value since the header
slice), and seed-from-build.js:597 stamps it verbatim — so every seed
already held layout: "inline-left". The block simply had no branch for it
and fell through to stacked-center. This was render-routing debt, not a
capture gap — the exact ADR-0009 Slice 3 deferral ("inline-left recognised
but routing it is a later slice"), now measured at 45% of the portfolio.
Shipped: a third inline-left branch in header-wcp.astro (logo left, nav +
optional CTA right on one row — fulldev header-1's original arrangement, which
stacked-center was forked from), inserted between the split and stacked
branches so both existing branches are byte-untouched (git-confirmed). Plus the
mechanical enum/type widening — HEADER_LAYOUTS in the marketing-blocks
schema, the fcr-blocks/Header.astro adapter type. seed-from-build needed
no change (value-agnostic stamp). The static lane is unaffected (ADR-0013
internal tooling; it passes no layout, so it defaults to stacked).
Proof (box, same runner, --force cms-build,capture-ours,scorecard; qconsulting
+ charlietaxi re-crawled first so BOTH their chrome dims — footer + layout —
clear together):
| site | before | after | proves |
|---|---|---|---|
| qconsulting | HOLD 96.7, sole veto chromeDisagree (footer+layout) |
SHIP 96.7, zero vetoes | both chrome dims cleared → highest scorer ships |
| charlietaxiservices | HOLD 94.1, sole veto chromeDisagree |
SHIP 94.1, zero vetoes | generalises to a 2nd site |
| WCP (split control) | HOLD 87.3, split→split agree | byte-identical (87.3, videoDebt) |
inserting the branch does NOT perturb the split sibling |
ours now measures inline-left on both — the rendered header matches live.
garvanbay (stacked control) was NOT rebuilt (its capture baseline isn't on
the box): its seed layout is unset → the untouched stacked-center else
branch, and WCP proves a branch insertion leaves siblings byte-identical, so
stacked is safe by construction. section-style + globals-diff validate
suites pass; the three cms-builds type-checked the block/adapter/schema chain.
Combined chrome tally after footer + inline-left: of the 16 sites gated
only by chromeDisagree, both dims now clear on any site whose remaining
chrome dims are just {footer.bg, header.layout}. The full fleet payoff lands
after the corpus --force theme re-crawl + batch-scorecard regen (still
pending). header.bg ×7 remains.
header.cta — stacked-branch CTA slot shipped 2026-07-21 (render-only, NO re-crawl)¶
Same shape as header.layout, one branch over. The live side is fully
captured (chrome.headerCta, populated on all 10 — 7 via raw.cta an
in-<header> styled link, so cands=0 there is not a gap; the value is
persisted in headerRaw and reproducible) and seed-from-build.js:598-601
already stamped ctaText/ctaHref into every seed. ours=null was a pure
render gap: 8 of 10 sites are stacked-center, and the stacked branch had
no CTA slot at all (hasCta was only wired in the split branch, and — as
of the inline-left slice — the inline branch). The other 2 are inline-left
and were already covered by that slice's hasCta.
Shipped: a {hasCta && …} CTA pill in the stacked branch, absolute right-4
top-4 on desktop only (@max-5xl:hidden), mirroring the mobile hamburger's
slot on the opposite breakpoint so they never collide and the centered
logo/nav stack is undisturbed. Reuses the existing .header-wcp-cta style —
no new CSS, no schema/adapter change (the adapter already builds cta
from ctaText/ctaHref). Unlike header.layout this edits inside the
stacked branch (garvanbay's code path), so the control was chosen accordingly.
Proof (box, same runner):
| site | before | after | proves |
|---|---|---|---|
| johnrochford | HOLD 95.9, sole veto chromeDisagree |
SHIP 95.9, zero vetoes | stacked CTA renders → both/agree → ships |
| brianlarkinsolicitor (stacked, no CTA) | SHIP 87.9 | byte-identical | the stacked-branch edit is inert without a CTA — the garvanbay-class control (garvanbay's seed carries no ctaText, verified) |
| WCP (split, has CTA) | HOLD 87.3, SERVICE AREAS agree |
byte-identical | the split branch's own CTA is unaffected |
ours now measures the phone/CTA pill on johnrochford (01 8721499). Only
header.bg ×7 remains before the fleet --force theme re-crawl.
header.bg — producer + text cascade shipped 2026-07-21 (5 solid; 2 media deferred; surfaced + fixed an obsolete veto)¶
The last chrome dim, and the only one that split three ways: 5 solid-colour
headers (bestcover #0f3b5e, shinners/sparklingbright #000000, squareone
#8fdacc, svco #575656), 2 media-backed (bradleyaccountancy,
pbaxterandsons — a hex can't express these; deferred, same class as the
media footers and imagesDropped drift). All 5 solid had theme.headerBg =
null because <header> computes rgba(0,0,0,0) and the fill is on a
descendant — the exact footer pattern, so it NEEDED a re-crawl (new
headerBgEl field), unlike layout/cta.
Shipped:
- lib/theme-extractor.js — the footer picker generalised to a shared
pickCoverBgEl(root) (identical walk; footer output unchanged) and called
for both $footer and $header → _raw.headerBgEl.
- lib/theme-derive.js — headerBg prefers headerBgEl, sample.header
fallback (mirrors footer).
- lib/assembler-fulldev/theme.js — --header-bg (omit-when-absent) + a
contrastingForeground-derived --header-text only on a dark header.
- header-wcp.astro — the <header> reads var(--header-bg, …) (dropped the
hard-coded bg-background), and --header-text recolours only the
top-level nav items (the child-combinator rule), NOT the dropdown-popover
links (which sit on their own light bg-background and stay --foreground).
The gate is BLIND to text legibility on chrome (header.bg compares only
the background colour — the ADR-0011 correlated-blindness case), so this was
proven two ways: the scorecard flip and an eyeballed render. On shinners
(black #000000, the worst case) the screenshot shows white nav items,
brand-red active item, black footer with white text — fully legible.
Proof (box):
| site | before | after | proves |
|---|---|---|---|
shinners (dark #000000) |
HOLD 86.2, chromeDisagree+systematicVariantMismatch |
HOLD 86.1, chromeDisagree cleared, holds only on pre-existing systematicVariantMismatch |
header.bg + footer both agree; nav text legible (screenshot) |
| brianlarkinsolicitor (light header) | SHIP 87.9 | byte-identical | dropping bg-background is pixel-inert when headerBg is null |
| WCP (light header) | HOLD 87.3 videoDebt |
byte-identical | unchanged |
It surfaced an obsolete veto — narrowed, not removed (owner-approved gate
change). transparentBlackRemnant (fidelity-harness.js:121) flagged any
headerBg/footerBg/background equal to #000000, built (commit e1d4d64)
to catch the old transparent-parsed-as-black bug. That bug is already prevented
at the deriver (theme-derive.js: alpha≈0 → null) and pickCoverBgEl only
accepts opaque fills — so #000000 in chrome is now a genuine measurement
(shinners' live header AND footer are black). It false-fired on shinners with a
−20 T penalty (86.2 → 83.9). Fix: narrow the remnant check to background
alone (a black page canvas is still the classic remnant signature; a black
header/footer is legitimate chrome). bgFields still feeds the before/after
delta unchanged. Zero impact on the existing matrix — no run-3 site had a
non-null black header/footer, and the veto fired on zero corpus sites; the
change is purely forward-looking. Re-scored shinners 83.9 → 86.1, veto gone.
See known-patterns "A veto built to catch a PARSE bug false-fires once the value
becomes measurable".
Mesh prevalence (0-section sites) — MEASURED 2026-07-21 on the corpus: 3 of
41 (7.3%). scripts/measure-mesh-prevalence.mjs counts mesh sites by the
pipeline's own section definition — the scorecard S-axis input
(structural.pages[].rows, the live↔ours alignment) — NOT a seed-schema guess.
(The 2026-07-20 attempt tried three seed counts and each failed its control:
brianlarkinsolicitor, a healthy 87.9 SHIP, counted 0 under two of them.) The
correct signal is "0 live-bearing rows on every scored page", which catches
BOTH the S=null shape (bluestars, 1 page) and the S=0 shape (sweeney, 5
pages) — the axes alone would miss sweeney. Built-in positive+negative controls
(bluestars/cassidy/sweeney MUST be mesh; brianlarkin/qconsulting MUST NOT) fail
loud, and the structural count agrees with the axes heuristic on every site. The
three mesh sites: bluestarsearlyyears, cassidymonumentals, sweeneyofwexford.
Still open — the PORTFOLIO number. The corpus is ~41 hand-picked team-built
sites, not a random draw; 7.3% extrapolates to order ~130 of 1800 (Wilson 95% CI
on n=41 ≈ 45–350), so the owner's "hundreds" prior is plausible at the upper end.
Reopen for the portfolio-wide count at the full crawl (count 0-<section> at
seed time). Decision pending (owner, 2026-07-21): a mesh-generation segmenter
slice vs. explicit manual-tier routing — NOT silent breakage, since these sites
cannot rebuild at all (S=0/null, the sole blocker). See memory
project_mesh_prevalence_expectation.
https_.jpg URL-mangle (sreenanandcompany.ie) — ROOT-CAUSED + FIXED
2026-07-21. The 403 (HTTP 403 https://static.wixstatic.com/media/https_.jpg,
run stats uploaded 1 / idempotent 1 / skipped 6 / failed 8, 1 of 42 sites) was
NOT a transform-seed-images bug — the mangle is baked in at CRAWL time by
lib/dom-pipeline.js's image self-hosting, which split a wixstatic URL on the
first /media/. A nested/doubled URL (…/media/https://…/media/<id>.jpg)
then yields urlPath https://…, whose first path segment is https:; the
[^a-zA-Z0-9._~-]→_ normalise turns : into _ → media id https_ (length 6,
so the < 5 guard misses it) → /assets/images/https_.jpg, carried into the
seed. Fixed by extracting lib/wix-media-ref.js#deriveWixMediaRef (splits on the
last /media/ → the real inner id, + a protocol-prefix skip guard;
byte-identical on every normal single-/media/ URL) and wiring it into
dom-pipeline. Locked by lib/wix-media-ref.validate.mjs. See known-patterns "A
nested/doubled Wix /media/ URL mangles when split on the FIRST /media/".
Recovery correction: because the bad ref is baked into body.html at crawl,
recovery is --force crawl → re-seed → images, NOT --force images alone (an
earlier note said images-only, which re-reads the already-mangled seed).
Confirmation pending (needs the box): the fix targets the demonstrable
mechanism that yields exactly https_.jpg; a box re-crawl of sreenanandcompany
confirms it resolves that specific site. If its actual URL is a different nesting
shape, the new guard at least skips it loudly as un-migratable rather than 403-ing
on a fabricated URL — no silent broken-image ship either way.
Next: ~~adjudicate chromeDisagree~~ done 2026-07-21 (four real
producer gaps — see the adjudication above) → the chrome slice (footer
producer first, then inline-left routing, then header-CTA threading) →
Cathal labels ship / needs-polish / hold, which no longer needs to wait
now the verdict is known → fit-calibration → promote only if fitted:true
+ precision.
Corpus artifacts live ONLY on the box and are not in git. The repo's
calibration/feature-matrix.json is still the stale 2-site copy; the 38-site
matrix and all 38 builds/<host>/fidelity-scorecard.json exist only on
cathals-demo, which already died once this month (07-19 incident). Pull them
into the repo before the next slice — builds/* is gitignored by design, so
the scorecards need either an explicit re-include or a digest committed under
calibration/.
Deployed-site fidelity gaps the scorecard surfaced (real bugs to fix, distinct
from metric work — not chased this session since Steps 0–2 were the metric, not
fixes):
- ~~garvanbay CMS home renders default where live is muted~~ — CLOSED
2026-07-10: there was no render bug. The deployed DOM painted bg-muted
(#f2f2f2 vs live's #f3f3f3 — visually identical) all along; the ours-capture
probe's rgb-only parseCssColor couldn't read Chromium's oklch(…) computed
serialization, reported "no fill" → default, and fabricated the gap. Fixed
instrument-side (canvas colour normalization); home S 83.5→84.6, SHIP held at
92.2. See known-patterns "The symmetric re-capture probe must parse every
colour space OUR lane paints in".
- ~~WCP renders service-grid columns=4 vs live 3 + 3b71607 teaser/cta-body
pipeline never applied~~ — regen+patch DONE 2026-07-10. Fresh live re-capture
(schemaVersion 2, all style dims), seed regenerated through the current pipeline,
grafted (zero page/globals divergence — WCP was reseeded, never persistently
edited), worker redeployed (version 2358615b) + live D1 reseeded (50 changes).
Landed: service-grid columns=3 (rendered + scorecard-confirmed, no mismatch
flagged), cta-strip body 23/39, align on 134 blocks, tone, 13 hero-scrim
corrections (strong→none, 0 regressions). Score: composite 73.7→76.3, S
60.9→65.5, G_det 73.1→74 — improved but still MANUAL-POLISH (S floor 70).
Two residuals hold it (both out-of-scope, pre-existing):
1. gallery page S=36.4 — Wix Pro-Gallery structural case (live = one giant
gallery widget → 31 junk sections filtered, 1 common; ours = 5 structured
blocks). Not a content-regen fix; a scorecard/segmentation limitation.
2. ~~service-page heroes render full-bleed dark vs live light~~ — FIXED
2026-07-10 pt4 via a capture-driven hero-1 route (NOT the "matcher-level
Hero.js" fix this entry once proposed — the matcher is geometry-blind in the
seed path; the LIVE capture already measured imageSide+bg.media). 10 service
heroes now render hero-1 (light text+side-image, no scrim). commercial-painting
hero tone:default→dark diff gone. See known-patterns "The capture plane, not the
matcher, is the authority for a hero's full-bleed-vs-side layout".
- Reopen: when the scorecard drives a manual-polish pass, or when garvanbay/WCP
content is re-stamped/re-seeded with the current section-style pipeline.
garvanbay end-to-end pass — HOLD→SHIP; residual polish (2026-07-08 pt5)¶
First full-site scorecard pass on garvanbay (all 10 pages; before, only home
was measured). Started HOLD (composite 87.9 — a criticalSectionDropped veto),
now SHIP (composite 91.2, S 88.5, G_det 87.8, no veto). Landed: the
Hero-dedupe re-match fix (seed-from-build.js, commit f72a93e) restored every
subpage's dropped intro band — accountspreparation / management-accounting /
payroll → S=100; the no-booking book-an-appointment page (hero + 5-service grid +
enquiry CTA) removed the veto. Images resolved to real R2 URLs via an authoritative
basename→URL map from R2 object metadata (x-amz-meta-original-name), 0 broken.
The fixed seed is committed (apps/cms/seed/garvanbay.json); demo redeployed (worker
bb2e95c8). Residual (none block SHIP; composite now 92.2 after the 2026-07-10
teaser-CTA + dropped-band slice 3b71607, which closed the company-secretarial /
tax-and-advisory / accountants-waterford dropped bands — TeamGrid ALL-CAPS
false-positive + missing fcr.cta-strip.body field):
1. ~~muted-tone bands~~ — CLOSED 2026-07-10: a capture-probe artifact, not a
render bug (rgb-only parser blind to oklch; see known-patterns "The symmetric
re-capture probe must parse every colour space OUR lane paints in").
2. book-an-appointment S=37.5 (was 39.7 — the 2026-07-10 oklch capture fix
surfaced one more real divergence: the hand-crafted "Our Services" grid carries
a muted band live doesn't have; accepted divergence on this deliberately-
rebuilt page) — our clean service grid consolidates live's 4
separate service sections → they score as dropLive. Structural (grid vs
sections), not content loss; the page is presentable. Split into per-service
sections only if the number needs lifting.
3. ~~Re-match over-produces on Wix responsive-doubles~~ — CLOSED 2026-07-14, and the
MECHANISM this entry stated was FALSE. It read: "both halves of a doubled section lose
Hero and BOTH re-match → duplicate fcr.about blocks", with Proper fix: "re-run
dedupeLayoutMap's dup-collapse (or sigBody) AFTER the re-match swaps a loser's
matcher".
Correction (2026-07-14): the duplicates are real; the causal story was invented. Measured: the doubles are nested ancestor/descendant pairs — an outer
<section>wrapping an inner one with byte-identical text — 67/67 on WCP, 20/20 on garvanbay, ZERO adjacent siblings. And the re-match never fires on the failing page: the inner half matchesCTAStripnatively, becausectx.positiondiffers between the halves andHerogates onposition <= 1, so the outer wins Hero at pos 1 and the inner falls through at pos 2. A sibling-adjacency collapse run "after the re-match" — the fix this entry prescribed — would have matched ZERO pairs.Why the original was drawn: the symptom is identical either way (two blocks, different matchers, same content), and the re-match is a real mechanism that really does swap a loser's matcher — it just isn't what happens here. The 4 hand-removed dups were real. The conclusion (the dedupe keys on
matcher.name, so a double whose halves diverge is invisible to it) was right. Only the why was fiction — and it was written by a session that removed the dups by hand and never ran the fix it proposed.
Actual fix (shipped): a third dedupe.js pass collapsing on content signature + DOM
ancestry, independent of matcher name. Ancestor + identical full text ⇒ the outer holds
nothing but the inner ⇒ it is a wrapper, not a second band, so a legitimately-repeated band
(never nested inside its own twin) cannot be eaten. WCP 287 → 264 blocks; garvanbay 71 →
71, zero delta; all 7 garvanbay Hero re-matches still fire. /commercial-repairs no
longer prints its heading three times. See known-patterns "Wix's responsive DOUBLE is a
NESTED WRAPPER, not an adjacent sibling".
- Note: garvanbay's build inputs (src/data/*.body.html) live only on EC2 and its
checkout is 100+ commits behind + dirty — the rebuild was done by pulling the 10
body.html locally and running the current generator, not by building on EC2.
Reseed/redeploy target the demo's D1/worker on the personal CF account (wrangler
authed locally).
Header / nav menu — B-defects FIXED 2026-07-08 pt6; operator editability blocked on an upstream emdash save race¶
The 2026-07-08 pt6 slice closed the render/structure half and root-caused the
editability half. Shipped (deployed worker e3ffaf4c + surgical D1 patch,
scorecard re-run: SHIP held, composite 91.3, G_det 87.8→88.3):
- B1/B2 (font/spacing) root cause:
apps/cms/src/layouts/Base.astrocarried an unlayered universal reset (*, *::before, *::after { margin:0; padding:0 }) in<style is:global>. Unlayered beats any@layer, so every Tailwind padding/margin utility across the WHOLE CMS lane silently lost (navpx-3→ 0, items ran together). Removed — Tailwind preflight (@layer base) already ships the identical reset, correctly layered. See known-patterns "An unlayered universal reset in an Astro component style silently disables every utility class". - Nav metrics tokenized on
header-wcp:--nav-font-size(15px) /--nav-item-px(10px) /--nav-row-h(48px) — defaults measured on live garvanbay; per-site capture can override from:rootlater (fold into the ADR-0009 header/density dimension). Plusaria-current="page"brand-colour highlight on the active item (matches live). Verified via computed values on the deployed page. - B3 (More/submenu): the drop point was
fromHeader(matcher + translate + the canonical block all already supported dropdowns).fcr.header.itemsgained a multilinesubItemsfield ("Label | /url"per line — the footer-linesencoding; Block-Kit repeaters are scalar-only);fromHeaderemits it frommenus[].links; the adapter parses it back. Deployed More▾ now carries Accountants Waterford / Book An Appointment / Contact, desktop + mobile sheet. - Bonus: the deployed logo had been broken since the pt5 globals reseed left a
raw
/assets/path in live D1 — patched to the resolved R2 URL (verified 200);fcr.business.imageUrl(JSON-LD) same.
STILL OPEN — A. Operator editability (upstream emdash bug). The globals entry
IS surfaced and fully editable in the admin UI ("Site-wide blocks" → site →
Header pencil; note emdash's native MANAGE→"Menus" section is unrelated — a
discovery trap). But saving a Block-Kit dialog edit fires TWO concurrent PUTs
with divergent payloads and the stale one wins (emdash 0.9.0 admin), so edits
report "Saved ✓" and then silently vanish. Reproduced cleanly local (pages AND
globals — the WS-3 success was a won coin flip; on drafts-enabled collections the
fresh edit lands in a revision but the stale one takes the draft pointer; on
no-drafts collections the loss is total). Direct API PUTs persist fine — client
bug, compounded by the server's non-serialized concurrent draft-creates. Harm
reduction shipped: globals collection now declares supports: ["drafts",
"revisions"] (seed generator + both committed seeds + live _emdash_collections)
so lost edits are at least recoverable revisions; the local junk-draft row (old
id-vs-slug reseed bug) deleted (live had none).
UPDATE 2026-07-09 — the upgrade slice ran and did NOT unblock editability.
The "upgrade to 1.0.0" fix path was executed as 0.9.0 → 0.28.1 (1.0.0 is an
accidental April publish, reverted upstream same-day — 0.28.1 is the real head;
see the "emdash upgraded to 0.28.1" entry below). Upstream issue #1158 (our
exact bug) was closed as "fixed by #1119" (0.15.0, Block-Kit dialog edits not
reaching the editor's formData) — but the race reproduced on 0.28.1 in the
local admin against a copy of live data: each save-click still fires two PUTs
with divergent payloads; trial 1 landed fresh-last (edit survived), trial 2
landed stale-last (edit reported "Saved ✓", orphaned as a revision, gone from
the UI after reload). The concurrent-PUT window and the unserialized server
draft-pointer write are byte-identical 0.9.0 → 0.28.1; no autosave-disable
config exists. Operator editability stays blocked pending a real upstream
fix — repro FILED upstream 2026-07-18 as a comment on #1158
(https://github.com/emdash-cms/emdash/issues/1158#issuecomment-5011095316;
the local draft is deleted per its own instruction). Checked the same day:
0.29.0's changelog touches nothing in the save/autosave/draft-pointer path,
and #1158 was closed 2026-06-29 — before the 0.28.1 repro — so upstream
believed a still-reproducible bug fixed until this comment. Note emdash is
Cloudflare's own OSS product (launched 2026-04-01), which raises both the
odds of upstream attention and the platform's longevity. Until fixed,
nav/content fixes ship
via the pipeline (surgical D1 patch), not the admin. Reopen trigger: an
emdash release whose changelog touches the admin save/autosave path or
_rev/draft-pointer handling (or #1158 reopened and re-fixed) → re-run the
2-trial Block-Kit UI repro from the pattern entry before trusting it.
Mitigating: revisions/drafts recoverability now works on both collections, so a
raced-away edit is recoverable rather than lost.
~~Also open: WCP worker still runs the pre-fix build~~ — closed
2026-07-09: the 0.28.1 upgrade redeployed WCP (version 72c15db9); nav
spacing/tokens verified live via computed styles (font 15px, item padding
10px). The trimtech "Header nav single-row vs two-row" note below is unrelated
(different defect, same canonical block).
Metric-side follow-ups (in the contract's "Follow-ups"): theme-fidelity.js
still lacks a .validate.mjs (structural-diff.js has one — now 53 checks);
imageSide over-fires on media heroes (like the gated columns over-fire); T
compares emission vs live, not the deployed DOM's computed styles; isJunk
misses WCP's heading-bearing "Watch Now" video-chrome strip (surfaced by
Slice 4a's matcherGap — a low-impact ribbon drop S already counted; widen the
heading-gated regex); the live↔ours page join in scorecard.js lacks the
home/index alias tolerance 4b-ii gave the sidecar join (manifestForSlug) —
a no---slugs ours capture keys the homepage index, live keys it home, and
the homepage silently drops from S/G with no warning (bit on 2026-07-10;
re-keyed by hand — a 92→93.1 phantom composite lift until caught).
G_det outlier triage + alignment fix — DONE + FIXED 2026-07-08. Triaged the
flagged outliers offline (no re-crawl): WCP FAQ (25%) was genuine (ours renders
the FAQ on navy where live is white); garvanbay hero (33%) and WCP content #1 (37.8%)
were alignment mis-pairing artifacts from structural-diff.js#canonicalise, NOT
the crop (control PAYROLL sim=93). Root cause fixed in canonicalise: (1) added
the bothEmptyDup collapse form — Wix doubles image-heroes/brand-ribbons with empty
headings, which the heading-keyed detector missed, letting a leftover twin get
NW-force-matched to an unrelated ours section; (2) decoupled the collapse anchor
(heading-bearing member, for S's NW cost) from the visual rect (earlier member,
for G's crop) so role=hero crops the hero band. Re-scored both anchors: garvanbay
hero G 33.4→63.6 (S unchanged 96, still SHIP); WCP spurious match gone, twins
single-penalised (S 59.9→60.9, still MANUAL-POLISH); WCP FAQ stays 24.9 (genuine,
preserved). The aligner cost model was left untouched (the pre-pass collapse removes
the force-match). Locked by lib/structural-diff.validate.mjs (42 checks). G_det is
now gate-ready on artifact grounds (its floor still awaits Slice-6 calibration like
the other axes). Detail: near-match-scorecard-contract.md "Follow-ups" (RESOLVED
2026-07-08) + known-patterns "A section-alignment collapse serves TWO axes". Residual
follow-up: the hero/welcome collapse still hides a genuine intra-pair drop from both
S and G (garvanbay renders no welcome intro+images band) — a distinct S improvement,
not chased here.
Header dimension — three fast-follows from the split-center slice (2026-07-12)¶
The header layout capability shipped (capability-backlog #2's "header/logo
layout" half — bounded stacked-center/split-center enum on header-wcp,
measured from chrome.headerRaw → classifyHeaderLayout, deployed to WCP; see
ADR-0009 Slice 3 + known-patterns "The capture plane is the authority for header
layout too"). Two named fast-follows remain (#1 closed 2026-07-12 pt3), each
confirmed against live WCP:
- ~~TopBar email / location drop~~ — CLOSED 2026-07-12 pt3. All three
fields of the original "phone-only topbar" symptom are now resolved: socials
fixed pt2 (
url→hrefthreading bug intranslateTopBar), email + location landed pt3. Email/location were genuine capture-plane misses (JS-rendered — the staticbody.html'spinnedTopCentercarries neither amailto:nor the "Available in Waterford | …" run) hidden further by a zero-height pinned container (its children are absolutely positioned, soresolveSectionBg($pinned)readsource: zero-size). Fixed by measuring the post-JS contact run in-page (chrome.topbarContactincapture-section-backgrounds.js, scoped to#pinnedTopCenter,mailto:-href +|-segmented run) + a fill-if-empty overlay inseed-from-build.jsafter the header overlay; landed on WCP live D1 via surgicalec_globalsread-mutate-write and rendered-artifact verified (phone · email · location, matching live). garvanbay overlay a faithful no-op (its matcher already had the fields). See known-patterns "A zero-height pinned container hides its measurable children from the bg probe". (Distinct from the trimtech topbar entry below — icon-only links.) - ~~Header CTA is pinned OUTSIDE
<header>→ not auto-detected.~~ — CLOSED 2026-07-12 pt4. WCP'sSERVICE AREASpill is a real<a href="/service-areas">pinned by Wix inpinnedTopRight(a sibling of the topbar strip, outside<header>), so the header-scoped probe returned null and the CTA had to be typed in by hand. A capture-plane pinned-layer walker now gathers outside-<header>button-styled anchors in the header Y-band (chrome.headerRaw.ctaCandidates) and a pureclassifyHeaderCta(lib/section-style.js) picks the winner, guarded by button-styling (the pill vs unstyled topbar contact links), a nav-row floor (cy ≥ navTop— excludes the topbar row; garvanbay's styled "Book an appointment" topbar pill is correctly rejected, so garvanbay staysheaderCta:null), and a header-band ceiling (excludes hero/floating buttons). Href same-origin-normalised so live (absolute) and ours (relative) compare equal. Verified on live WCP (detectsSERVICE AREAS → /service-areas, matching the committed seed — now reproducible-from-capture, not manual) + garvanbay (null, faithful). Same detector closes the globalsheaderCtamismatch (below). CMS lane; S/T/G byte-identical; +17 validate checks.inline-leftrouting (#3) + stacked-center CTA render + the per-site static lane stay deferred. See known-patterns "A pinned-outside-<header>button IS the header CTA". (Distinct from the trimtech topbar entry below — icon-only links.)
Correction 2026-07-13 — pt4's "reproducible-from-capture, not manual" claim above
was FALSE when written; it is TRUE now. The detector had only ever run against a
live browser session. The persisted section-backgrounds.json was captured
mid-pt4 and carried no ctaCandidates on any of its 24 pages, so a regen still
emitted ctaText: "" — caught by the pt6 graft check, which correctly refused the
globals write. The classifier was right all along; the stored artifact was one
version behind. Fixed by re-capturing WCP live 2026-07-13: ctaCandidates +
headerCta now on 24/24 pages, the regen produces SERVICE AREAS on its own, and
the live-D1 stamp came back a no-op (live and the regen independently agree —
that no-op is the proof). The committed seed is corrected (it still held
ctaText: "", the value that would have reverted live). See known-patterns "A
capture-plane detector is not shipped until the CAPTURE has been RE-RUN and
PERSISTED" — the general risk: any capture-plane slice that ships a new in-page
probe leaves every existing section-backgrounds.json in the fleet stale.
3. inline-left layout recognised but not routed. classifyHeaderLayout
returns inline-left (logo left, nav right — fulldev header-1) but the
render only handles stacked/split; an inline-left site would fall back to
header-wcp stacked. Reopen: first portfolio site measured inline-left —
route it to header-1 (per header-wcp's docstring) or add the variant.
TRIGGER FIRED 2026-07-18: salttherapysolutions.ie — the first blind
corpus site — measures inline-left on live and is the sole chrome
disagreement holding its chromeDisagree veto (every other chrome dim
agrees; scorecard globals.summary: 1 disagreement). The measurement side
worked unattended; only the render branch is missing. Shared-canonical
change → needs the zero-regression A/B on garvanbay + WCP per Phase 3.
×5 as of 2026-07-19 (11 corpus sites scored): salt + bestcover +
bkalarms + braylaunderette + brayupholstery all measure inline-left
live and all hold chromeDisagree on it. inline-left is evidently the
portfolio norm, not an outlier — this render branch is now the single
highest-leverage veto-clearing fix on the board. (The other systematic
chrome row in the same veto — header bg: color/media/dark → light, ×7 —
is a separate dimension; see the corpus-scores note in the scorecard
entry below.)
Capability #2's identify half — globals reporting projection SHIPPED
2026-07-12 pt2 (lib/globals-diff.js). Header / topbar / footer are still
excluded from the scored axes (capture-section-backgrounds.js guards
el.closest('header'|'footer')), but they are no longer scorecard-invisible: an
additive, reporting-only comparison of the LIVE chrome vs an independent
re-measurement of OUR deployed chrome (the symmetric live-vs-ours spine, not a
compare-to-seed) — header/topbar/footer background + headerLayout/headerCta —
now surfaces as a globals block in fidelity-scorecard.json (S/T/G_det
byte-identical; lib/globals-diff.validate.mjs 36 checks). It now runs on the
v3 captures (2026-07-12 pt3 re-capture of WCP + garvanbay): header/footer/topbar
background agreement + topbar presence, and the real headerLayout/headerCta
live-vs-ours numbers — WCP headerLayout agree (split-center = split-center,
confirming our deployed header matches live); headerCta was a false mismatch
(live null vs ours SERVICE AREAS — the live pill exists but the header-scoped
probe couldn't reach it, pinned outside <header>), CLOSED 2026-07-12 pt4 by
the fast-follow #2 detector: both live + ours now measure SERVICE AREAS →
/service-areas → status:both, agree:true, WCP globals disagreements 1 → 0
(garvanbay stays neither/null, faithful). Still deferred: the globals gate
vetoes + thresholds (Slice 6, like every reporting projection). See
capability-backlog.md #2.
~~WCP globals still not fully re-seedable — 3 residual divergences~~ — CLOSED 2026-07-13 pt3 (the table below had LIVE and REGEN the wrong way round)¶
2 of the 3 rows were never divergences in the pipeline's favour — they were LIVE being WRONG. The entry as written on 2026-07-13 pt2 said:
block live (faithful) regen (would overwrite) fcr.floating-ctaapi.whatsapp.com/send?phone=…,bottom-rightENQUIRE→/contact-us,rightfcr.floating-socialfacebook + youtube whatsapp only native blockpresent (operator-authored) absent The first two look like the same class as the header CTA: the matcher guesses a generic default where the capture plane should be measuring the real floating button.
The "live (faithful)" column header was the error. Measuring the live Wix site (Playwright, 1440×900) settled it in one lookup — live carries three floating widgets, and our deployed site matched none of them:
| live Wix DOM (measured) | container | what live D1 served |
|---|---|---|
ENQUIRE → /contact-us, mid-right pill (179×45) |
pinnedMiddleRight |
nothing — the slot held a WhatsApp button |
| WhatsApp button, bottom-right (55×55) | pinnedBottomRight |
mislabelled as the CTA |
Up scroll-to-top (47×47) |
pinnedBottomRight |
no CMS home (recognised + dropped) |
| — (no floating social rail exists on live) | — | facebook + youtube rail (phantom) |
The regen was the faithful one. FloatingCTA.js had ENQUIRE → /contact-us right all
along; FloatingSocial.js had the WhatsApp item right and only its hardcoded
position: 'right' wrong (live measures bottom-right). One field of the six was
genuinely unmeasured.
Where live's values came from: apps/cms/scripts/.tmp/wcp-add-floating-globals.sql —
an untracked, hand-written SQL patch (no git history) from the 2026-07-11 session that
first registered these globals. It invented a WhatsApp CTA, copy-pasted the topbar's
facebook+youtube into a floating rail live doesn't have, and wrote the socials: url: ""
that pt2 later spent time fixing. Three bad values from one hand-authored file — and the
wiki then recorded them as ground truth and reasoned forward from them, which is how a
whole dimension got scoped against an inverted premise.
Closed by the floating dimension (capture-plane probe + classifyFloatingCta /
classifyFloatingSocial, ADR-0009 Slice 3) plus a correction of live toward
measurement: live D1 stamped surgically (sync-globals-to-d1.mjs, 2 blocks patched, 7
preserved — the native block and the migrated r2: logo refs both survive). Deployed WCP
now renders ENQUIRE → /contact-us at right and a WhatsApp rail at bottom-right; the
phantom facebook/youtube rail is gone; 0 loud-fail placeholders. No worker redeploy was
needed (the render path was already in the bundle — a data-only fix).
Residual: 1 row, and it is correct. The native block (an empty operator-authored
paragraph: {"_type":"block","style":"normal","children":[{"_type":"span","text":""}]}) is
genuine operator surplus. A regen should never produce it, and the graft must always
preserve it — that is the tool working, not a defect. WCP's globals are now
reproducible-from-capture end-to-end, with the standing rule unchanged: globals land by
surgical stamp, never a reseed (a reseed would still revert the migrated logoUrl /
business.imageUrl to raw /assets/ paths, because the regen emits pre-migration refs).
The lesson, and it is the third in a row of the same shape: pt7 was check the producer before believing a capture gap; pt2 was check the artifact before believing a detector shipped; this one is check the ARTIFACT before believing LIVE is the truth. A value sitting in production D1 has no more authority than the hand that typed it. See known-patterns "A hand-written D1 patch is a FABRICATION with a production address".
fcr.topbar.socials was a fourth divergence and was closed pt2 — live being stale,
not the regen being lossy (see known-patterns "The graft check is a TWO-WAY diff"). Same
.tmp file, same root cause.
Floating dimension — two render-lane follow-ups (2026-07-13 pt3)¶
The floating measurement is shipped and agrees with live (ADR-0009 Slice 3). Two gaps remain, both on the render side — the enum value is correct, what the block does with it is not:
-
floating-cta-wcp--rightrenders a ROTATED VERTICAL TAB; live's ENQUIRE is a horizontal pill. The block stylesposition: 'right'withtransform: rotate(-90deg)(a side-tab, a real Wix pattern) — but WCP's live pill measures 179×45 un-rotated at the mid-right edge (a rotated pill's bbox would read 45×179). So we render the right position with the wrong orientation. The measured enum is faithful; the block's interpretation of it is a design choice made before anything was measured. Fix candidates — UNTESTED HYPOTHESIS: (a) restyle--rightas a horizontal pill and add a distinctright-tabvalue for genuine side-tabs, or (b) capture orientation as a bounded dim (the raww/hare already inchrome.floatingRaw.candidates— no re-crawl needed to decide). Reopen: when the floating CTA is next worked, or when a second portfolio site measures a floating CTA (whichever first) — one more site tells us which treatment is the portfolio norm. -
The scroll-to-top FAB has no CMS home.
classifyFloatingCtarecognises it (bare//#href, orUp/Toptext) and deliberately drops it — without that test its/href would read as a CTA to the homepage. Live WCP has one (47×47, bottom-right); we render nothing. It is pure chrome (no content), so the fidelity cost is small and it is loudly dropped rather than silently mis-rendered. Reopen: when a site's scorecard flags it, or when the floating render work above lands — addingfcr.scroll-top(a zero-field global) is ~30 min at that point.
~~The baked seed carries the WRONG SITE's identity — WCP's admin says "garvanbay"~~ — CLOSED 2026-07-13 pt4¶
Fixed by an assertion a build cannot bypass. resolveSite() already asserted
seed.meta.name === SITE — but only inside prebuild, and .emdash/seed.json is a
gitignored build artifact, so npx astro build (skipping the npm lifecycle) would bake
whatever stale seed was lying in the tree. That was the rot mechanism.
The guard now lives in apps/cms/astro.config.mjs at config load — every astro build
and astro dev loads the config no matter how it is started, so prebuild can be bypassed and
this cannot. A missing or mismatched .emdash/seed.json refuses to build, naming the
site and the fix. emit-seed.mjs was also added to predev (it only ran in prebuild, so
astro dev on a named site would have silently used the default site's seed).
Proven, not assumed: with a garvanbay-baked seed in the tree,
SITE=waterfordcountypainters now REFUSES ("building it would give
"waterfordcountypainters" another client's identity and content"); SITE=garvanbay builds.
WCP was redeployed and its admin now runs under its own name.
This closes the hard blocker on ADR-0008 Slice 3 fleet provisioning, which would otherwise have minted N workers carrying one site's identity.
The original entry is preserved below for the reasoning, which generalises (a stale build artifact that nobody asserts, carrying authoritative-looking wrong data).
~~The baked seed carries the WRONG SITE's identity — WCP's admin says "garvanbay"~~ (2026-07-13 pt3 — original entry, now closed above)¶
apps/cms/.emdash/seed.json (the seed the worker bakes at build time via
virtual:emdash/seed) holds meta.name: "garvanbay" / settings.title: "garvanbay".
The WCP worker was built while that stale artifact was in the tree, so WCP's emdash
admin runs under garvanbay's name — the annoyance Cathal reported.
It is not cosmetic. ADR-0008 Slice 2 recorded this exact failure once already ("the
fresh WCP D1 first auto-seeded garvanbay content") and fixed it with
scripts/emit-seed.mjs, which emits .emdash/seed.json for the active SITE. But
nothing asserts that the baked seed matches SITE, so the artifact silently rotted
back to whichever site was built last. Today it is harmless only because WCP's D1 already
has content and emdash's auto-seed doesn't fire. Provision a fresh tenant while that
file is stale and it seeds the wrong client's site.
Same disease as this session's other three findings (stale capture, hand-written SQL, one-site media-pool contract): a stale build artifact that nobody asserts, carrying authoritative-looking wrong data.
Fix (in the rebuild scope): emit-seed.mjs runs per SITE, plus a build-time
assertion that .emdash/seed.json#meta.name === SITE which refuses to build
otherwise (the check-renderers shape — a build that ships the wrong tenant's identity
must not be possible). Reopen trigger: already open — it is a precondition of the
clean-room rebuild (scope), and a hard
blocker on Slice 3 fleet provisioning (ADR-0008), which would otherwise mint N workers
with one site's identity.
WCP asset debt — 45 of 46 Wix videos never migrated (2026-07-13 pt3) — MEASURED + GATED 2026-07-14 (Slice 1c) — SEGMENTED 2026-07-15: all 27 have measured section homes; remaining work is RENDER, ranked¶
Segmentation update (2026-07-15). The "which section carries which video" half is done:
capture schemaVersion 7→8 attributes every mediaRefs video to its deepest containing section
(the join that fires is the poster id, not the mp4 URL — see known-patterns "A video's
SECTION HOME is its POSTER id"), and lib/video-segmentation.js joins the ids through
structural-diff#alignPage (so "matched"/"dropped-live" agree with S and C by construction)
to the build sidecar's block names. Report: builds/waterfordcountypainters.ie/video-segmentation.json
— 27/27 ids attributed, 0 unattributable; garvanbay v8, 0 videos, faithful no-op. The
ranked render backlog:
| home | videos | render work |
|---|---|---|
fcr.gallery (4 gallery-page blocks) |
13 | RENDER + PRODUCER SHIPPED 2026-07-15; ACQUISITION BLOCKED (VOD) — see below |
fcr.about (service pages, 1–3 per section) |
11 | live paints a player inside the content section — one adapter/block decision |
fcr.hero (home) |
1 | hero-4 already has video machinery |
| dropped-live (home FAQ section) | 3 | ~~blocked on the home-FAQ heading-EXTRACTION bug~~ — that bug is fixed 2026-07-16 pt2; these 3 are VOD like the rest, so they are behind the acquisition wall, not behind a render or extraction gap |
The originalNameOf precondition (the entry this file used to carry: folder-label Misc as
provenance for 19/46 videos) is closed — filename-grade gate + loud null shipped 2026-07-15;
acquisition of the 26 is unblocked once each bucket's render home exists.
Gallery bucket (13) — render/producer SHIPPED, acquisition BLOCKED on Wix VOD (2026-07-15)¶
The gallery-video slice landed the whole CODE path, A/B-proven zero-regression and reproducible-from-capture, but did not deploy because acquisition hit a real stop:
- Shipped (committed, not deployed):
videosmultiline field onfcr.gallery(schema);makeStyleLookupemits each gallery section's attributed video refs, freshness-gated on the v8mediaRefs.sectionAttributionkey; transformer stampsfcr.gallery.videos;gallery-wcp.astrorenders a<video controls>strip below the image grid (inert when absent);Gallery.astroadapter parses the lines field;migrateGalleryVideoshook intransform-seed-images.mjs. A/B (change stashed vs applied,_key-normalized): garvanbay 6 gallery blocks gainvideos:""+ zero other diffs; WCP's 4 gallery-page category blocks carry exactly the 13 attributed refs (Commercial 4 / Residential 3 / Shed repair 2 / Misc 4), every other gallery blockvideos:"". CMS build compiles; check-renderers passes. - BLOCKED — the 13 videos are Wix VOD "channel videos", not Media Manager assets. 0 of 13
appear in the Media Manager pool (
mediaPool, 46 videos) by any id form; the live player streams them as tokenized HLS from_api/vod/public/lists/<channelId>(secure_video,source_url: null) via_api/v1/access-tokens→repackager.wixmp.com/.../master.m3u8?token=. Captured mp4-shaped URLs 400/403 on direct fetch (token required, short-lived). The ADR-0010 by-id API path (Media Manager) cannot reach them. See known-patterns "A Wix gallery's VIDEOS are VOD-channel HLS, not Media Manager". - No dead refs shipped. The seed graft (raw
/assets/videos/refs) was reverted; the committed WCP/garvanbay seeds are unchanged, so no<video>with a dead src ships.videoDebtis UNCHANGED this session (gallery shortfall still 13) — the machinery is banked, the number moves when acquisition lands.
Reopen trigger (own slice + ADR-0010 amendment): build the VOD acquisition pipeline —
enumerate _api/vod/public/lists/<channelId> per gallery channel, mint a per-video access token,
download the HLS stream and remux to mp4 (ffmpeg), upload to R2 with derived provenance (VOD list
items may carry a title), then graft the migrated videos refs into the committed WCP seed,
redeploy the worker, and re-capture ours to confirm videoDebt gallery shortfall 13→0. The
channel ids are in the network trace (9ba179c0…, 4cd95f58…, + 2 more). Fold VOD in as a THIRD
Wix media surface alongside Media Manager images/videos (ADR-0010).
BLOCKED AT SOURCE — measured 2026-07-16 pt2. Do not build the pipeline above until this clears. The plan presumes the VOD API answers. It does not: every
_api/vod/.../lists/<channelId>and.../play/<id>call on live WCP returns 403 "Internal server error of auth middleware", zero HLS/repackager/.tsrequests ever fire, and zero<video>elements ever mount. The widget still paints its poster + a "Play Video" button, so the page looks video-bearing; the 403s are in the console. Reproduced 5 ways — headless and headed, default and real Chrome UA, cookie consent declined and accepted, all 4 category tabs — so it is not headless detection, not consent, and not a mis-aimed probe (each of those was a live hypothesis first; see known-patterns "Before scoping an ACQUISITION pipeline, measure that the SOURCE still serves the asset").This also contradicts the 07-15 trace recorded above ("the player enumerates them from
lists/<channelId>… items carrysource_url: null") — that describes the endpoint answering. Either the VOD app broke in the intervening day, or that trace's context differed. Not resolvable from here.Scope is wider than the gallery:
roof-painting(thefcr.about×11 bucket) fires the sameplay/call and gets the same 403, and none of its captured ids are in the pool either. ~24 of the 27 missing videos are behind this one wall. Only the hero (×1, Media Manager, already migrated) is unaffected.What IS now known (so a future session need not re-derive it): the channel ids are not in the rendered markup (
channelId/listId: absent) — they appear only in the player's network requests, which a capture-plane probe can intercept. Full values, from today's trace:9ba179c068494c0c8d7bcbc585393c8cand4cd95f58005b4a48a57a1e3f133e20ab. ffmpeg 8.1.2 is on the box. That producer is buildable and currently worthless.Reopen trigger: Cathal is checking the Wix Video app's status on the WCP account (2026-07-17) — a lapsed/deauthorised app instance fits both the API 403 and the CDN 403 below. If it comes back: re-run the playback measurement FIRST, then Slice F. If it does not: the videos are unacquirable by any route we own, and the honest move is to re-scope
videoDebt(see below), not to keep engineering around a dead source.And it puts a question mark over the veto:
videoDebtcounts videos live references. If live cannot serve them to a visitor either, a replatform that omits them may be faithful to what a visitor sees. Settle that when the app's status is known — do not relax the veto first.
Slice 1c update. The "next move" below (harvest what live actually references) shipped as the
capture's page-level mediaRefs probe (schemaVersion 7, rendered-DOM video refs) + lib/asset-debt.js.
Measured: live WCP pages reference 27 distinct video ids (so of the pool's 46, 19 are library
cruft — the split this entry said was never done); our deployed pages reference 1 (the migrated
hero). Site shortfall 26–27 across 8 pages (gallery 13, home 4, roof-cleaning 3, …), and the
videoDebt veto now HOLDs WCP on it with the id list as the punch-list.
Correction (2026-07-16): "of the pool's 46, 19 are library cruft" is false. The 19 is
46 − 27, and that subtraction only means something if the 27 referenced videos are a subset of the pool's 46. Re-measured: the intersection is zero — the sets are disjoint. All 27 referenced videos are VOD (absent from the Media Manager pool by every id form, under a much wider net than the original three: every string at any depth in every pool record, the bare 32-hex tail, plus a raw substring sweep). All 46 pool videos are unreferenced. So the number described nothing real, and it was read forward as if it did.Why it was believable, and the tell that was already written down: the shortfall itself (live references 27, ours serves 1) is correct and still stands — the veto is sound. Only the cruft gloss on top of it was arithmetic with no join underneath. And this very entry already recorded that the hero "does not appear in the pool in the capture's id form" — i.e. the one video known to be in the pool also failed the join. A join that misses its positive control is not a join. (The hero is in the pool as
8b4be4_a0c791484f4b492ca396ec695d175f1a; it reached R2 via its poster id, not its video id — the capture's video refs and the pool's ids were never comparable spaces.) See known-patterns "A set-difference is not a number until you have measured the INTERSECTION". The harvest is a floor, loudly — 0<video>elements mount pre-interaction on live, so the ids come from the markup tier (player configs), and a JS-only config would be invisible on both sides.
Cathal spotted a video on live WCP (VID-20251217-WA0003) in the "Waterford County
Painters" section and again in the FAQ section that we don't render. Measuring the
media pool (with the fixed mediaPool union — see ADR-0010's second amendment):
| site | pool files | videos | videos migrated |
|---|---|---|---|
| waterfordcountypainters | 393 | 46 | 1 (the hero slide, ADR-0010 slice B) |
| garvanbay | 41 | 0 | n/a |
So this is WCP-specific, and it is 45 files — not a portfolio-wide fault. garvanbay has no videos at all, which is why the gap never surfaced on our SHIP-grade reference.
Root cause is segmentation, not acquisition. The API has every video; the scrape never surfaces them, so ADR-0010's by-id join has nothing to join to. Pulling all 46 to R2 would be motion without progress — they still wouldn't render, because nothing knows which section carries which video.
This is also the at-source-never-pulled: 298 number finally cashing out. That
bucket was filed as "expected for a real media library (old uploads, alternates)" with an
explicit warning that it was not yet separated from "used on live and our scrape
missed it". It never was. The videos are the proof that the unseparated number was hiding
real debt.
Next move (in the rebuild scope): harvest which assets are actually referenced on
live (rendered DOM, not the static crawl — these are JS-mounted) and split
at-source-never-pulled into "used on live, we missed it" (real capture debt) vs
"unused library cruft" (expected). That count is the ranked backlog. Only then decide
whether video segmentation is a capture-plane slice.
asset-manifest.json is a PRE-migration artifact — never gate on it (2026-07-14, Slice 1c scoping)¶
Measured while scoping the ADR-0011 1c asset veto: seed-from-build.js writes
builds/<domain>/asset-manifest.json before transform-seed-images migrates refs, so the
manifest reports the regen seed's raw state — garvanbay (the SHIP-grade reference) shows 63 raw
refs + 47 media-missing flags while its deployed site is fine. A veto on this artifact would
false-HOLD every site, always. Its "expected" side is also the per-section capture assets.videos
count, a lazy-mount floor that reads 0 across all 25 WCP pages while live visibly plays video —
so the manifest can neither see the video debt nor be trusted on migration state.
It remains useful as a seed-regen-time triage report (expected-vs-supplied per block). The
veto-grade instruments are the post-migration asset-migration-gaps.json (supplied-but-dead →
deadAssetRef) and the v7 mediaRefs harvest (live-referenced-but-unserved → videoDebt).
Fix candidate — UNTESTED HYPOTHESIS: have transform-seed-images re-emit the manifest
post-migration so its unmigrated list is honest; nobody needs that today, so it is filed, not
planned. Reopen: if anything ever reads asset-manifest.json for a go/no-go decision.
~~fcr.gallery HAS NO TEXT FIELD — every site's gallery intro copy is destroyed~~ — CLOSED 2026-07-14 pt4 (the fix was smaller than the filing: the matcher and the canonical block never needed touching)¶
The entry's prescription said "schema → transformer → adapter → block"; measured against the code,
half the chain already worked. Gallery.js#extract had emitted the intro as subtext all
along (first 30–300-char <p>), and the static lane rendered it (translateGallery puts it in
the slot). The destruction was CMS-lane only: fromGallery never read subtext, the schema
had no field, the adapter passed nothing. gallery-wcp.astro needed zero change — its
existing <SectionProse><slot/> renders the prose exactly as cta-wcp does.
Shipped (worker 1a9d6a45): body on fcr.gallery (text_input, multiline — the
fcr.cta-strip.body shape verbatim), fromGallery emits body: props.subtext, Gallery.astro
renders it via paragraphsFromText. Landed on live D1 by surgical sync-style-to-d1 stamp (1
block patched, 12 preserved — never a reseed); committed seed patched. A/B regen proof: WCP 13
diffs all fcr.gallery.body (home = the 202-char intro, rest ""); garvanbay 6 diffs all
body: "" — render-inert, not redeployed. Re-measured on the deployed worker:
contentDestroyed veto GONE; C = 100 (0/145 destroyed); WCP HOLDs on videoDebt alone.
S/T/G_det unchanged (82.2/100/84.4 — correct: the veto was the instrument, not the score).
Split-continuation galleries (SPLIT_TRANSLATORS path) get no subtext by construction — the
text belongs to their first half, so an empty body there is faithful, not a gap.
~~tone is an INERT LEVER on cta-wcp~~ — CLOSED 2026-07-14 (Slice 1d: the audit found FOUR dead levers in THREE mechanisms; all fixed, allowlist empty)¶
The entry below was right, and it under-counted. The lever audit (ADR-0011 1d,
apps/cms/scripts/lever-audit.mjs) convicted four real dead levers on its first run:
| lever | mechanism |
|---|---|
cta-strip.tone |
inline background: beats the tone rule (this entry's case) |
checklist.tone, team-grid.tone |
schema declares it, canonical honors it, the adapter never passed it |
blog-posts.tone |
a scoped (unlayered) block style beat the layered tone utilities |
All four fixed the same day; cta-wcp paints a SET tone via Section's CVA (solid brand — faithful to
live's solid #1e73be, and probe-visible) and keeps the legacy gradient for UNSET (unmeasured must
not change). The audit is a durable validator — the lever matrix derives from the schema, so a new
select field is audited automatically, and a lever that dies again fails it loudly. Re-measured on
the deployed worker: the 14 ribbon tone + 5 content tone systematic rows are gone (38 → 0
total flagged). See known-patterns "The lever audit".
Historical entry:
packages/components-v3/src/components/blocks/cta-wcp.astro accepted tone, passed it to
<Section tone={tone}>, and then hard-painted an inline background — an inline background
shorthand beats any tone-driven stylesheet rule. All 19 WCP fcr.cta-strip blocks carried the
CORRECT measured tone: "brand" and it never reached the paint. It would also have silently
defeated any config-writing agent (set tone=brand forever, each one logged as an applied fix —
see ADR-0011).
imagesDropped is representation drift, not loss — STILL not gated after the Slice-1c probe (2026-07-14; measured same day)¶
The C axis reports imagesDropped (10 on WCP, 34 on garvanbay) but deliberately excludes it from
siteC and from the gate. Included, it scored garvanbay 47.7 vs WCP 92.7 — ranking the
SHIP-grade reference below the site its owner rated 4/10.
Probing the live DOM (rather than assuming a detector bug) showed why: Wix paints a full-bleed
section background as an <img>; our Astro output paints the same image as a CSS
background-image. Same picture, different mechanism. Same class as the columns over-fire above.
Slice 1c shipped the probe and measured the outcome (2026-07-14). The capture census now counts
CSS background-images (content.bgImages, schemaVersion 7) and content-diff compares
images + bgImages when both sides carry the field (never on a mixed v6/v7 pair — asymmetric
counting is the exact bug being fixed). Result: garvanbay 34 → 14, WCP 10 → 11. Mechanism 1
(the bg-image spelling) explained ~20 of garvanbay's 34; the residual 14 is mechanism 2 — anchor
drift redistributing inline images between adjacent sections — which no census change can fix.
Per the slice's pre-registered criterion ("gate only if garvanbay collapses to ~0"): it stays
reporting-only. Reopen: if a genuinely image-dropping producer bug lands, it WILL still show
here (the class is real); gate-ability now depends on aligner anchor quality, not on the census —
same root as the "role-aware anchoring" aligner item above.
~~The scorecard scored 79.3 MANUAL-POLISH on a site the operator rated 4/10~~ — PARTLY CLOSED 2026-07-14 (ADR-0011 Slices 1a + 1b)¶
The diagnosis in this entry was WRONG, and correcting it is the whole of ADR-0011. It said the scorecard is "structurally unable to see" the defects. It mostly could — it measured them and threw them away:
variantMismatch.count (the HEADLINE) : 0
variantMismatch.items (the DETAIL) : 38
x19 faq tone: default → brand <- the owner's "the FAQ is the wrong colour"
x14 ribbon tone: brand → default
x 5 content tone: brand → default
The FAQ defect was measured nineteen times and reported as zero. Cause: critical is tagged iff
ROLE_WEIGHT >= criticalRoleWeight (1.3) — but ROLE_WEIGHT is a visual-importance number
(faq 0.75, ribbon 0.5, content 1.0), so those roles could never be critical by arithmetic.
Criticality and visual weight were two concepts sharing one number. This was a gating and
aggregation failure, not a perception failure.
CLOSED by 1a (systematic veto — role-agnostic, fires on a broken producer) and 1b (the C
axis — content destroyed inside a matched block; verify-content.js already existed and had never
been wired in). Both reference sites now HOLD, for real, already-documented reasons.
The blind spots that REMAIN (the original table, corrected):
| defect class | status |
|---|---|
| intra-block content destroyed | CLOSED (1b) — contentDestroyed veto; found fcr.gallery on run 1 |
| fabricated links | CLOSED (1b) — fabricatedLink veto (0/16 on WCP: the negative control passes) |
| systematic mis-render (tone etc.) | CLOSED (1a) — and it was never invisible, only un-gated |
| chrome PALETTE | CLOSED (1c, 2026-07-14) — the v7 topbarStrip probe measures the strip on BOTH lanes (the old dims measured Wix's zero-height container vs a Wix-only selector — two blind probes whose false === false was scored as agreement); globals-diff gained a palette tier over hexes; any positive chrome disagreement now vetoes (chromeDisagree). Both reference sites genuinely compare and genuinely agree; the cyan case is a locked fixture. |
| assets | CLOSED (1c, 2026-07-14) — videoDebt veto from the v7 rendered-DOM mediaRefs harvest (not the pre-migration asset-manifest, which would false-HOLD every site — see the entry below): live WCP references 27 videos, ours 1 → WCP HOLDs. Plus deadAssetRef from the post-migration gaps sidecar. |
| inert levers | CLOSED (1d, 2026-07-14) — the lever audit (schema-derived matrix, computed-signature assert) found four dead levers in three mechanisms; all fixed, allowlist empty, and the audit is a durable validator. The FAQ ×19 rows were the mirror image (working lever, wrong value via a dishonest join key) — also fixed. Variant mis-renders 38 → 0. |
The most important finding of the clean-room rebuild, and it is about the instrument, not
the site. A fully rebuilt WCP scored S 72.5 / T 100 / G_det 72 → composite 79.3, no
vetoes, globals 7/7 agree, 0 loud-fail placeholders on all 25 pages. Cathal opened the
deployed site and called it 4/10, listing four defects — every one of which the
scorecard is structurally unable to see:
| defect (all real, all now fixed or filed) | why no axis could see it |
|---|---|
| topbar rendered in another brand's cyan | header/topbar/footer are excluded from the scored axes by construction |
globals reported the topbar as agree |
live null vs ours null — a null-vs-null match checks nothing |
| 4 icon+text cards rendered as a bare logo strip, text destroyed | the section is present on both sides, so S counts it matched; content lost inside a matched block is invisible |
every nav item linked to / |
links/chrome are not an axis at all |
| 45 of 46 videos never migrated | asset reconciliation is reporting-only and gates nothing |
The instruments were green because they measure section presence, tone and coarse perceptual similarity — not whether a block says what live says. "200 OK, zero placeholders, globals agree" was reported as success over a visibly broken page.
This is not a threshold-tuning problem. The blind spots are enumerable: chrome, intra-block content, links, assets. Until they are closed, a scorecard verdict means "no structural regression", not "this site is good" — and it must never be reported as the latter. See known-patterns "The near-match scorecard is BLIND to content destroyed INSIDE a matched block".
Reopen: this is Slice 6 work and arguably precedes calibration — fitting thresholds on axes that cannot see the dominant defects fits the wrong model. Candidate additions: an intra-block field-level diff (does the matched block carry live's text/items?), chrome into a scored axis (not just a reporting projection), and a link-integrity check (every nav/CTA href resolves to a page that exists and is not the homepage by accident).
The 7-Year Guarantee ribbon's icons are inline Wix SVG vector art — no CMS home (2026-07-13 pt4)¶
Distinct from the icon cards (fixed pt4 — see known-patterns "One emitter, two item
shapes" correction). WCP's live guarantee ribbon renders three inline <svg> icons
(measured 32×45, 45×34, 45×45 at y≈180) — Wix vector art, not client-uploaded images. The
USPBar matcher only looks for <img>, finds none, and emits icon: ''; the band renders
text-only.
Why it is not a mechanical fix: copying Wix's vector art into our output is the same IP
problem as parastorage CSS (memory no-wix-css-copy) — the images on a Wix site belong to
the client, but Wix's stock vector art does not. The alternatives are (a) map each SVG onto a
named lucide glyph by semantics (7-Year Guarantee → award/badge, Fully insured →
shield/heart-pulse, Reliable & Long Lasting → refresh) — a design decision, and a guess;
or (b) accept a text-only ribbon.
Note the near-miss: a probe reported this band as "text only, 0 imgs, 0 svgs" — because it
filtered images to those with a non-empty alt, and these have empty alts. The operator's
screenshot was the thing that corrected it. Reopen when the ribbon is next worked, or when
a second portfolio site measures SVG icons — one more site tells us whether (a) is worth it.
The home page drops the client-logo strip entirely (2026-07-13 pt4)¶
Live WCP's home carries an 11-logo client strip (Tobin & Cantwell, Our.ie, Terra Spirits,
Portarlington RFC, Hennessy Feeds, Coffey Engineering…) at y≈1139–1223. The rebuilt seed has
no fcr.logo-strip on home at all — though the service pages each carry theirs (11
items) correctly. Pre-existing (the pre-rebuild seed had the same); surfaced only because the
pt4 USPBar re-route made the home block list legible. Not caused by the re-route: the real
logo strips still route correctly. Reopen with the matcher/insertedOurs work below.
~~Structural debt the full-site scorecard surfaced~~ — insertedOurs 51 → 19, FIXED 2026-07-14 pt5 (it was FOUR bugs, and the biggest was the instrument)¶
insertedOurs: 51 was filed as one defect ("we over-produce sections; likely the responsive-double
re-match"). It was four, and more than half of it was never real:
isJunkwas deleting 42 REAL live sections (27 of the 51). Wix injects a per-comp SVG colour rule into ordinary content bands, and the style-leakage rule condemned the whole section on sight — so live's real "Our Commercial Repair Services Include:" band (and its equivalent on every service page) was filtered away, and our faithful copy of it scored asinsertedOurs. The function's own comment promised "a genuine section is never removed"; that rule was the only one of the three not heading-gated. Now gated. A pure Wix<style>pseudo-section has no heading and is still filtered.- Nested Wix wrapper doubles (23 blocks). Real render defect:
/commercial-repairsprinted "Commercial Repairs" three times (hero + cta-strip + about) where live prints it once.dedupe.jspasses 1–2 key onmatcher.name, so a double whose halves matched different matchers was invisible to them. New pass 3 collapses on content signature + DOM ancestry (an outer<section>wrapping an inner one with identical text is a wrapper, not a second band — so a legitimately-repeated band, never nested inside its own twin, cannot be eaten). WCP 287 → 264 blocks; garvanbay 71 → 71, zero delta; all 7 garvanbay Hero re-matches still fire.Correction to the entry "Re-match over-produces on Wix responsive-doubles" (below): its stated mechanism is WRONG. The doubles are not adjacent siblings, and the Hero re-match is not what splits them — measured, they are nested ancestor/descendant pairs (67/67 on WCP, 20/20 on garvanbay, zero siblings), and on
/commercial-repairsthe re-match never fires at all: the inner half matchesCTAStripnatively, becausectx.positiondiffers between halves andHerogates onposition <= 1. A sibling-adjacency collapse "after the re-match" — the fix that entry prescribes — would have matched ZERO pairs. The conclusion (collapse on content signature, not matcher name) was right; the mechanism was invented. - A FALSE
criticalSectionDroppedveto. Removing the 23 duplicates took away the spare block that had been absorbing an aligner mis-pairing, and five service pages flipped to HOLD — while all five demonstrably render<h1>+ subtext + CTA. The alignment is 1:1 and heading-similarity-driven: live carries two rows bearing the page title (an uncollapsed hero double), our single hero pairs with the exact heading match (a content row), and live's hero row is left "dropped". The site got better and the verdict got worse. Fixed inscorecard-gate.js: a critical role is only dropped if our page hasn't got one (CRITICAL_ROLE_PRODUCER). Fails closed — a genuinely absent hero still vetoes, and with no build sidecar it still vetoes. +10 checks (19 → 29). - One real content loss, unmasked by fix 2.
/thank-you-contact-formlost "We'll respond to your request shortly." —Hero.extract()sources subtext only from$el.find('p, h4'), and that band is<h1>+<h2>with no<p>. The phantom duplicate had been accidentally surfacing it: a bug was masking a bug. Fixed with a fallback to the next heading level, gated on SINGULARITY — a subtitle is one line; several same-level headings are an item list (ungated, it hoists a trust-badge item, "Fair Pricing", into/index's hero). Measured: fills exactly 1 WCP hero, changes 0 on garvanbay.
Result (deployed, worker ee71e36c): insertedOurs 51 → 19; S 72.5 → 78.1; G_det
72 → 78.6; composite 79.3 → 83.7; MANUAL-POLISH, no vetoes.
droppedLive rose 15 → 30, and that is HONEST — the junk filter was hiding real drops behind
a "junk" label, including 4× "Watch Now" (the video sections). See the warning below.
Residual, not chased: the hero: dropped 5 rows still count against S even though the veto
correctly no longer fires — the aligner needs role-aware anchoring (a hero row should prefer a
Hero-produced block over an exact-heading content row). That is the same aligner weakness, one
layer down, and it is worth a slice.
⚠ Do NOT widen isJunk to swallow the "Watch Now" strips (2026-07-14 pt5)¶
The metric-side follow-ups above suggest "isJunk misses WCP's heading-bearing Watch Now
video-chrome strip … widen the heading-gated regex". Don't. Those 4 sections (263px tall,
/index, /commercial-cleaning, /commercial-repairs, /roof-cleaning) are Wix video
widgets — they are the very videos Cathal reported missing (45 of 46 unmigrated). Filtering them
would hide the video debt inside the junk filter, which is exactly the failure this whole
sequence exists to eliminate. They must stay visible as drops until the videos render.
~~FAQ renders on navy where live is white — 19 of 25 pages~~ — CLOSED 2026-07-14 (Slice 1d: a WORKING lever fed a WRONG value by a dishonest join key)¶
The navy was tone: "brand" stamped on all 20 fcr.faq blocks — and the lever worked perfectly;
the STAMP was wrong. Producer: the FAQ blocks' source comp-id is a Wix wrapper that spans both
the cta ribbon and the FAQ band; the live capture enumerates that id as the (headless) cta band,
so the id-join copied the ribbon's brand/#1e73be onto every FAQ, while the capture's actual FAQ
sections measured white all along. Fixed with a heading/textHead-consistency guard on
makeStyleLookup (see known-patterns "A comp-id join is only as honest as Wix's WRAPPER
STRUCTURE"); 19 FAQs re-stamped default/left (surgical sync-style-to-d1 + committed-seed
patch). Re-measured: G_det 78.5 → 84.4. ~~The 20th (home) is blocked on the heading-extraction
bug below.~~ The 20th CLOSED 2026-07-16 pt2 — the FAQ heading fix
(headingBeforeAccordion, lib/matchers/FAQ.js) let the Slice-1d join guard pair home's FAQ with
its real capture section, so tone: brand → default / _bg → #ffffff / align → left fell out of
the same regen. All 20 WCP FAQs now carry the measured tone; deployed home renders white
(independently re-captured). It also unmasked the cta-band segmentation gap — see above.
The home cta band has NO BLOCK — one Wix section holds two bands, we emit one (2026-07-16 pt2)¶
Surfaced by fixing the FAQ-heading bug (below), which had been masking it. Wix puts WCP's home
cta ribbon and its FAQ band inside one <section> — comp-lyzpdyil plus its nested twin
comp-lyzscb43, carrying two h2s ("Ready to transform your property? Call or email us
anytime; our team is available 24/7." and "Waterford County Painters FAQ"), one accordion, and no
boundary between them. There is no separate <section> for the cta band, so buildSections
enumerates one section, one block (fcr.faq) is emitted, and the cta band's copy has no home.
Before the heading fix the copy was rendering — as the FAQ block's heading, in the wrong band, in
brand navy. It was only ever visible by squatting in a slot that belonged to something else, so
correcting the FAQ made a real, pre-existing loss visible ("a bug was masking a bug" — the same
shape as /thank-you-contact-form on 2026-07-14). Not a regression: the A/B regen proves the
cta-strip's heading was "" in both base and applied; only the FAQ block moved.
Measured consequence: home's S 81.6 → 79.6, because the drop row honestly re-attributes
from faq (weight 0.75) to content (weight 1.0) — the live row is verbatim
{kind:"dropLive", role:"content", weight:1, heading:"Ready to transform your property?…"}. The
lower number is the true one; do not reconcile it by touching thresholds. See known-patterns "An
honest re-attribution can LOWER a score".
Note what this is NOT: not a heading-extraction bug (that is fixed), and not a matcher-scoring
bug. It is segmentation — one source section must become two blocks. The existing precedent is
the SPLIT_TRANSLATORS path (translateAbout splits a 3+-image About into content-1/2 +
gallery-wcp; the CMS transformer dispatches continuations on entry.rendered.component). Whether
the split belongs in the FAQ matcher, in buildSections, or in a translator is undecided and
deliberately not guessed at here — measure which sections portfolio-wide carry a two-band wrapper
before picking, since the answer sets the blast radius.
Reopen trigger: the next FAQ/CTA or segmentation slice; or the first other site measured with a
two-band wrapper (one more site tells us whether this is a WCP quirk or an FCR-template pattern,
which is the difference between a matcher special-case and a buildSections change).
MEASURED 2026-07-17 — the SHAPE is 2/2, the DEFECT is 1/2, and buildSections already has the precedent¶
scripts/measure-two-band-wrappers.js (read-only; runs the real seed lane —
buildSections → matchAllSections → dedupeLayoutMap) over the whole local corpus:
WCP 25 pages + garvanbay 10 pages = 35 pages, 326 surviving sections. Report:
builds/two-band-wrappers.json.
| WCP | garvanbay | |
|---|---|---|
| sections with ≥2 band h2s | 2 | 1 |
| two-band wrappers (copy orphaned) | 1 (index §comp-lyzscb43) |
0 |
The corpus does NOT settle quirk-vs-pattern, and the reopen trigger above should not be read as fired. n=2 with 1 hit is 50/50; ADR-0010's second amendment is the standing warning against promoting a one-site measurement to a contract. What the measurement did settle is more useful:
Both sites carry the two-band SHAPE, on the home page, both times paired with FAQ. garvanbay's
home FAQ section comp-l60lrz7n holds its accordion and a 3-card article-teaser band nested in
the same <section> (verified: 1 post-list-pro-gallery node inside it). It loses nothing —
because buildSections enumerates that band through its dedicated [class*="post-list-pro-gallery"]
branch, so BlogPosts#blog-16 emits it. WCP's cta ribbon has no such branch, so its copy has
nowhere to go.
So the variable is not whether Wix bundles two bands into one section — it does, on both sites.
It is whether the second band has an independent enumeration path. That is a direct steer on
the undecided fix location: buildSections already solves this exact problem for one band type,
which is evidence for a buildSections-level branch over a FAQ-matcher special-case. Not acted
on — the ADR-0009 §"reconciliation principle" wants the blast radius first, and one confirmed
instance does not set it.
Reopen (unchanged in kind, sharpened): the measurement is now one command, so run it on the
next site whose crawl lands locally — node scripts/measure-two-band-wrappers.js --all. A second
site with an orphaned band promotes this from anecdote to pattern and picks the fix.
Three instrument faults, each of which produced a confident wrong number first (all three are why the script counts what it counts — see known-patterns "An h2 is not a band"):
≥2 distinct h2sflags card bands. WCP's 4-card USP ribbon and garvanbay's 3-card teaser feed each mark every card titleh2. DOM shape cannot separate them from the real defect. The discriminator is not structural — it is whether the copy survives into a block's props.- Section-scoped orphan-checking INVENTS the defect. It reported garvanbay as a two-band
wrapper: the teaser h2s are genuinely absent from the FAQ block's props. They are carried by
BlogPosts, a different block on the same page. Ask the page, not the section. - Bidirectional substring matching HIDES the defect. With the haystack widened to every block,
n.includes(p)lets any short prop ("24/7","Call") sitting inside a long h2 mark it homed — WCP's known orphan flipped took. Containment must be one-way (p.includes(n)).
Fault 3 was caught only by the positive control (the known WCP case must be detected). Faults 1 and 2 each made the number larger; fault 3 made it zero — the flattering answer, and the one that would have closed this entry as "not a real problem".
Not blocking the videos any more: the 3 videos the old FAQ entry said this would unblock are VOD, so they are behind the acquisition wall regardless (see the gallery bucket above). That half of the old entry's double-motivation is inert until the Wix Video app question is settled.
The per-site static lane's nav is still URL-prefix-grouped (2026-07-13 pt4)¶
The nav capture-plane fix (chrome.navRaw → classifyNav → seed overlay) shipped on the
CMS lane only. lib/assembler-fulldev/translate.js still groups nav via
groupNavByPrefix() — a heuristic that provably fails on WCP (RESIDENTIAL's children —
roof-cleaning / roof-painting / dry-verge-system / tarmac-restoration — share no URL prefix).
header-wcp.astro is backward-compatible with both, so nothing is broken; the static lane just
keeps the worse grouping. Reopen with the first per-site static build intended for launch
(same trigger as the noindex blocker).
Every other site's capture is now one schemaVersion stale (2026-07-13 pt4)¶
The nav probe bumped the capture schemaVersion 4 → 5. By design (ADR-0009's
capture-staleness guard) every existing section-backgrounds.json in the fleet now fails the
nav dimension's requirement and is skipped with a loud warning rather than stamped from a
fabricated null. garvanbay is in exactly this state and its regenerated header items are
byte-identical to the committed seed — zero-regression, working as intended. Any site
wanting the nav fix must be re-captured. This is the general rule, not a defect: a new in-page
probe makes every existing capture artifact stale.
CMS multi-tenant fleet (ADR-0008)¶
emdash upgraded 0.9.0 → 0.28.1 (2026-07-09) — platform current; save race persists upstream¶
Both deployed workers now run emdash/@emdash-cms/admin/@emdash-cms/cloudflare
0.28.1 (exact-pinned — the three are lockstep; garvanbay worker 3ee7bfd5,
WCP 72c15db9). What the upgrade required and proved:
- D1 migrations 036–050 applied automatically at first boot on BOTH live
databases (verified
MAX(name)=050, content intact). Rehearsed first on a local miniflare copy of the live garvanbay export. Pre-upgrade recovery anchors: Time Travel bookmarks garvanbay00000186-…-800f5abd29a0dea59661fbfdc4b65a3d/ WCP0000006c-…-c87d915…f3a, plus SQL exports atbuilds/<domain>/d1-pre-0281-export.sql(gitignored). NOTE: the original 036 (0.10–0.12) had a CASCADE data-loss bug — never boot those versions against a live D1; jump ≥0.13 (we got the fixed 036). - Worker contract (0.19.0 break):
src/worker.tsis nowexport { default, PluginBridge } from "@emdash-cms/cloudflare/worker"andwrangler.template.jsonccarries"triggers": {"crons": ["* * * * *"]}— without both, scheduled publishing/plugin cron silently never run. images: falseset inastro.config.mjs— 0.22.0 wraps Astro's image endpoint by default to read from the storage adapter; opted out to keep the ADR-0005 public-R2-URL/_imagepath byte-identical. Revisit if we ever want emdash-managed media on the render path.- 1.0.0 is a trap, not the target: npm's
emdash@1.0.0is an accidental 2026-04-27 publish (upstream PR #796 reverted it same-day; CI now blocks 1.x;@emdash-cms/cloudflarehas no 1.0.0).latest= 0.28.1 is the head. - Block-Kit repeater sub-fields are still scalar-only (now type-enforced):
fcr.generic-section's repeaterimageUrlflippedmedia_picker→text_input(it never rendered as a picker anyway). Top-levelmedia_pickerfields (hero/about/cta/header) render correctly at 0.28.1. - Scorecard: SHIP held at composite 91.3 (S 88.5 / T 100 / G_det 88.3) — identical to the pt6 baseline; upgrade is render-neutral.
- The save race is NOT fixed — see the header/nav entry above (operator editability still blocked; repro to be filed upstream against #1158).
- Incidental:
playwrightis now a declared root devDependency (it had been an undeclared ad-hoc install, so the upgrade'snpm installpruned it and broke the capture lane until re-added).
Cross-account sharding unproven (Slice 2 done on one account)¶
Slice 2 proved isolation — garvanbay (emdash.dcathal.org) and
waterfordcountypainters (wcp.dcathal.org) run as two independent
single-tenant workers — but both live on the same personal account
(1ceb…). ADR-0008's scaling premise is ~1800 sites sharded ~450/account
across ~4 accounts; the cross-account deploy path (a second account's
wrangler auth/token, per-account API tokens, account-assignment bookkeeping)
is not exercised. Reopen when Slice 3 provisioning automation lands or
when a second Cloudflare account is brought online — whichever first.
Non-fatal No matching export for "default" build errors (pre-existing)¶
astro build (both garvanbay and WCP) logs 3 esbuild errors that do not
fail the build (it completes, deploys, and serves 200):
X [ERROR] No matching export in "html:…/fcr-blocks/Map.astro" for import "default"
X [ERROR] No matching export in "html:…/fcr-blocks/Prose.astro" for import "default"
X [ERROR] No matching export in "html:…/blocks/Features.astro" for import "default"
Component-import resolution, unrelated to the ADR-0008 seed/tenant work (surfaced while grepping the WCP build). Pre-existing — not introduced this session. Could mean those three blocks fail to render if actually used; verify whether they resolve at runtime before assuming benign. Not blocking Slice 2/3.
WCP image migration — history (the logo? placeholders moved to their own entry)¶
Context corrected 2026-07-10 pt4. WCP's committed seed had never been
image-migrated — every imageUrl was a raw /assets/images/… path that 404s on the
CMS worker (which serves images from R2). The images were already in R2 from an
earlier session; only the seed pointed at dead paths. This session ran
transform-seed-images.mjs (342 refs → r2:, 0 uploads — all idempotent) + reseeded
live D1, so WCP images now resolve fleet-wide (28 /_image URLs/page). The prior
claim here that WCP "renders images correctly" was wrong.
The logo? placeholders once filed here as "cosmetic, one strip, not chased" were
measured 2026-07-12 pt5 and are neither — see "WCP logo-strip — 276 loud-fail
placeholders" above.
The sibling-shape object-src crash is CLOSED. The migration writes repeater
item images (fcr.logo-strip/gallery/footer) as an object {src, originalName}
(the "unflattened item-image objects" — garvanbay's seed has 7 of the same). Rendering
these crashed the whole page: LogoStrip/Gallery/Footer adapters called scalar-only
resolveImageRef(item.src) → TypeError: ref.startsWith is not a function → SSR
truncated every WCP page. Fixed by switching those three adapters to the shape-agnostic
getImageSrc (the ADR-0005 ImageField contract — handles scalar AND object). This
also pre-empts garvanbay's latent version (its 7 objects would crash the same way once
surfaced/redeployed). The remaining data-hygiene question — flatten repeater item-src
to a scalar vs leave it object-shaped — stays open under the "repeater-item images"
workstream, but the render is now safe either way. See known-patterns "An item-image
adapter must resolve via getImageSrc, not resolveImageRef".
trimtech.ie specific¶
Insurance Industry Compliance — fabricated, not on live¶
Confirmed via Gemini placement check on 2026-04-12: the section does not exist on live. The "TrimTech Hire Fleet" item belongs in the homepage Service Grid, not as its own section. Either the matcher is mis-grouping a service-grid card into a standalone About-style section or the structure-matcher is picking up a hidden Wix container as a section. Needs investigation in assembler-v3.js buildSections() and the About vs ServiceGrid matchers.
Header nav single-row vs live's two-row split (subpages)¶
Live trimtech wraps the main nav onto two rows below the topbar. Our Header.astro renders a single horizontal row right of the logo. Affects every subpage. Header matcher / component change.
Hero text overlay leaks onto subpages¶
Subpages (e.g. about-us) render a Hero component with the slideshow text overlay. Live subpages have a clean image-only hero. Probably a Hero matcher false-positive on subpage hero strips, OR the matcher should detect "no slideshow nav present" and emit a no-overlay variant.
About column-image vertical alignment¶
Multiple About-style sections render with the image column starting visually below the text column instead of flush-top. Looks like a flex/align-items issue in About.astro .about__media. Affects every site, not just trimtech.
Service icons missing on subpage service item lists¶
Live subpages show a small icon next to each service description (Vehicle Body Repairs, Precision Paintwork, etc.). Our About matcher extracts the text but drops the icon. Matcher needs an icon-extraction step that walks for the closest preceding <svg> or <img> per item.
Pro Gallery capture misses last item (ServiceGrid card count)¶
capture-layout.js scroll-loads the Wix Pro Gallery and reads data-hook="item-title" nodes. On trimtech, live has 8 service cards but we capture only 7 ("Tyre & Alloy Wheel Specialists" missing). Either the scroll doesn't go far enough or Wix is lazy-mounting beyond our wait loop. Fix candidate — UNTESTED HYPOTHESIS: increase scroll distance / add a second wait pass for items that mount late. See lib/capture-layout.js Pro Gallery block.
Hero picks wrong slideshow slide heading¶
Wix slideshow has multiple <h1> elements (one per slide), all present in the DOM at all times. Hero.js picks the first, which may not be the visually active slide. Live shows "RELAX, WE'LL TAKE IT FROM HERE." but we pick "TrimTech Crash Repair & Bodyshop". Fix candidates — UNTESTED HYPOTHESIS: (a) enumerate all slide headings in capture-layout.js and pass them through as slideshowHeadings[] to cycle alongside images; (b) read which slide is active via the aria-hidden / display state in the captured DOM.
Core Services image sizing¶
User reports 3 visual images where we pass only 2. Likely causes: (a) the outer section's alt="background" image is bleeding through via section CSS; (b) About.astro .about__media flex column max-heights render at different proportions than live. Needs inspection in browser devtools on the deploy.
Pinned-layer children escape coverage¶
comp-mnpz99kc and comp-mn66d9ou remain uncovered on trimtech.ie homepage — they're children of a pinned layer that the matcher walker doesn't visit. Coverage reports them as misses. Fix candidate — UNTESTED HYPOTHESIS: extend section enumeration to walk pinned layer subtrees, or register a dedicated matcher for these pinned decorative comps.
Insurance Industry Compliance — on live?¶
Gemini placement check says the section is absent from live homepage. Either Gemini screenshots didn't reach that scroll depth or live really moved the section. Verify manually before "fixing" — may be a screenshot-range issue, not an extraction bug.
TopBar drops Mail/Phone/Location icons + "Dublin" location text¶
Live trimtech topbar has 6 icons on the right (Mail, Phone, Location, Instagram, Facebook, TikTok) and "Dublin" text on the left next to the email. Ours emits only Instagram/Facebook/TikTok and no location text. TopBar matcher needs to extract icon-only links (no platform regex match) AND short text labels next to the contact info.
Pipeline-wide¶
infra/llm-gateway/ — untracked LiteLLM scaffold, ownership undecided (2026-07-10)¶
The crashed 2026-07-09 pt3 session scaffolded infra/llm-gateway/ (docker-compose
+ litellm-config + README + .env.example, no secrets): a LiteLLM proxy giving
the pipeline one OpenAI-compatible endpoint routing to local Ollama models
(hermes3/gemma3) and DeepSeek's cloud API — presumably for cheap local
classification/triage. Config-only; nothing runs; nothing in the pipeline calls
it yet; Cathal doesn't recall commissioning it. Decide: commit it as infra, or
delete. Until decided it sits untracked (invisible to a fresh checkout).
Per-site static build hardcodes noindex,nofollow — launch blocker (2026-07-05)¶
lib/assembler-fulldev/emit.js:169 emits
<meta name="robots" content="noindex, nofollow" /> on every per-site
static page. Fine as a staging guard while the long-tail portfolio isn't
live — but the day those ~1800 replatformed sites go to production, Google
will refuse to index all of them until this is flipped. dom-pipeline.js
also injects the same noindex into the raw .head.html snapshot
(lib/dom-pipeline.js:611), so the guard is belt-and-braces.
Note: the CMS path is not affected — Base.astro emits no robots
meta, so CMS-served pages are indexable. This is per-site-static only.
Fix (own slice): env-gate the robots meta in emit.js (e.g. emit
index,follow unless a STAGING/NOINDEX flag is set), default to
indexable for production builds. Do not silently flip it — a wrong
default either deindexes live sites or exposes staging deploys to search.
Reopen trigger: fires the moment any per-site static build is intended for a public/production launch. Until then it's a correct staging guard, not a bug.
~~WCP logo-strip — 276 loud-fail placeholders~~ — CLOSED 2026-07-12 pt6 (it was a transformer MISROUTE, not a capture gap)¶
237 empty srcs → 276 ui-missing placeholders across all 24 pages → now 0.
The pt5 diagnosis in this entry was WRONG and is corrected here. It read: "they
are UNFILLED, not phantom — a capture/matcher gap; the scrape isn't surfacing the
logo srcs". Two facts killed that: the client logos are in the seed (7 of the 8
observed on live), and the offending blocks were never logos at all. The build's
section-matchers.json named the producer: 94 of the 105 fcr.logo-strip blocks
came from the USPBar matcher, not LogoStrip.
Root cause (CMS lane only): lib/cms/transformer.js mapped USPBar:
fromLogoStrip. USPBar.extract emits { items: [{icon, text}], logos?: [{src,alt}] }
— so a USPBar carrying no logos fell through fromLogoStrip's props.logos ||
props.items fallback, and every {icon, text} item was coerced to {src:'', alt:'',
href:''}. The text was silently destroyed and the empty src painted a logo?
loud-fail in its place. Live shows a 67px band reading "7-Year Guarantee · Fully
insured · Reliable & Long Lasting Results" — zero images, pure text. The static
lane never had the bug (translateUSPBar → features-1, text intact).
Fix: a text-bearing home for the ribbon — new canonical usp-bar.astro (thin
strip; the standalone form of the ribbon hero-4 already hoists), new CMS type
fcr.usp-bar (+ adapter + dispatcher), and fromUspBar splitting by what the
section actually carries: logos → fcr.logo-strip (unchanged; the real 4-badge
strip still renders), text → fcr.usp-bar. Delivered by a seed regen through the
current pipeline + a pages-only D1 write. Verified on the deployed site: ribbon
text renders, ui-missing 276 → 39, block counts unchanged on all 24 pages.
Residual — CLOSED 2026-07-13 pt7. WCP now renders ZERO loud-fail placeholders on
all 24 pages (276 → 39 → 0). The last 39 were image-kind on two pages
(/service-areas 27, /accessibility-statement 12) from 13 fcr.service-grid
items with an empty imageUrl — and, once the producers were checked first
(the discipline this session's other bug taught), they were not a capture gap
either: the content is faithful and the sections legitimately have no imagery.
Live /service-areas "CLICK AN AREA" is a 9-item link grid (title + href, zero
images on live); /accessibility-statement is an InfoCards block of text cards
(title + description). The bug was that services-1.astro rendered <TileMedia>
unconditionally, so a legitimately image-less card hit the Image primitive's
loud-fail. Fixed with a block-level hasMedia guard: a grid where no item
carries an image renders no media slot; a grid where some do keeps it, so an item
that is genuinely missing an image still loud-fails (proven live by blanking one
image in a 3-image grid → placeholder fired → reverted). See known-patterns "A grid
with no imagery is a TEXT grid".
empty-image-slots: 13 in the reconciliation is now BENIGN — those are exactly
these legitimately image-less items. rendered-placeholders (0) is the authoritative
defect signal; don't chase the slot count to zero.
See known-patterns "One emitter, two item shapes".
gallery.columns — regen measures 4, live had 2 (grafted, unverified)¶
Surfaced by the pt6 graft check. The current capture measures the gallery page's
gallery block at columns=4; live D1 carried 2. Live's value was grafted through
rather than overwritten — an unrelated dimension on a page the USPBar slice had no
business changing, and which was not verified against the live site. Reopen: when
the gallery page is next worked (it is also the Wix Pro-Gallery structural case,
S=44.4), settle which is faithful and let the pipeline own it.
garvanbay is clean (contrast, 2026-07-12 pt5)¶
31 refs, 31 rendered, 0 placeholders, 0 orphans, 0 broken. The asset debt was WCP-specific, not a portfolio-wide renderer fault.
Asset reconciliation — the other three buckets (WCP, 2026-07-12 pt5)¶
From the same report (builds/<domain>/asset-reconciliation.json, reporting-only,
gates nothing):
- pulled-but-unrendered: 1. The hero video's poster still
(
…f000-52758e65.jpg, 69 KB) — orphaned by design now that the video replaces it at slide 8 (ADR-0009 Slice 5b). Nothing else we pulled goes unused. This is the bucket the exercise was commissioned to find, and it is effectively empty. - ~~referenced-but-missing: 1.~~ — CLOSED 2026-07-13.
fcr.business.imageUrl(theog:image+ JSON-LDimage) pointed at a raw/assets/logos/…path, dead on the R2-served worker. It was not a missing asset: the logo was already in R2 under the exact key the header logo uses, so zero uploads were needed (the transformer hashed the local file straight onto the existing object). The two real bugs were both consequences of the field never being rendered:transform-seed- images.mjs'sFIELD_MIGRATIONShad nofcr.businessrule, andBase.astrofed the field intonew URL(...)with nogetImageSrc— the one CMS image consumer resolving nothing. Both fixed at the pipeline level (so the next site inherits it);og:image/twitter:image/JSON-LD now resolve 200. The "upload the local file" plan this entry once carried rested on the false "logos are local-only" claim — see the 2026-07-13 correction in known-patterns "Wix transform-suffixed image paths…": the logo IS in the Wix library, it just carries a name rather than a media id. See known-patterns "An image field that never renders on-page is still a shipped image field".referenced-but-missing1 → 0. - at-source-never-pulled: 298 of 392. The client's Wix library holds far more than the site uses. Expected for a real media library (old uploads, alternates) — but it is not yet separated from "used on live and our scrape missed it", which is precisely the LogoStrip failure. After the LogoStrip slice, re-run and re-read this number; a residual here is a capture-coverage signal.
garvanbay has no wix_site_id (its crawl inputs live only on EC2, and
builds/ is gitignored, so the id can't be derived locally) — the reconciliation
degrades loudly and skips the Wix pool for it. Recover the id from an EC2 page
snapshot, or on the next garvanbay crawl, and add it to
apps/cms/sites/garvanbay.jsonc.
CMS demo tenant = garvanbay (restored 2026-07-05 pt2)¶
The deployed demo (emdash.dcathal.org /
replatform-emdash-demo.cathaldempsey.workers.dev, personal
cathaldempsey@gmail.com account 1ceb…) serves garvanbay again. It
was briefly swapped to wfpainters earlier on 2026-07-05 to prove the SEO
fix live; that swap is now reversed (commit 77bdb33, worker version
3e157b52): garvanbay seed restored, D1 ec_pages cleared + reseeded,
worker rebuilt so global.css bakes garvanbay's theme. Verified live —
garvanbay content, 10 pages (no wfpainters orphans), images/logo/subpages
all 200.
To swap the demo to another site: each site's seed is in git —
git checkout <sha> -- apps/cms/seed/seed.json (wfpainters a7a3bbf,
garvanbay 8ce82af) → back up + clear remote ec_pages
(wrangler d1 execute replatform-emdash-demo --remote --command
"DELETE FROM ec_pages;") → node apps/cms/scripts/reseed-d1-from-json.mjs
→ npm run deploy. The rebuild is required — the theme is baked at
build time from the seed's meta.name. See known-patterns "Swapping the
CMS demo tenant…".
Note: wrangler is authed to the personal cathaldempsey@gmail.com
account (not FCR Media Ltd) — the demo D1/R2 live there.
CMS one-site milestone — open workstreams (2026-07-04)¶
The 2026-07-04 session proved the write-path loop on the deployed CMS
(auth → edit → publish → live; images visible/editable/placed). See
scope-2026-07-04-one-site-operational.md for the full milestone. Still
open toward "materially operational":
- Image field migration: top-level fields done; repeater-item images
remain. Option A (ADR-0005 followup) now covers all four top-level
image fields —
fcr.about.imageUrl(2026-07-04 pt2) plusfcr.hero.imageUrl,fcr.cta-strip.imageUrl,fcr.header.logoUrl(2026-07-04 pt3, commit8a51bc5) — asmedia_picker+ scalar URL string. Still open: repeater-item images (service-gridimageUrl, team-gridimageUrl, gallerysrc, logo-stripsrc, blog-postsimageUrl, footerpartnerLogos[].src) are object + text_input inside repeaters → blank/uneditable in the admin. Block-Kit repeater sub-fields are scalar-only (text_input/number_input/select/toggle), somedia_pickercan't be dropped in as-is — needs a separate investigation (e.g. can the picker nest in a repeater at all, or do these stay hand-typed URLs?). Also deferred: herovideoUrl/slideshowUrls(video-picker semantics / multi-value). Do the repeater question before onboarding a 2nd site. - Two image models unreconciled. Structured
fcr.*image fields use the ADR-0005r2:-scheme in R2; the native emdashimageblock an operator inserts via the toolbar uses emdash media storage (ULID keys, nox-amz-meta-original-name). Per-edit upload path needs to reconcile these (ADR-0005 §"Open decisions — Per-edit upload"). - Contact form has no real submission (decided in scope, not built).
/contactstill renders the pre-canonicalMarketingBlockslayout + a demo POST handler that only logs. Wire a real destination + spam protection, and move/contactonto theFcrBlocksdispatcher. - Access policy admits whole domains (
@fcrmedia.ie/@goldenpages.ie) and everyone admitted is Admin-provisioned. Tighten to specific emails (or IdP groups + roleMapping) before wider use — see ADR-0006. - Reseed writes remote D1 but reverts content not in seed.json.
reseed-d1-from-json.mjsoverwrites D1 fromseed.json, so any content authored only through the admin (or richer in D1 than in seed) is lost on reseed. This drew blood on 2026-07-04 (pt3): the globals reseed had been silently no-op'ing for months (id-vs-slug bug); fixing it made reseed write globals for the first time — from aseed.jsonwhose globals had drifted to a thin skeleton — wiping the live 8-item nav menu/topbar/footer. Recovered from therevisionstable andseed.jsonreconciled up from live (commit8ce82af; see known-patterns "Fixing a silently-broken sync is destructive when the two stores have diverged"). Globals seed.json is now reconciled, but the general policy gap stands: a real seed-vs-admin source-of-truth policy is still open. Until then, before any reseed, dry-run and diff the payload against live; never assume seed is canonical just because it's the input. Relates to the known-pattern "emdash seed targets local data.db, not remote D1".
Footer partner-logo credit never reaches the seed — transformer field-mapping bug¶
The last survivor of the 2026-05-12 "image deficit" entry (4 live garvanbay
images absent from seed). The 3 welcome-section images resolved 2026-07-10:
the split-continuation dispatch (3b71607) now emits them as a home
fcr.gallery block, seeded + live. Remaining:
c00d38_cd883381...~mv2.png(website-design-by-FCR-logo.png), footer partner-logo position. Root cause known:lib/assembler-fulldev/translate.js#translateFooteremitsprops.credit = { src, alt };lib/cms/transformer.js#fromFooterreadsprops.partnerLogos(notprops.credit). Mismatched field name — credit data never makes it into the seed. Clean fix, one line, then regen+patch.
Out of scope here: 2 blog-post thumbnails for posts newer than the 3 the
matcher captured (matcher-side scope); hero ribbon trust badges (live carries
none — our empty ribbon: [] is faithful).
R2 upload — throughput ceiling (deferred until 50-site end-to-end is proven)¶
Slice 4 of ADR-0005 shipped the SDK upload path
(@aws-sdk/client-s3, x-amz-meta-original-name on every PUT,
backfill of wrangler-era objects via CopyObject +
MetadataDirective: REPLACE). The §6 metadata gap is closed for
SDK-uploaded objects and was retroactively closed for the 158
wrangler-era wfpainters objects via the backfill done-criterion.
The throughput target was missed and the investigation deferred. ADR-0005 §"Effort" set <30 min wall for the one-time 290k-image portfolio migration (parallelised 20–50 wide). Measured throughput at portfolio scale projects to ~70–110 min, an order of magnitude above the target.
Sweep data (2026-05-11, 158-image wfpainters corpus, fresh PUTs
under unique bench-{ts}/ prefixes, HEAD-then-PUT path matching the
production helper):
concurrency PUT/sec proj(min for 290k)
50 58.2 83.1
100 68.0 71.0
200 47.2 102.4 ← retry-induced dip
500 55.3 87.5
1000 59.1 81.7
Curve shape: flat with noise. Throughput clusters around 50–70
PUT/sec regardless of concurrency. Per known-patterns.md
"parameter sweep distinguishes hypotheses": flat → remote-imposed
ceiling. Likely H1 (per-token or per-account rate-limit on
Cloudflare's S3-compatible API), though the limit's class
(per-token vs per-account vs per-IP) is unattributed.
Hypothesis status:
- HTTP/2 hypothesis ruled out — R2's S3-compatible endpoint
rejected the HTTP/2 handler with ERR_HTTP2_ERROR: Protocol error.
HTTP/2 only on the public CDN path (pub-…r2.dev), not on
<account>.r2.cloudflarestorage.com.
- Bandwidth hypothesis ruled out — wfpainters corpus is 6.1 MB
total (avg 38.9 KB/image), not bandwidth-bound on any plausible
home-internet pipe.
- Per-token rate-limit hypothesis (H1) — fits the data but the
class hasn't been tested. ~50–70 PUT/sec is well below R2's
documented bucket-level ops/sec limit (1500), suggesting
per-token or per-account, not bucket capacity.
EC2 pre-stage state. cathals-demo (52.48.145.217) confirmed
reachable; Node 22.22.0 installed; curl available; SSH config at
~/.ssh/config aliased. The script and .dev.vars would push via
scp/rsync. Not run — the EC2 sweep was the cheap test that
would distinguish per-token (EC2 stays flat at ~60 PUT/sec) from
network-path (EC2 hits 200+ PUT/sec). Held for when this is taken
back up.
Strategic implication. This is a recurring cost that scales with how aggressively we onboard the long-tail portfolio. For the near-term gate (Slice 3 → 50-site end-to-end validation), 60 PUT/sec is sufficient: 50 sites × ~160 images/site = 8000 images at 60 PUT/sec = ~2 minutes. Throughput architecture only matters at wholesale-fleet scale and shouldn't pre-empt the working end-to-end pipeline.
New data point (2026-07-12 pt5, unexplained). upload-image.validate.mjs
Phase 4 now measures 7.7 PUT/sec at concurrency=50 (188 fresh PUTs) —
roughly 8× slower than the 50–70 PUT/sec the 2026-05-11 sweep recorded,
and below the validator's own 50 PUT/sec floor, so the validator fails
on Phase 4 (Phases 1–3, incl. idempotency against live R2, pass). Not caused
by the ADR-0010 upload-helper change (a content-type map entry cannot slow a
PUT; key derivation was proven byte-identical over 190 corpus filenames and
against live R2). Most likely environmental (local network path), but it is
unattributed — the EC2 sweep pre-staged below is still the cheap
discriminator. Don't relax the floor to make the validator green.
Deferral contract. Reopen this issue when one of the following fires (whichever first): - 50-site end-to-end pipeline is proven (Slice 3 + per-site seed flow + verify-design coverage), and we're sequencing wholesale onboarding. - A wholesale onboarding batch lands in scope (>200 sites at once) and an operator estimates the upload step at multiple hours. - The R2/Cloudflare side changes (rate-limit class published, new product tier, jurisdictional endpoint with different limits).
When reopened, the cheap next move is the EC2 sweep already pre-staged. If EC2 hits 200+ PUT/sec, the answer is "run the migration from EC2"; if it stays flat, multi-token sharding (N tokens × ~60 PUT/sec) is the architectural fix.
Tracked here as a strategic deferral, not a "we'll get to it" item — the deferral contract above is the trigger condition.
Copied-vs-shared fulldev primitives — not verified¶
packages/components-v3/ was seeded from fulldev/ui at evaluation time (2026-05-03 per ADR-0001). fulldev/ui upstream moves at ~50 commits / 7 days at evaluation rate. Not verified whether the workspace package is a snapshot copy, a git submodule, an npm dep with a pinned version, or something else. If it's a snapshot, drift is already starting and will only grow.
Critically, the 2026-05-09 session made fixes inside packages/components-v3/ (reviews-1.astro avatar conditional, team-grid.astro short-circuit removed, icon.astro rewritten to static map). If we're sourcing from upstream, those would conflict on next pull; if we're a snapshot, those are FCR-owned and stay.
Cheap to confirm now (one git log exercise + a look at how the package was added). Expensive to discover late.
Schema ↔ component type validation absent¶
CMS plugin schema declares field shapes in apps/cms/src/plugins/marketing-blocks/index.ts. Canonical primitives expect specific prop types. Adapters reconcile them. The reconciliation is hand-written TypeScript, validated only at adapter compile time — there's no end-to-end check that "schema field X maps to primitive prop Y" stays consistent across schema edits.
If a schema field type changes (e.g. imageUrl: text_input → images: repeater) without updating the adapter, the build still passes — adapter receives unexpected shape, primitives get malformed input, runtime renders go silent or wrong. Same class of bug we just spent the session loud-failing at the primitive layer; needs the same audit shape applied upward.
Not blocking. Worth ADR-0006 when a schema migration first burns us.
Unmatched-Wix-section behaviour at the matcher layer¶
Today: when lib/dom-pipeline.js / matchers hit a section type that no matcher recognises, behaviour is silent drop. Same class of bug as the primitive layer pre-2026-05-09. Worth carrying the loud-fail discipline up into the matcher layer when it's next in scope.
Detection rule (per known-patterns.md "primitive emits nothing for unrecognised input"): any layer that drops bad input without a visible/logged signal needs the same audit treatment. Matcher layer is in the queue; haven't hit it.
Silent-drop closed 2026-07-08 (ADR-0009 Slice 4b-i). The always-matches
GenericSection fallback (score 0.05, strictly below every real matcher →
zero-regression) now claims every content-bearing unmatched <section> in both
lanes, and the two previously-silent continues in lib/cms/transformer.js now
loud-warn (ADR-0004). Residual: truly-empty sections are now loud but still
dropped. (The FloatingCTA/Social matched-but-no-translator drop this once cited is
resolved — both translators are registered as globals and deployed to WCP.)
The genericFallbackRate meter over GenericSection use shipped
2026-07-08 (Slice 4b-ii) — every fallback use is now measured + can veto on a
critical region. So the matcher-layer loud-fail discipline has now landed for the
CMS lane — this entry stays open only for the residual + the static-lane emit
polish.
google-reviews icon has no glyph (deferred — not firing)¶
The CMS icon enum (src/plugins/marketing-blocks/index.ts) offers
google-reviews, but neither lucide-static nor simple-icons has a glyph for
it, so it would loud-fail if selected. Not currently firing — no deployed
page uses it yet — so deferred until a site actually selects it. When needed,
vendor a glyph locally (same pattern as the LinkedIn mark, commit 3069ee4)
or map it to a bundled Google 'G'.
(The portfolio was placeholder-free as of 2026-07-04 pt4 — every icon the
deployed pages reference resolves; circle-check/menu/clock/search via
the icon-map pass, linkedin via the vendored glyph.)
Image ownership / IP¶
All images downloaded by lib/backfill-assets.js and served from our infrastructure are assumed to be images the FCR client legitimately holds rights to (their own photography, paid stock from the original site's media library, or licensed content). Pipeline does not verify or strip rights metadata. If a site is ever rebuilt for a non-FCR-client (third-party crawl), this assumption breaks — images would need a per-domain copyright check before mirroring. Document the FCR-portfolio-only constraint anywhere the crawl/seed flow is exposed to non-FCR sites.
This is also why feedback_no_wix_css_copy flags parastorage CSS as Wix IP that must be stripped, but wixstatic image hosts are OK to download — the images on a Wix-hosted site belong to the site owner; the CSS belongs to Wix.
Phase 1 fails on nested-slug pages (CSS mkdir)¶
lib/dom-pipeline.js:723-724 writes per-page CSS as path.join(cssDir, slug + '-N.css'). When the slug contains a slash (e.g. post/foo, local-service/electrician-dublin-3), the subdirectory under public/assets/css/ is never mkdirSync'd and the page fails with ENOENT. Same pattern probably applies to other per-slug writes in the same block. Fix: fs.mkdirSync(path.dirname(path.join(cssDir, cssFile)), { recursive: true }) before the write.
Phase 1 and Phase 2/3 write to different build dirs¶
lib/dom-pipeline.js (Phase 1) writes under ~/replatform-dashboard/builds/<domain>/ but lib/capture-layout.js, lib/theme-extractor.js, and lib/assembler-v3.js all read/write under ~/replatform/builds/<domain>/. Currently worked around by cp -r between the two trees. Fix: pick one canonical BUILD_DIR and have every phase use it.
Phase 4+ requires manual assembled-site scaffold¶
lib/assembler-v3.js outputs into builds/<domain>/assembled/, but there is no scaffolder that creates assembled-site/ with package.json, astro.config.mjs, src/pages/, and real (not symlinked) src/components/. First run for a new site is currently hand-scaffolded from a template like trimtech. Also note: cp -r /home/admin/replatform/components/src/components dest creates a symlink because the source path ends in a symlink — must use cp -r SRC/. DEST/ to get real files. Symlinked components cause vite to walk up to /home/admin/replatform-dashboard/tsconfig.json which extends an uninstalled astro/tsconfigs/strict and breaks the build.
Slideshow image download is manual¶
Phase 1 downloader only fetches images that appear in the static body.html. Slideshow slides 2+ that only exist in JS state are captured by capture-layout.js but not auto-downloaded. Currently worked around by curl-ing them per site. Fix: have capture-layout.js download the full-resolution image for each enumerated slide.
Component sync is manual¶
Components live in components/src/components/ (canonical) but Astro builds from builds/<domain>/assembled-site/src/components/. If a source component is edited and not copied across, the deploy silently uses the old version. Runbook includes the copy step but it's easy to forget.
Fix: add a sync step to the deploy runbook that always refreshes components before astro build.
Assembled pages → src/pages is manual¶
assembler-v3.js writes to builds/<domain>/assembled/<slug>.astro. Astro builds from builds/<domain>/assembled-site/src/pages/<slug>.astro. There is no automated sync step — runbook has cp builds/<domain>/assembled/*.astro builds/<domain>/assembled-site/src/pages/ as a manual step.
Fix: have assembler-v3.js write directly into assembled-site/src/pages/, deleting the intermediate assembled/ directory.
Phase 5 >25 MiB file drop is silent¶
Cloudflare Pages rejects files >25 MiB. Runbook does find dist -type f -size +24M -delete before deploy, which silently removes the file. If it was a hero video, the hero breaks with no warning. trimtech's Sherwin-Williams video was 30MB and had to be manually compressed with ffmpeg scale=1280 crf 28 → 9MB.
Fix: in the assembler, detect oversize videos and emit an ffmpeg command (or run it automatically) before copying into public/assets/videos/. Also log when Phase 5 drops anything.
Placement check captures up to 8000px only¶
lib/placement-check.js scrolls in 900px steps until scrollHeight or 8000px, whichever is smaller. Long pages get truncated and Gemini can report "section not present on live" when it's just below the capture window. Raise the cap or paginate the comparison.
Top-level section enumeration vs nested¶
assembler-v3.js enumerates all section elements (not just top-level) and runs the matcher on every candidate, then dedupes ancestor/descendants with matching matchers. Works, but the ordering in the rendered page can become non-obvious when nested sections get matched as siblings of their ancestors. If a future matcher introduces weird ordering, revisit buildSections() and sigToEntries dedupe.
No Phase 2/3 fallback when computedLayout is missing¶
If capture-layout.js fails or is skipped, the About matcher's layout-based image-position detection falls back to imagePosition='left' for everything. Technical Capabilities and other text-left/image-right sections will render mirrored. Accept as degradation, but make the fallback explicit in the report.
Older v2 debt (mostly superseded by v3)¶
v3 replaces most of the v2 hot path. These remain relevant only if we need to fall back to v2 for unmatched sections:
- Phase 2 classification missed Testimonials on medical homepage — real reviews block got classified as About. v3's matcher approach avoids this via explicit Testimonials matcher with review-text signals.
- Claude JSX escape / missing imports — only matters if Claude is invoked as fallback. Deterministic assembler-v3 doesn't have this class of bug.
Interactions gap¶
Phase 1 DOM clone ships interactions.js for dropdowns, slideshow/video sync, FAQ accordion, scroll reveal, lightbox. v3 components are mostly self-contained (Header, Gallery, Testimonials have own <script> blocks; FAQ uses native <details>; Hero supports backgroundVideo + slideshow nav + reveal animation).
Real remaining gaps: - Section-level scroll-reveal — only Hero has a reveal animation; About/ServiceGrid don't fade in on scroll. - Video ↔ slideshow sync — hiding background video when a slideshow advances past slide 0. Niche.
A small shared IntersectionObserver helper would cover scroll-reveal without the Wix dependency.
Wix→WooCommerce lane (ADR-0012 pilot — bmpartsandtools.ie)¶
Store is LIVE on cathaldev2; loader gaps + content debt carry explicit triggers (2026-07-15)¶
The pilot store: https://cathaldev2.fcrweb.ie (/var/www/site2 on cathals-demo, WP 7.0.1 /
Elementor Pro 4.0.4 / Woo 10.8.1). 747 products, 259 categories (exact Ecwid mirror after
in-place repair). Post-feed runbook (all idempotent): repair-category-tree.php →
import-category-images.php → import-category-descriptions.php → fix-nav-dropdown.php →
post-feed-fixups.php.
The chain now lives in DCathal/replatform-woo
(local ~/replatform-woo), parametrized — ADR-0012 first-milestone step 1, done 2026-07-16.
~/wordpress/scripts/bmparts/ is the unversioned original and is now an attic copy: it
still carries the site literals and the hardcoded post ids. Run the toolkit, not the attic.
Open items:
- wix-product loader (owner-run, separate repo): after three feeds — names now exact ✓
(slash-splitting fixed), but the Tools & Consumables subtree still arrives flattened to top
level and empty categories are skipped.
repair-category-tree.phpstays in the chain (dry-run flag inside). Drop-the-repair trigger: a fresh feed shows 7 top-level categories without the repair having run. - Content debt (owner/catalog side, NOT host/pipeline): 27 products have no image in Ecwid itself (variant-shaped entries — "Brown – 21mm Step", spindles; loader retries won't help); 8 products land in Uncategorized because their Ecwid categories are hidden from the public API. Fix in Ecwid (add photos / unhide), or hand-file in WP admin.
- Payments: Authipay gateway plugin on disk, inactive; checkout untested end-to-end.
- SECURITY — rotate before any handover (claim corrected 2026-07-16, urgency lower than
filed):
~/wordpressholdscathal.dev.ppkat root and live WCck_/cs_keys inconfig/api-credentials.md+ its CLAUDE.md. AwebdevWP admin account exists for the developer handover; site2 WC keys are instance-specific and must be regenerated after any migration.
Correction (2026-07-16): "the
wordpressrepo hascathal.dev.ppkcommitted at root" is false —~/wordpressis not a git repository at all. No.git, no remote, no history. Nothing in it has ever been committed or pushed; the.ppkand the WC keys have never left local disk. Still worth rotating, but this was filed as a published-secret problem and it is a local-file problem.Why it was believable:
~/wordpress/crawleris a repo with a live remote (DCathal/Replatform), so "the wordpress repo" was true of the part stage 1 depends on — and a folder of scripts + config + a.ppkreads like one. The observation (a secret is in the folder) was right; the inference (it is therefore in git) never followed. Onegit -C ~/wordpress statuswas the whole check.The unnoticed half was worse. Not being a repo meant the pilot's bmparts scripts had no version control, and
crawler/lib/layout-analyser.js+lib/tag-scanner.js— the two files ADR-0012 names as load-bearing for stage 1 — were untracked since March, living on one laptop. Both committed + pushed 2026-07-16 (DCathal/Replatforma28ac81). - Elementor 4.1.0-beta1 on the box; the templates carry the Pro-4 fixes (ADR-0012 §Context) and render correctly on it — but pin/downgrade is an open decision for a production host. - The toolkit has never run on a SECOND site — the parametrization is unexercised (2026-07-16). Step 1 is proven no-op on bmparts (reverse-substitution + 6/6 id re-derivation on the live box), which proves it did not break the pilot. It does not prove it works elsewhere: every resolver has only ever been asked a question that had a right answer on this one install. The fail-closed paths are exercised (a bogus template type exits 1); the succeed-on-a-different-box path is not. Per known-patterns "A media-pool enumeration derived from ONE site is a hypothesis" — a contract measured on one reference site is a hypothesis, and this one is two days old. Reopen: the second site of the Wix/Ecwid→Woo pair — expect the first real findings there, and expect them in$cards(below) before the resolvers. -$cardsinpost-feed-fixups.phpis still bmparts' homepage — a label→category map ('Trade Bundle Deals' => 'Trade Only Deals', …). Left alone deliberately: it is content, not identity, and hoisting it into config would be guessing at the job-spec's shape before step 2 defines it. The real question is where card mappings come from — the crawl (which already measures the homepage's cards) or the job spec. Decide it in step 2, not by reflex.