Skip to content

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:323emitGlobalCss(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-POLISHverdictReasons 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-21composite() (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. deriveBrandPalette deliberately 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 #2aa1db painted 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 hueDist 2.71° / rgbDist 32 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 / highrestoring 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):

  1. 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 /about team-grid, /payroll checklist — both zero-regression). The per-site static lane wasn't separately built — no local assembled-site exists (garvanbay's is on EC2; wfpainters has body.html but 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.
  2. columns capture over-fires cols= on non-grid sections. resolveSectionStyle reports a columns count on heroes/CTAs/content-2 splits (any even-width 2-item first row). Harmless today — columns only stamps on identity-matched COLUMNS_ELIGIBLE blocks (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 reads style.columns on 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-08genericFallbackRate (Σ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 chromeDisagree at 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; the top-bar-missing crop 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.jsonfooter: {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:99 reads background: var(--footer-bg, var(--background, #ffffff)).
  • lib/assembler-fulldev/theme.js#emitGlobalCss emits --topbar-bg / --topbar-text (2026-07-13 pt4) and no --footer-bg at all.
  • theme.json#colors.footerBg is null on every site inspected, WCP included — so the extractor never populates it either. palette.footerBg is referenced at theme.js:161 only 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.

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.json0); the only committed per-site artifact is theme.json (.gitignore:48). The CMS ship lane deploys through apps/cms prebuildemit-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.json or 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 via theme.json. The topbar is the precedent and it runs the other way from how it is usually cited: --topbar-bg comes from palette.topStripBg (extractor, pickTopStripBgEl), while chrome.topbarStrip is 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 _rawderiveTheme. - 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.layoutinline-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.jsheaderBg 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 matches CTAStrip natively, because ctx.position differs between the halves and Hero gates on position <= 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.astro carried 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 (nav px-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 :root later (fold into the ADR-0009 header/density dimension). Plus aria-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.items gained a multiline subItems field ("Label | /url" per line — the footer-lines encoding; Block-Kit repeaters are scalar-only); fromHeader emits it from menus[].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.headerRawclassifyHeaderLayout, 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:

  1. ~~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 (urlhref threading bug in translateTopBar), email + location landed pt3. Email/location were genuine capture-plane misses (JS-rendered — the static body.html's pinnedTopCenter carries neither a mailto: nor the "Available in Waterford | …" run) hidden further by a zero-height pinned container (its children are absolutely positioned, so resolveSectionBg($pinned) read source: zero-size). Fixed by measuring the post-JS contact run in-page (chrome.topbarContact in capture-section-backgrounds.js, scoped to #pinnedTopCenter, mailto:-href + |-segmented run) + a fill-if-empty overlay in seed-from-build.js after the header overlay; landed on WCP live D1 via surgical ec_globals read-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.)
  2. ~~Header CTA is pinned OUTSIDE <header> → not auto-detected.~~ — CLOSED 2026-07-12 pt4. WCP's SERVICE AREAS pill is a real <a href="/service-areas"> pinned by Wix in pinnedTopRight (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 pure classifyHeaderCta (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 stays headerCta: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 (detects SERVICE AREAS → /service-areas, matching the committed seed — now reproducible-from-capture, not manual) + garvanbay (null, faithful). Same detector closes the globals headerCta mismatch (below). CMS lane; S/T/G byte-identical; +17 validate checks. inline-left routing (#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-areasstatus: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-cta WhatsAppapi.whatsapp.com/send?phone=…, bottom-right ENQUIRE/contact-us, right
fcr.floating-social facebook + youtube whatsapp only
native block present (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:

  1. floating-cta-wcp--right renders a ROTATED VERTICAL TAB; live's ENQUIRE is a horizontal pill. The block styles position: 'right' with transform: 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 --right as a horizontal pill and add a distinct right-tab value for genuine side-tabs, or (b) capture orientation as a bounded dim (the raw w/h are already in chrome.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.

  2. The scroll-to-top FAB has no CMS home. classifyFloatingCta recognises it (bare //# href, or Up/Top text) 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 — adding fcr.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.json27/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.

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): videos multiline field on fcr.gallery (schema); makeStyleLookup emits each gallery section's attributed video refs, freshness-gated on the v8 mediaRefs.sectionAttribution key; transformer stamps fcr.gallery.videos; gallery-wcp.astro renders a <video controls> strip below the image grid (inert when absent); Gallery.astro adapter parses the lines field; migrateGalleryVideos hook in transform-seed-images.mjs. A/B (change stashed vs applied, _key-normalized): garvanbay 6 gallery blocks gain videos:"" + 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 block videos:"". 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-tokensrepackager.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. videoDebt is 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/.ts requests 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 carry source_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 (the fcr.about ×11 bucket) fires the same play/ 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: 9ba179c068494c0c8d7bcbc585393c8c and 4cd95f58005b4a48a57a1e3f133e20ab. 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: videoDebt counts 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.

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 nulla 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:

  1. isJunk was 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 as insertedOurs. 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.
  2. Nested Wix wrapper doubles (23 blocks). Real render defect: /commercial-repairs printed "Commercial Repairs" three times (hero + cta-strip + about) where live prints it once. dedupe.js passes 1–2 key on matcher.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-repairs the re-match never fires at all: the inner half matches CTAStrip natively, because ctx.position differs between halves and Hero gates on position <= 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.

  3. A FALSE criticalSectionDropped veto. 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 in scorecard-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).
  4. One real content loss, unmasked by fix 2. /thank-you-contact-form lost "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 — buildSectionsmatchAllSectionsdedupeLayoutMap) 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"):

  1. ≥2 distinct h2s flags card bands. WCP's 4-card USP ribbon and garvanbay's 3-card teaser feed each mark every card title h2. 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.
  2. 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.
  3. 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 to ok. 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.navRawclassifyNav → 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 garvanbay 00000186-…-800f5abd29a0dea59661fbfdc4b65a3d / WCP 0000006c-…-c87d915…f3a, plus SQL exports at builds/<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.ts is now export { default, PluginBridge } from "@emdash-cms/cloudflare/worker" and wrangler.template.jsonc carries "triggers": {"crons": ["* * * * *"]} — without both, scheduled publishing/plugin cron silently never run.
  • images: false set in astro.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 /_image path 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.0 is an accidental 2026-04-27 publish (upstream PR #796 reverted it same-day; CI now blocks 1.x; @emdash-cms/cloudflare has 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 repeater imageUrl flipped media_pickertext_input (it never rendered as a picker anyway). Top-level media_picker fields (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: playwright is now a declared root devDependency (it had been an undeclared ad-hoc install, so the upgrade's npm install pruned 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.

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 (translateUSPBarfeatures-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 (the og:image + JSON-LD image) 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's FIELD_MIGRATIONS had no fcr.business rule, and Base.astro fed the field into new URL(...) with no getImageSrc — 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-missing 1 → 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.mjsnpm 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) plus fcr.hero.imageUrl, fcr.cta-strip.imageUrl, fcr.header.logoUrl (2026-07-04 pt3, commit 8a51bc5) — as media_picker + scalar URL string. Still open: repeater-item images (service-grid imageUrl, team-grid imageUrl, gallery src, logo-strip src, blog-posts imageUrl, footer partnerLogos[].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), so media_picker can'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: hero videoUrl/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-0005 r2:-scheme in R2; the native emdash image block an operator inserts via the toolbar uses emdash media storage (ULID keys, no x-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). /contact still renders the pre-canonical MarketingBlocks layout + a demo POST handler that only logs. Wire a real destination + spam protection, and move /contact onto the FcrBlocks dispatcher.
  • 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.mjs overwrites D1 from seed.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 a seed.json whose globals had drifted to a thin skeleton — wiping the live 8-item nav menu/topbar/footer. Recovered from the revisions table and seed.json reconciled up from live (commit 8ce82af; 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".

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#translateFooter emits props.credit = { src, alt }; lib/cms/transformer.js#fromFooter reads props.partnerLogos (not props.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_inputimages: 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.php stays 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): ~/wordpress holds cathal.dev.ppk at root and live WC ck_/cs_ keys in config/api-credentials.md + its CLAUDE.md. A webdev WP 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 wordpress repo has cathal.dev.ppk committed at root" is false — ~/wordpress is not a git repository at all. No .git, no remote, no history. Nothing in it has ever been committed or pushed; the .ppk and 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/crawler is 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 .ppk reads like one. The observation (a secret is in the folder) was right; the inference (it is therefore in git) never followed. One git -C ~/wordpress status was 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/Replatform a28ac81). - 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. - $cards in post-feed-fixups.php is 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.