Skip to content

Known patterns

What we've learned about Wix → static replatform that must be folded into the pipeline so we don't relearn it on every site.

Each pattern: how it manifests, where it bites, and where the fix lives (or needs to live) in the codebase. When lib/structure-gap.js flags a mystery section, check this list before debugging from scratch.

When you spot a new pattern: add an entry here in the same session you fix it. A shipped fix without a catalogued pattern is half the value.


Hero / above-the-fold

Wix slideshow with no visible "next" button (auto-rotates)

Bites: capture-layout.js's slide enumerator clicked a next-button selector and returned only the active slide. Hero rendered as a single static frame.

Fix: lib/capture-layout.js#captureSlides — three-pass: 1. Query every <img> / [style*="background-image"] / <video> inside the slideshow root. Wix often pre-renders all slides as siblings, just toggling visibility — picks them up in one shot. 2. Try the next-button click flow (works on older Wix templates). 3. Auto-rotation watcher: poll the active slide for ~20s, collect new URLs as the slideshow advances itself.

Status: shipped 2026-05-09.

Hero heading wrapped in <h3> / <h4> instead of <h1> / <h2>

Bites: Wix editors choose heading element by visual style, not semantics. Hero matcher checked only h1/h2 → empty <h1></h1> slot on 4/5 garvanbay service pages. Silent regression — the build emitted broken pages until Gemini caught it.

Fix: lib/matchers/Hero.js#extract — heading lookup is `h1 > h2 > h3 > h4

large-font-p`. Subtext also looks at h4.

Status: shipped 2026-05-08 after Gemini verify-design pass surfaced it.

Trust ribbon overlapping hero (USPBar inside <header>)

Bites: Wix wraps the trust strip ("7-Year Guarantee / Fully insured / Reliable") inside the <header> element on wfpainters subpages. Our buildSections blanket-excluded sections under <header> to avoid pulling in nav links → trust ribbon never enumerated → Hero matcher rendered without it and Gemini flagged "missing-ribbon-bar" / "missing-feature-bar" across multiple subpages.

Compounded by Hero matcher's strict position === 0 rule: once the trust ribbon was enumerated, it took position 0 and the actual hero fell to position 1 — Hero rejected, CTAStrip won the fallback.

Fix: - lib/assembler-fulldev.js#buildSections (and lib/structure-gap.js): allow <section> inside <header> when the section has at least one <p> outside any <nav> descendant. Pure-nav sections still skipped. - lib/matchers/Hero.js#match relaxed to position <= 1 so a trust strip at 0 doesn't force-skip the real hero. - lib/assembler-fulldev/emit.js#hoistHeroRibbon cross-entry pass: USPBar with ≤4 short items adjacent to the hero (±3 entries) gets hoisted into hero.props.ribbon and the standalone entry is marked droppedAsDuplicate. Both directions are evaluated; the candidate with the shortest total item-text wins (the ribbon is typically a 3-item strip, ~50 chars total; longer USPBars like a 4-item trust-badge block are page sections, not ribbons). - lib/components-v3/blocks/hero-4.astro renders the ribbon as a separate full-width brand-coloured strip immediately below the <Section>, not as a bottom-overlay on the hero media. Live FCR pattern (confirmed via placement-check.js on wfpainters): the strip is its own row beneath the hero, not layered over it.

emit.js iterations now also filter entry.droppedAsDuplicate (was only filtered by variant-picker before backfills ran).

Status: shipped 2026-05-09.

Single-instance dedupe — earlier position always wins for Hero

Bites: Relaxing Hero's position rule (===0 → <=1) for the trust-ribbon fix unintentionally let a second section win Hero on garvanbay service pages — the page-intro section at position 1 has heading + image bg too, and often outscored the real hero on text signals (e.g. h2 vs styled-<p> heading proxy). Two hero-4 components stacked, often the wrong one showing first. Gemini scored these pages 1/10.

Fix: lib/assembler-fulldev/dedupe.js adds a SINGLE_INSTANCE_MATCHERS set (Hero for now). Pass 0 sorts duplicates by _matchPosition first, score only as a position-tie breaker — for Hero, the canonical rule is "first prominent section is the hero", and an intro section deeper in the page never wins, no matter how many h2s and images it has.

The loser entry's content still surfaces: re-match callback runs against a filtered matcher list (offending matcher excluded) so e.g. the /payroll intro that lost Hero falls to About → content-1.

lib/assembler-fulldev.js exposes matchers + a buildCtx helper from loadLayoutFromDomain so the re-match callback can rebuild a section's ctx using its remembered position.

After this fix, Gemini scores on garvanbay rebounded: home 1→5, payroll 4→5, management-accounting 1→4, company-secretarial 2→7.

Status: shipped 2026-05-09.

Page-aware hero overlay (single image vs slideshow / video)

Bites: subpage heroes use a single static image — needs a strong dark overlay so white heading text reads. Homepage heroes use slideshow / video which have their own visual dimming — strong overlay over those reads as a murky black band.

Fix: lib/assembler-fulldev/translate.js#translateHero infers scrimIntensity: 'light' when video or slideshow >1, 'strong' for single image, 'medium' as fallback. lib/components-v3/blocks/hero-4.astro swaps three opacity tiers.

Status: shipped 2026-05-09.

Background video extraction without component render

Bites: Hero matcher captured backgroundVideo correctly — hero-4 component only ever rendered <Image>. Video silently dropped.

Fix: lib/components-v3/blocks/hero-4.astro branches on slides / backgroundVideo / image priority. Video uses autoplay muted loop playsinline with image.src as poster.

Status: shipped 2026-05-09.

The capture plane — not the matcher — is the authority for a hero's full-bleed-vs-side layout

Bites: WCP's 11 service-page heroes are a light text + side-image section on live, but rendered as a full-bleed dark hero-4. The obvious framing ("teach Hero.js to distinguish a full-bleed background from a side image with bbox geometry") is a trap: the matcher runs geometry-blind in the CMS seed path (seed-from-build.js builds ctx with compLayout: new Map(), computedLayout: null), so Hero.js cannot measure a side image there — it just promotes the first <img> to backgroundImage, and fromHero defaults scrim=strong → dark. Meanwhile the capture plane already measured it: section-backgrounds.json carries bg.media=false + style.imageSide=right for those exact sections, joined per-block by makeStyleLookup. The design signal existed; the hero layout just never read it.

Fix: decide the hero layout from the capture, not the matcher. makeStyleLookup exposes media; the transformer stamps imageSide+scrim:none on fcr.hero only when imageSide && !media (a non-media section with a measured side image), routing the CMS Hero.astro adapter to hero-1 (text+side image, no scrim). Genuine media heroes (bg.media, or a measured scrim) keep hero-4. hero-1.astro (previously unused in both lanes) was reworked to mirror content-2's proven SectionSplit layout. 10 service heroes converge (WCP composite 76.3→79.0, S 65.5→70.8, S-floor cleared); garvanbay's media heroes get 0 stamps (SHIP untouched — code inert on media heroes).

Detection rule: when a fidelity bug looks like "the matcher extracted the wrong thing", first check whether the design plane (capture) already measured the right thing — the matcher's job is content + block identity; the ADR-0009 capture is the authority for layout/style. A "matcher-level fix" for a dimension the capture already quantizes is usually the wrong layer (and impossible where the matcher is geometry-blind).

Status: shipped 2026-07-10 pt4 (ADR-0009 hero variant; CMS lane — static lane deferred, same Option A trigger as imageSide/columns).

The capture plane is the authority for header layout too — the same geometry-blind-seed-path trap, and a header CTA can be pinned OUTSIDE <header>

Bites: WCP's live header is a split-center layout — nav items flank a centered logo on one row (HOME FARMING COMMERCIAL | logo | RESIDENTIAL GALLERY CONTACT US + a SERVICE AREAS pill) — but ours rendered the FCR-typical stacked-center (header-wcp's only layout: logo above, nav below, garvanbay's shape). The obvious framing ("teach Header.js to emit the layout from logoPosition") is the exact hero trap one level up: Header.js#logoPosition is measured from Playwright computed layout, which is null in the CMS seed path (seed-from-build.js builds ctx geometry-blind), and even when present logoPosition is center for both stacked and split — the discriminator is where the nav sits relative to the logo, which the matcher never measures. Two further gotchas: (1) the header CTA (SERVICE AREAS) is a real <a href> but Wix pins it outside the <header> element (el.closest('header') is null), so a header-scoped probe correctly finds nothing — the trimtech "pinned-layer children escape coverage" lesson, at the header; (2) the nav items are Wix StylableHorizontalMenu <li data-testid="menuItemDepth0">, not plain anchors, so an <a>-only nav scan under-counts them.

Fix: measure the header layout on the capture plane, same rail as the section dims. Raw geometry in-page (chrome.headerRaw: logo bbox, nav-item centroids, navTop) → pure node-side lib/section-style.js#classifyHeaderLayout → bounded enum stacked-center | split-center | inline-left | null (logo above the nav row → stacked; logo in-row + nav flanking a centered logo → split; logo left + nav right → inline-left; too thin → null → the block's stacked-center default stands, zero-regression). seed-from-build stamps it onto the fcr.header global from the homepage chrome; a bounded layout prop on header-wcp renders it. To keep the other consumer byte-identical when a shared block grows a variant, keep the default branch verbatim and gate only the new branch behind the enum — proven here by diffing the stacked markup old vs new (the only textual delta was the ternary )}, which emits no HTML; garvanbay render unchanged).

Detection rule: any global/chrome layout question (header stacked-vs-split, topbar pinned-vs-static, footer columns) is capture-plane work, not matcher work — the matcher is geometry-blind in the seed path and its per-element signals (logoPosition) often can't separate the layouts anyway. Collect raw bboxes in-page, classify to a bounded enum node-side, verify on the rendered artifact (screenshot the deployed header), and when a value the extractor should reach lives outside the expected element (Wix pins chrome siblings), return null rather than fabricate — the gap is a named fast-follow, not a guess.

Status: shipped 2026-07-12 (ADR-0009 Slice 3 header dimension; CMS lane — classifyHeaderLayout + header-wcp layout/cta, WCP worker b4e7a6a4). Header-CTA auto-detection (pinned outside <header>) + topbar field extraction + inline-left routing + globals-into-the-scorecard are named fast-follows (known-issues).

A pinned-outside-<header> button IS the header CTA — button-styling + a nav-row floor separate it from the topbar contact strip

Bites: WCP's SERVICE AREAS header CTA is a real <a href> but Wix pins it OUTSIDE <header> (in pinnedTopRight, a sibling of the topbar strip), so the header-scoped probe (header.querySelectorAll) returned null — the CTA had to be typed in by hand, and the globals scorecard read a false mismatch (live headerCta:null vs ours SERVICE AREAS; the live pill exists, the probe just couldn't reach it). The tempting guard — "exclude anything in a pinnedTop* container as topbar" — is a trap: the CTA pill lives in pinnedTopRight, the SAME pinnedTop* family as the contact strip (pinnedTopCenter). And the <header> bounding box spans the whole chrome (topbar row + nav row), so the Y-band alone can't separate the pill (cy 108) from the topbar phone/email links (cy 26).

Fix: measure it on the capture plane like the other chrome dims — gather outside-<header> anchors in the header Y-band in-page (chrome.headerRaw.ctaCandidates, each with a button-styled flag) and pick the winner in a pure node-side classifyHeaderCta. Two geometric guards do the separating a container-id can't: button-styling (opaque bg OR a visible border — the pill has a white fill + 2px brand border + 50px radius; topbar contact links are transparent) splits the CTA from text links; a nav-row floor (cy ≥ navTop − pad) excludes the topbar row above the nav — decisively proven on garvanbay, whose topbar carries a styled "Book an appointment" pill that the floor correctly rejects (cy 30 < navTop 198) so garvanbay stays headerCta:null; a header-band ceiling (cy ≤ headerBottom + pad) excludes hero/floating buttons below. Href is same-origin-normalised in-page (new URL(h, location.href) → pathname when internal) so the live absolute URL and our relative one compare equal in the scorecard AND our rendered link stays same-origin. One detector, two payoffs: the CTA auto-detects (feeds the seed-from-build header overlay, reproducible-from-capture) AND the globals headerCta mismatch flips to agree.

Detection rule: a chrome element pinned outside its semantic parent (el.closest('header') null) is the trimtech "pinned-layer children escape coverage" shape — don't identify it by the pinned CONTAINER (Wix reuses pinnedTop* for both the topbar and a header CTA); identify it by what it IS (button-styled) and WHERE in the header band it sits (nav row, not the topbar row above). A styled button in the topbar is a topbar CTA, not a header CTA — the row it sits in decides, not the styling alone. And a "live-vs-ours mismatch" on a field OUR side measures but the LIVE probe can't reach is a probe reach gap, not a real divergence — fix the probe, don't accept the mismatch.

Status: shipped 2026-07-12 pt4 (header fast-follow #2; classifyHeaderCta + ctaCandidates gather in capture-section-backgrounds.js; CMS lane; verified on live WCP (detects SERVICE AREAS → /service-areas) + garvanbay (null, styled-topbar-CTA rejected) + globals mismatch closed → agree; +17 validate checks). Stacked-center CTA render + per-site static lane deferred (known-issues).

Wix's pinned layer holds SEVERAL unrelated widgets — partition it by the header band and by href class, never by container shape

Bites: [id^="pinned"] is not one thing. Live WCP puts four distinct widgets in it: the topbar contact run (pinnedTopCenter), the header CTA pill (pinnedTopRight), a floating ENQUIRE pill (pinnedMiddleRight), and a WhatsApp button + an "Up" scroll-to-top sharing pinnedBottomRight. The static-HTML matchers try to tell them apart by container shapeFloatingCTA.js fires on "a pinned container with exactly one link", FloatingSocial.js on "a pinned container with a whatsapp/facebook link" — which works on WCP only by luck, and hardcodes position: 'right' because static HTML has no geometry. Get the partition wrong and a social button lands in the CTA slot (which is exactly what a hand-written D1 patch then enshrined).

Fix: two probes split the pinned layer at the header band, as mirror images that must stay in step: - headerGeometry claims candidates inside the band (cy ≤ headerBottom + 40) → the header CTA. - floatingGeometry claims those below it (cy > headerBottom + 40) → the floating widgets.

Within the floating set, partition by href class, not by geometry or container: a known social platform (socialPlatformOf) is a social item and never the CTA; a bare / or # href (or Up/Top text) is a scroll-to-top and is recognised and deliberately dropped (it has no CMS home — without the test its / href reads as a CTA to the homepage); what remains, button-styled with a navigable href, is the CTA. Position comes from measured viewport-corner distances, clamped to each block's own enum.

Two traps built into the probe: - The pinned container is zero-height (children absolutely positioned) — the same trap that hid the topbar contact run from the bg probe. Measure the children, never the box. - "Floating" is position: fixed|sticky or a [id^="pinned"] ancestor. Neither test alone is sufficient across Wix templates.

Detection rule: when two matchers can both fire on the same DOM subtree and only a container's shape separates them, they are one dimension that hasn't been measured yet. A matcher that is right "by construction" on your one reference site is a matcher that is guessing.

Status: shipped 2026-07-13 pt3 (floatingGeometry + classifyFloatingCta / classifyFloatingSocial; schemaVersion 4; live WCP ENQUIRE@right + whatsapp@bottom-right, garvanbay 0 candidates → null/faithful; +40 validate checks; globals-diff floating peer reports live-vs-ours agree: true on both).

A dimension whose inputs are already-BOUNDED capture values derives at the JOIN — no re-crawl (unlike a raw-geometry dimension)

Bites: the hero variant (carousel / video / full-bleed / side-image / text) is a design dimension like scrim or imageSide, but its inputs — the full-bleed bg.media flag, the measured imageSide, and the expected asset COUNTS (assets.{slides,videos}) — are all already classified and persisted in section-backgrounds.json. So unlike classifyScrim / classifyImageSide (which read RAW geometry — _styleRaw, scrimAlpha — that only exists in-page and MUST be computed at capture time), classifyHeroVariant is a pure function of persisted BOUNDED values → it re-derives at the seed join (seed-from-build.js#makeStyleLookup) over a stored capture with no re-crawl. That let the whole capability ship and verify against the existing v2 captures, sidestepping the re-crawl brand-nondeterminism landmine (known-issues).

Detection rule: before adding a new classifier to capture-section-backgrounds.js (which forces a re-crawl to land its output), check whether its inputs are already in the stored capture as bounded values. If so, derive it at the JOIN instead — network-free, verifiable on existing artifacts. Only dimensions that need raw geometry / computed-style (not persisted) must live in the capture. And when a measured enum retires an inference (here imageSide && !mediavariant), it's zero-regression iff the enum reproduces the inference's routing for every shipped case AND the render decision that could DROP content (media-vs-text) stays on hasMedia, not the new enum — so a mis-measured text variant on a hero that carries an image never suppresses the image.

Status: shipped 2026-07-12 pt2 (hero variant; classifyHeroVariant in lib/section-style.js, derived in seed-from-build.js#makeStyleLookup, stamped in lib/cms/transformer.js, routed in CMS Hero.astro; carousel/video render proven locally, zero-regression diff on garvanbay + WCP seeds).

Bites: ADR-0009 measures a hero variant: carousel from the slide COUNT (resolveSectionAssets counts <li>/slide elements), but rendering the carousel needs the actual frame URLs — which the count-only capture never recorded, so slideshowUrls stayed "" and the measured carousel collapsed to its single poster. Recovering the 8 WCP frames hit three traps, each of which silently yields the wrong result rather than an error:

  1. Lazy-mount. The slideshow virtualizes to ~2 mounted <img> at a time — a single in-page pass gets 1-2 of 8. Frames only appear as you advance, so it needs the interaction/rotation passes (ported from capture-layout.js#captureSlides), which are page-level (clicks + waits) and can't live inside a page.evaluate.
  2. De-activation by the shared crawl's full-page scroll. The section-bg crawl scrolls the whole page (to mount lazy widgets) then back to top — which leaves the hero slideshow with its "next" arrow PRESENT but INERT (clicks don't advance → recovers 1/8). scrollIntoView on the slideshow + a settle re-activates it → 8/8. Compounding it: the wrong control ([data-testid="nextButton"]) matches FIRST and clicks-without-advancing then vanishes; the [aria-label*="next"] / [class*="next"] arrows are the ones that actually advance — so selector ORDER matters, and the control must be RE-QUERIED each iteration (Wix re-mounts the arrow → a cached handle goes stale).
  3. Migration skips absolute URLs. The enumerated frames are absolute static.wixstatic.com/media/… URLs, but transform-seed-images#isAlreadyMigrated treats ANY https:// as external → skips it → the frame never reaches R2 (would_upload: 0). Emit the /assets/images/<basename> local-path form (mirroring Hero.js#slideshowImages) so the migration recognises, downloads, and uploads it.

Fix: a --slides-gated page-level captureHeroSlides (OFF by default — the rotation poll adds ~15-25s, unwanted on the always-run scorecard capture), scoped to the first wixui-slideshow (the hero; a reviews carousel further down is bg.media:false so it's excluded by construction), re-activates the slideshow, advances via the working arrows with a rotation top-up toward the measured count (loud-reports a shortfall — never pads), and emits /assets/images/ refs deduped by media id. Attached to the hero content section's assets.slideUrls, joined by makeStyleLookup onto fcr.hero.slideshowUrls exactly like variant/style (so it's reproducible-from-capture, not a manual stamp), migrated to r2: by transform-seed-images#migrateSlideshowUrls. hero-4.astro's rotating-carousel renderer (slides>video>image priority) was already capable — the whole gap was frame DATA, not render code.

Detection rule: when a MEASURED shape (carousel/video) renders as its fallback (single poster), the shape and its ASSET payload are decoupled — check whether the asset half was CAPTURED (URLs) or only COUNTED. Enumerating an interactive Wix widget needs page-level passes AND a re-activation after any full-page scroll; verify the recovered count against the measured expectation and loud-report a shortfall. And a data migration that string-matches https:// as "external" will silently skip a freshly-captured LIVE URL — feed it the pipeline's local-path form.

Status: shipped 2026-07-12 pt4 (ADR-0009 Slice 5a carousel frames; captureHeroSlides in capture-section-backgrounds.js behind --slides, slideUrls join in seed-from-build#makeStyleLookup + transformer.js; CMS lane. WCP home renders the 8-frame carousel LIVE — 8/8 enumerated, 7 uploaded + 1 idempotent to R2, slides-dropped/media-missing cleared; garvanbay no-op; join reproducibility proven. Hero video (video.wixstatic.com) migration is Slice 5b.).

Bites: WCP's hero video was acquired (ADR-0010 slice B) and still didn't play: hero-4 renders slides > video, and the home hero is a carousel, so the 8 stills won. The tempting reads are both wrong. "The video is a background BEHIND the carousel" → then it would need a z-layer and the carousel would cover it. "Add a videoSlideIndex field" → new schema surface for something the data already encodes. What live actually does (measured, not inferred — Playwright on the live page: one <video> inside the slideshow, readyState 4, paused:false, currentTime advancing) is simpler: the video IS one of the slides. Wix paints a video slide as its poster still (<videoId>f000.jpg) until the player mounts — which is exactly why our arrow-advance frame enumeration captured it as an ordinary image. The poster was sitting in our 8 frames all along, at index 7.

Fix: the slide list is an ordered ref list, so the video rides in it at its own position: replace the poster ref with the video's r2:…mp4 ref at the same index (8 slides stay 8, order preserved, no new field, editor-visible), and have hero-4 render any video-extension slide as <video> and everything else as <img>. Two details that matter: (1) the poster↔video join is by media id via the API's own posters[] (Wix names them <videoId>f000.jpg), and the id must be run through the SAME normaliseStem that produced the R2 key — the key stem is a lossy normalisation (ADR-0005 §3: _79 decodes to q, eating two chars), so a naive _- swap silently fails to match; (2) a video slide must hold for its own duration — a 23s clip in a 4.5s image slot is its own fidelity bug (live gives it the full run: measured currentTime well past 4.5s while active).

Detection rule: when an acquired asset still doesn't render, ask what the live platform actually does with it and measure that on the live page — don't design from the data shape. A carousel that "has a video somewhere" usually has the video AS a frame, and its poster is already in your captured frames (same media id, f000 suffix). And when a shared canonical block grows a new branch, prove the other consumer never enters it (garvanbay renders hero-4 with zero hero-4-slides → the change is inert for it) rather than reasoning that it's safe.

Status: shipped 2026-07-12 pt5 (ADR-0009 Slice 5b + ADR-0010 slice B; linkVideoSlide in transform-seed-images.mjs, video-slide branch + per-slide dwell in hero-4.astro; WCP home plays the video LIVE, worker 48441217).

A zero-height pinned container hides its measurable children from the bg probe — measure the topbar contact RUN, not the box; and land it by read-mutate-write, never a reseed

Bites: WCP's topbar (phone | email | location) is Wix's #pinnedTopCenter, but the container renders at height 0 (its children are absolutely positioned) — so resolveSectionBg($pinned) read source: zero-size and the topbar looked empty/absent to capture. Compounding it: only the phone survives in the static body.html; the email (a mailto: link) and location (a text run) are JS-rendered, so the matcher (off static HTML) left fcr.topbar.email/.location empty and the deployed topbar rendered phone-only. Two blind spots hid the data in plain sight — the container measures zero-height (bg/bbox probe blind) and the run is post-JS-only (static matcher blind). This is the header-layout authority lesson one strip up: a global/chrome field the matcher can't reach is capture-plane work.

Fix: measure it on the post-JS capture plane, reading the measurable children, not the zero-height box. chrome.topbarContact (capture-section-backgrounds.js) scopes to #pinnedTopCenter, takes the mailto: href for email, and segments the visible run on | classifying each piece (email / phone / location) — an additive peer of chrome.headerRaw that leaves the bg-only chrome.topbar untouched (globals-diff.js still reads it). seed-from-build overlays it fill-if-empty after the header overlay, so a matcher-found or operator value is never clobbered — proven a faithful no-op on garvanbay (its matcher already had email/location → topbar byte-identical) while WCP gained both. Landing corollary — surgical read-mutate-write, never a reseed: the regenerated seed's header CTA is empty (WCP's SERVICE AREAS pill is pinned OUTSIDE <header>, invisible to the capture — fast-follow #2), so a full reseed would have reverted the manually-set CTA. Instead: read the LIVE ec_globals.content, mutate only the topbar's two fields, write the whole array back — every other block (header CTA, native operator block) preserved verbatim. Rendered-artifact verified on the deployed topbar.

Detection rule: a chrome region that reads zero-size/absent from a background or bbox probe is not necessarily absent — a Wix pinned strip collapses its container to zero-height while its children paint; probe the children's rects and text, not the wrapper. And when a render slot the block already supports arrives empty, check whether it's a capture-plane miss (JS-rendered, invisible to the static matcher) before assuming the slot is broken — measure it post-JS, don't touch the renderer. When you then land a surgical global fix, read LIVE and mutate in place: a regen/reseed can silently revert a capture-invisible manually-set field (the same reseed-revert landmine as "Fixing a silently-broken sync is destructive when the two stores have diverged", now triggered by a field the capture can't re-derive).

Status: shipped 2026-07-12 pt3 (topbar fast-follow #1; topbarContact in capture-section-backgrounds.js, fill-if-empty overlay in seed-from-build.js, deployed to WCP live D1 via surgical ec_globals read-mutate-write + rendered verified). Unlocked by the single v3 re-capture that also landed hero-variant real measurement + globals headerLayout live-vs-ours numbers.


Sections / matchers

TeamGrid false-positives on services

Bites: TeamGrid matcher fired at priority 75 on any section with 2+ content images and matching paragraph count. wfpainters services (farm painting, commercial cleaning, roof painting, tarmac restoration) hit this signal and rendered as 4 team-member cards instead of a service grid.

Fix: lib/matchers/TeamGrid.js#match now requires ≥ 2 paragraph / heading text-starts to match a name pattern (FIRST_NAME_RE) AND the matched text is ≥ 6 chars AND contains a space. "We specialise…" / "When it comes…" / "Bring faded driveways…" all start with a single capitalized word that fails the space + length filter. Real team bios ("Tom Holmes BBS, FCCA", "Cathy Dunne, Senior Accountant") pass.

When TeamGrid bows out, ServiceGrid (priority 70 — same shape, no name signal required) takes over and routes to features-3.astro.

Status: shipped 2026-05-09.

Checklist sections (heading + ✔-prefixed bullets)

Bites: USPBar/InfoCards both partially-matched and rendered nearly empty content (heading-only, or paired bullets as title+description cards).

Fix: lib/matchers/Checklist.js (priority 75 — beats both). Detects 50%+ of paragraphs starting with [✔✓☑], strips prefix, emits one item per bullet. Renders via dedicated lib/components-v3/blocks/checklist.astro (borderless inline list).

Status: shipped 2026-05-08.

Dedupe ignored matchers without heading/text props

Bites: Wix nests sections (outer wrapper + inner content with same data). Dedupe's signature rule used matcher | heading | text — fine for About, CTAStrip, FAQ, Hero. But USPBar / Checklist / Gallery extract returns {items} with no heading field, so the signature was empty and dedupe skipped. wfpainters homepage was emitting two features-1 (USPBar) blocks for the same trust-badge content.

Fix: lib/assembler-fulldev/dedupe.js#sigBody falls back to items.slice(0,3).map(it.text || it.title).join('|') when no heading / text. Stable across matchers that emit lists.

Status: shipped 2026-05-09.

TeamGrid heading regex eats credentials

Bites: First version matched Tom Holmes but stopped before BBS, FCCA because [A-Z][a-z]+ doesn't allow an internal capital (McNamara) and the credentials clause wanted \s+ not ,.

Fix: lib/matchers/TeamGrid.js#FIRST_NAME_RE accepts \p{L}'’- chars within a name word and trailing [\s,]+[A-Z]{2,} groups for credential strings (2+ caps rules out "S" from "Senior").

Status: shipped 2026-05-08.

TeamGrid DOM-order ≠ visual order (seniority)

Bites: Wix uses absolute positioning so DOM-order doesn't track visual order. Garvanbay's about page rendered Tom → Cathy → John → Orla, live shows Tom → John → Cathy → Orla.

Fix: Sort items by numeric prefix in image alt (001.jpg, 002.jpg …) — FCR upload-order convention tracks seniority. Items without a numeric prefix go last.

Better fix queued: sort by image y-position from per-page capture-layout.js output. General solution; needs assembler-fulldev to load <slug>.layout.json (currently only homepage is captured).

Status: alt-numeric shipped 2026-05-08; layout-y queued.

Multi-line address in Contact section

Bites: Live FCR contact pages render the address as a single <p> with <br>-separated lines. Default cleanText flattens to one string, losing the line breaks; the contact slot showed "Garvanbay Accounting 26A Parnell Street Dungarvan Waterford Ireland" run-on.

Fix: lib/matchers/Contact.js#extract walks the address <p>'s child nodes preserving <br> separators, yields addressLines: string[]. Translator emits structured <div class="contact-info-name"> + lines.

Status: shipped 2026-05-09.

Contact form recaptcha + Wix-internal hidden fields

Bites: Contact matcher surfaced every <input> including g-recaptcha-response, hidden honeypots, and Wix-mangled names like textarea_comp-l6.... The form rendered with junk fields.

Fix: lib/assembler-fulldev/translate.js#translateContact filters by name regex (drop recaptcha/honeypot/empty-name/hidden/checkbox), maps common field names → human labels (First name / Last name / Email / Message). Falls back to a sane 4-field shape if extraction is too sparse.

Status: shipped 2026-05-09.

Phone/email as plain text instead of tel: / mailto:

Bites: Some FCR contact pages render contact info as styled rich-text ("Phone: 058 89555") not anchors. Contact matcher only pulled <a href="tel:"> so phone went missing.

Fix: lib/matchers/Contact.js#extract — fallback regex scan for IE-style phone patterns and email addresses in paragraph text when the anchor pass yields zero.

Status: shipped 2026-05-09.

Bites: LocationMap matcher pulls address from the section's first short <p> — flaky because the address often isn't in the section's prose at all. Wix mapSrc is a parastorage URL that won't load outside Wix.

Fix: lib/assembler-fulldev/emit.js#applyCrossEntryBackfills looks up the footer's "Address" column and joins the non-link entries to backfill the LocationMap's address prop. Map-wcp embeds Google Maps via maps.google.com/maps?q=...&output=embed (no API key).

Status: shipped 2026-05-09.

A Wix wrapper can hold TWO bands — so a section's FIRST heading is not its own, and the second band has no block at all

Bites: WCP's home fcr.faq carried the cta ribbon's copy as its heading ("Ready to transform your property? Call or email us anytime…") instead of the live FAQ's own "Waterford County Painters FAQ". FAQ.extract read $el.find('h1, h2').first() — reasonable, and wrong here, because Wix put the cta band and the FAQ band inside one <section> (comp-lyzpdyil, plus its nested twin comp-lyzscb43): two h2s, one accordion, no boundary between them. This is the extraction-side twin of "A comp-id join is only as honest as Wix's WRAPPER STRUCTURE" (below) — the same wrapper that poisoned the tone join poisons the heading, one layer up.

Two traps in the obvious remedies:

  • "Take the accordion's own heading" — the heading is not inside the accordion subtree on any measured section, and it is not a DOM sibling either (Wix's nesting), so prevAll() finds nothing. Document order is the only usable proximity.
  • "Prefer the heading that says FAQ" — lexical, so it dies on any site whose FAQ band isn't titled "FAQ" (garvanbay's reads "Frequently asked questions"), and it silently mis-fires wherever a later heading happens to contain the word.

Fix: take the last h1/h2 that precedes the accordion in document order; fall back to the historical .first() when there is no accordion (the h3 strategy) or nothing precedes it. Structural, not lexical. Blast radius measured before shipping, across both reference sites: 45 sections unchanged, 0 declines, 2 changed — both the home wrapper double, which dedupe collapses to one block. garvanbay seed: 0 diffs.

The half that is NOT an extraction bug — and it is the bigger one. There is no <section> for the cta band at all, so our pipeline emits one block for a two-band wrapper and the cta band has no home. Fixing the heading therefore unmasked the loss: before, the cta copy was visible only because it was squatting in the FAQ's heading slot ("Removing a duplicate can UNMASK a data-loss bug", below — same family, different cause). The real remedy is segmentation — one source section → two blocks, the SPLIT_TRANSLATORS shape translateAbout already uses for About→content+gallery — not a bigger heading heuristic.

Detection rule: when a block carries a heading that belongs to the adjacent band, don't reach for a smarter heading picker — ask how many bands the section holds. If the answer is two, the heading is a symptom and the missing block is the defect. And check what the wrong value was accidentally surfacing before you correct it: a wrong field in the right place is often the only reason some content renders at all.

Status: heading fix shipped 2026-07-16 pt2 (headingBeforeAccordion in lib/matchers/FAQ.js; both lanes — 47 static-lane FAQ sections translate clean, garvanbay byte-identical). The cta-band segmentation gap is open (known-issues).

An h2 is not a band — measure the DEFECT (orphaned copy), not the SHAPE, and ask the PAGE not the section

Bites: measuring how many sections carry a two-band wrapper (the entry above), to decide whether the fix is a matcher special-case or a buildSections change. The obvious detector — a section with ≥2 distinct h2s — is wrong three different ways, and each wrong version returns a confident number.

  1. Wix marks CARD titles as h2. WCP's 4-card USP ribbon and garvanbay's 3-card article-teaser feed each give every card an h2. To the DOM they are indistinguishable from a genuine two-band wrapper — same nesting, same tag. No structural test separates them, so don't look for one: the question is not what the markup looks like, it is whether the copy survives into a block's props. A card title lands in props.items[]; WCP's cta ribbon lands nowhere. Measure the defect, not its silhouette.
  2. Section-scoped orphan-checking INVENTS the defect. Asking "is this h2 in this section's props" reports garvanbay's home FAQ as a two-band wrapper — its teaser h2s really are absent from the FAQ block's props. They are not orphaned: buildSections' post-list-pro-gallery branch enumerates that band separately and BlogPosts emits it. Copy has a home on the PAGE or it does not; a section cannot answer.
  3. Bidirectional substring matching HIDES the defect. Once the haystack is every block on the page, n.includes(p) lets any short prop ("24/7", "Call") that happens to sit inside a long h2 mark it homed. WCP's known orphan flipped to ok and both sites reported zero. Containment must be one-way: some prop carries the whole heading (p.includes(n)).

Faults 1 and 2 inflate the count; fault 3 zeroes it. Only the third is dangerous, because "no problem here" is the answer nobody re-checks — the empty-pool lesson from ADR-0010's second amendment, in a different costume. It was caught only by the positive control (the known WCP case must be detected), which is the whole argument for running a detector against a case you already know the answer to before you believe it on cases you don't.

And the measurement paid off in a direction nobody scoped: the two-band SHAPE is 2/2 sites (both home, both FAQ-paired) while the DEFECT is 1/2. The variable is not whether Wix bundles two bands into one <section> — it always does — but whether the second band has an independent enumeration path. buildSections already grants one band type (blog) exactly that. The prevalence question ("quirk or pattern?") stayed unanswered at n=2; the architecture question got an answer anyway.

Status: scripts/measure-two-band-wrappers.js (2026-07-17), read-only, runs the real seed lane. node scripts/measure-two-band-wrappers.js --all.

An honest re-attribution can LOWER a score — check WHICH row moved before calling it a regression

Bites: the FAQ-heading fix above made WCP home's S fall 81.6 → 79.6, on a page that had objectively improved (the FAQ band went from navy-with-the-wrong- heading to white-with-the-right-one, and an independent re-capture of the deployed page confirmed it). Read as a scalar, the slice looks like a regression.

It isn't. The aligner is heading-similarity-driven, so before the fix our FAQ block — whose heading was the cta copy — paired with live's cta row, leaving live's FAQ row unmatched → dropLive faq (weight 0.75). After, our FAQ pairs with live's FAQ row correctly, leaving live's cta band unmatched → dropLive content (weight 1.0). One drop either way; the score fell because the drop is now attributed to the heavier, and true, role.

Kin to "A veto must fire on the ABSENCE of a thing, not on a metric's failure to PAIR it" (below) and to ADR-0011's "Slice 1 will make the numbers WORSE" — but with a wrinkle worth keeping separate: there, the site was unchanged and the instrument stopped lying. Here the site changed for the better AND the instrument stopped being fooled, in the same move.

Detection rule: when a fidelity axis moves the wrong way after a fix, diff the rows, not the number — kind, role, weight, and what paired with what. A drop that changes identity (faqcontent) is a re-attribution, not a regression, and the honest score is the lower one. Never reconcile it by relaxing a threshold; the row list is the evidence.

Status: measured 2026-07-16 pt2 (WCP home; drop row verbatim {kind:"dropLive", role:"content", weight:1, heading:"Ready to transform your property?…"}).


One emitter, two item shapes — a matcher→emitter map that points TWO matchers at one emitter silently destroys the odd shape out (and the loud-fail placeholder is the SYMPTOM, not the bug)

Bites: lib/cms/transformer.js mapped USPBar: fromLogoStrip ("USPBar is a trust strip — render via the logo-strip variant"). It reads reasonable, and it was wrong for the ~half of USPBars that carry no logos. The two matchers emit different item shapes:

LogoStrip.extract → { logos: [{src, alt}] }              image-shaped
USPBar.extract    → { items: [{icon, text}], logos? }    TEXT-shaped (+ optional logos)

fromLogoStrip coalesces props.logos || props.items and then reads l.src off whatever it got. For a text USPBar that fallback hands it {icon, text} items, each of which becomes {src: '', alt: '', href: ''}the text is dropped on the floor and the empty src renders a logo? loud-fail. On WCP: 237 empty items → 276 placeholders across all 24 pages, while live shows a 67px band of pure text ("7-Year Guarantee · Fully insured · Reliable & Long Lasting Results", zero images). The static lane never had it — translateUSPBar maps to features-1 with the text intact, so the two lanes had silently diverged on the same matcher.

Fix: split the emitter by what the section actually carries, not by what the matcher is nominally "like": logos present → fcr.logo-strip; text items → fcr.usp-bar (a new text-bearing block; the standalone form of the ribbon hero-4 already hoists). Never let a shape-specific emitter accept a foreign shape through an || fallback — make the fallback reject, or give the other shape its own emitter.

Detection rule (two, both cheap): 1. The placeholder is a symptom — find the PRODUCER before theorising. A wall of logo?/img? loud-fails invites the story "the capture missed the images" (it invited exactly that here, and the wrong diagnosis got written into the wiki). The build's section-matchers.json sidecar names the matcher behind every emitted block — one lookup showed 94 of 105 "logo strips" were USPBar, and the whole capture-gap theory evaporated. Ask which matcher produced this block? before asking why is its image missing? 2. Audit any matcher→emitter map for many-to-one entries. Two matchers pointing at one emitter is only safe if their extract() shapes are identical. Diff the shapes; if they differ, the emitter is silently coercing one of them. An emitter that reads a field the other matcher never emits (l.src on a {icon,text} item) is a guaranteed data-loss path, and it fails invisibly — the block still renders, just empty. Cross-check the OTHER lane's translator: if it maps the same matcher somewhere else entirely (features-1 vs logo-strip), that disagreement is the tell.

Status: shipped 2026-07-12 pt6 (fromUspBar in lib/cms/transformer.js, new canonical usp-bar.astro + fcr.usp-bar type/adapter/dispatcher; WCP ui-missing 276 → 39, all 237 logo? gone, real logo strips preserved; worker 19a8b763, pages-only D1 write).

Correction (2026-07-13 pt4): the split rule above — "logos present → logo-strip; text items → usp-bar" — is WRONG, and it left half the bug live. It was written as if (props.logos?.length) return fromLogoStrip(...), so imagery wins. That is exactly backwards for the commonest band of all: an icon + text CARD. WCP's home carries four (vat.png → "VAT Registered", shield.png → "CRO No. 633944 Fully insured", …). They have both logos and text, so the logo branch took them, fromLogoStrip kept only {src, alt} — and every card's text was destroyed again, this time without even a placeholder to betray it (the src was present, so nothing loud-failed; it just rendered a bare row of icons where live shows icon-over-label cards). The operator found it by looking at the page; no instrument did.

The correct rule: split on TEXT, not on imagery. A band whose items carry words is a ribbon or a card band, whatever imagery it also has. Only a band with no text at all is a true logo strip — WCP's real 11-logo client strip has zero text and still routes there correctly. The icon travels with the text as an imageUrl (the live icons are client-uploaded images, not named glyphs — see the note below).

Why the original was drawn: the pt6 session was staring at 237 text-only USPBars and fixed exactly that case. "Logos present → it really is a logo strip" is a reasonable read of a two-way split — but the shapes are not two, they are three: text-only, image-only, and both. A binary split on the presence of the rarer field silently mis-files the overlap. When you split an emitter by "what the section carries", enumerate the overlap case explicitly, and pick the field whose presence is decisive (text) over the one that is merely possible (images).

Also corrected: icon is not the right sink for a scraped icon. pt6 set icon: '' deliberately, reasoning that the matcher's icon is an image URL while the Icon primitive takes names (ADR-0004's static map), so a URL there would loud-fail. That reasoning is right and the conclusion — drop it — was wrong: it silently lost the client's artwork. The fix is a separate imageUrl field rendered as an <img>, with icon left for genuine named glyphs. A field with no home is a missing field, not a field to discard.

Still open: WCP's other band (the 7-Year Guarantee ribbon) has icons on live too — but they are inline <svg> vector art, not images. Copying Wix's vector art is the parastorage-IP problem (memory no-wix-css-copy); mapping them onto named lucide glyphs is a design decision, not a mechanical one. That band renders text-only today. See known-issues.

Shipped 2026-07-13 pt4 (worker 51891396): fromUspBar splits on text; fcr.usp-bar gains imageUrl (schema → transformer → adapter → usp-bar.astrotransform-seed-images FIELD_MIGRATIONS). All four cards render text + icon from R2; the 11-logo client strips are unmoved.

An always-matches fallback matcher is zero-regression iff its score sits strictly below every other matcher's minimum positive score — and closing its silent-drop with a loud-warn exposes adjacent drops

Bites: ADR-0009 Slice 4b-i adds a lowest-priority GenericSection matcher that must catch every otherwise-unmatched section (to close the silent-drop) WITHOUT stealing any section a real matcher would claim. Priority alone can't guarantee this — structure-matcher.js#matchSection uses priority only as a score tie-breaker, so a low-priority matcher with a high match() score still wins. The real guarantee is the score gap: GenericSection.match returns a constant 0.05, and no other matcher returns a positive score below About's 0.15 last-resort floor (every specific matcher scores ≥ 0.4; About's floors are 0.15/0.2/0.4). So the only sections GenericSection can claim are those where every other matcher returned exactly 0 — the previously-dropped set. Proven on WCP (24 pages: 0 generic-section blocks, block-type distribution unchanged) — GenericSection is a faithful no-op on a site whose sections are all already matched.

Fix: for any always-matches fallback, set its score to a constant strictly below the minimum positive score any other matcher can emit, and confirm no matcher returns a value in the open gap (0, fallbackScore]. Then the fallback provably claims only the all-zero set (zero-regression by arithmetic, not by testing). Don't rely on priority for last-resort ordering — it's a tie-breaker, not a gate.

Corollary — closing one silent-drop with a loud-warn exposes adjacent drops on the same path. Adding the warn to transformer.js's two previously-silent continues (ADR-0004) immediately surfaced a pre-existing silent drop the same branch was hiding: FloatingCTA/FloatingSocial match on every page but have no CMS PT translator, so the CMS lane was dropping floating rails entirely (48 warns/WCP run). Expect this — the loud-report you add for your target drop lights up its neighbours; triage them (here: a deferred known-issue) rather than narrowing the warn back to silence.

Detection rule: when adding a catch-all to a scored dispatcher, the zero-regression proof is arithmetic — is the fallback's score strictly below every other producer's minimum positive score, with an empty gap? If yes it can only claim the currently-unclaimed set. And when you make a silent drop loud, read the full warn output on a real site: a drop-closing change is also a drop-detector for everything else falling through the same branch. (Render-surface proof for a no-op-on-samples fallback: since GenericSection claimed nothing on either sample site, it was proven by forcing a throwaway block through the live path and reverting — but note a new block type needs a worker redeploy first or the forced instance hits MissingBlock; cf. "A measured dimension whose value equals the block default … prove the render surface another way".)

Status: shipped 2026-07-08 (ADR-0009 Slice 4b-i; lib/matchers/GenericSection.js score 0.05, lib/cms/transformer.js fromGenericSection + loud-warns, lib/assembler-fulldev/translate.js#translateGenericSection, packages/components-v3/…/blocks/generic-section.astro). Live-proven on the garvanbay demo, reverted.

Components / pipeline

A composite from a SINGLE surviving axis is that axis, not a composite — and null-vs-0 makes the failure shape-shift

Bites: the near-match composite re-normalises over the axes present, so with S and G_det both null it emitted composite = T — a mesh 0-section site (renders empty chrome, S+G_det unmeasurable) read composite 95.5 / 100, a flattering row for a broken site. The gate still refused to ship it (the S floor fails on null), but a config/queue reader trusts the composite number, and fit-calibration would too if the row ever reached it. Worse, the failure shape-shifts on page count: a 1-page mesh site scores S=null (bluestars), a multi-page one scores S=0 (sweeney) — so a detector keyed on "S is null" silently misses half the class.

Fix: composite() (lib/scorecard-gate.js) requires ≥2 axes; fewer returns null and the gate holds honestly. The 2-of-3 partial (e.g. no --shots → G_det null) is untouched — it stays a legitimate ranking number the gate still can't ship on. batch-scorecard already excludes any missingAxes row before composite, so the fit was never fed a mesh site; this only kills the misleading per-site number. Detection rule: when a metric is a weighted mean over "present" inputs, decide the MINIMUM inputs below which the mean is meaningless and return null there — a mean of one number is that number wearing the mean's name. And when you count a defect class, count it by the pipeline's own signal (here the S-axis alignment structural.pages[].rows, which reads 0 for BOTH the null and the 0 shape), never by a proxy that one shape evades.

Status: shipped 2026-07-21 (composite() ≥2-axes rule + honest hold reason, +4 gate-validate checks; scripts/measure-mesh-prevalence.mjs counts the class by the S-axis input with positive+negative controls — 3/41 corpus, 7.3%).

A nested/doubled Wix /media/ URL mangles when split on the FIRST /media/ — split on the LAST, and a < N length guard won't catch a protocol-slug

Bites: lib/dom-pipeline.js self-hosts wixstatic images by taking the media id from imgUrl.split('/media/')[1]. On sreenanandcompany.ie a nested/doubled URL appeared in the crawled HTML — https://static.wixstatic.com/media/https://static.wixstatic.com/media/<id>.jpg — so split('/media/')[1] returned https://static.wixstatic.com/media/<id>.jpg, whose first path segment is https:. The id-normalise .replace(/[^a-zA-Z0-9._~-]/g, '_') turned the : into _ → media id https_, which the mediaId.length < 5 guard waved through (it is length 6) → the ref /assets/images/https_.jpg was baked into body.html and carried into the seed, where transform-seed-images finally 403'd fetching static.wixstatic.com/media/https_.jpg. The loud 403 was correct — but it fired one stage too late, on a value produced two stages upstream.

Fix: split on the last /media/ (parts[parts.length - 1]) — the innermost segment is the real id, and a normal single-/media/ URL is a no-op (byte-identical) — plus a ^https?[:_]-prefix guard that skips a residual protocol-mangle loudly instead of baking it. Extracted to a pure lib/wix-media-ref.js#deriveWixMediaRef + wix-media-ref.validate.mjs. Detection rule: a .split(sep)[1] that assumes the separator appears once is a latent bug the moment the payload can contain the separator (a URL nested in a URL); take the last occurrence when the innermost is the one you want. And a length/character guard tuned to reject garbage (< 5) won't reject plausible garbage (https_) — guard on the value's SHAPE (does a real id start with a protocol?), not just its size. Recovery for a value baked upstream is a re-run of the producing stage (--force crawl), not the consuming one.

Status: shipped 2026-07-21 (dom-pipeline wired to deriveWixMediaRef; sreenanandcompany box re-crawl confirmation pending).

A fidelity metric that scores presence + dims is blind to mis-renders and non-image asset-drops — close them with additive reporting projections, never by moving the score

Bites: the near-match scorecard vetoes on absence (section drop → criticalSectionDropped; unmatched → genericFallbackCritical; missing image → loud-fail). But a section that matched and rendered the WRONG variant banks a large presence share, and its dim disagrees cost ≈0 — matcherGap.propsWithheld counts only ours-missing, never disagree. And a non-image asset dropped (hero video, collapsed carousel) degrades to a poster/single frame a screenshot can't distinguish from success. Both are silent by construction: the pt4 WCP hero mis-render was caught by operator report, not the metric.

Fix: add the missing signal as an additive reporting projection — never by changing the scored dims, which moves S and breaks the byte-identical invariant every scorecard slice holds (see "A diagnostic that projects debt already inside a score must NOT re-penalise"). variantMismatch (lib/structural-diff.js) counts decisive-dim disagrees on critical-role sections; the asset-manifest.json sidecar (lib/cms/asset-manifest.js + capture assets + seed-from-build) records expected-vs-supplied asset counts + migration status. Both report now; gate vetoes + thresholds wait for Slice 6 calibration. Scope trap: only dims already in the scored DIMS (tone, imageSide) can be counted without moving S — promoting hero-variant/ scrim/textTone to scored dims is itself a calibrated change, not free.

Detection rule: when a fidelity bug "looks obvious" but the metric never flagged it, classify it — absence (already caught), wrong-value (variant-mismatch), or missing-asset (asset-manifest). A new blind spot gets a new additive projection, not a rescore. And the metric that catches YOUR OWN silent degradation is the whole point of re-measuring the deployed output.

Status: shipped 2026-07-11 (reporting mode; commit 6bc7a95). Full backlog + process: capability-backlog.md.

An item-image adapter must resolve via getImageSrc (ImageField-shaped), not scalar-only resolveImageRef — a migrated repeater image arrives object-shaped

Bites: reseeding WCP with r2:-migrated images crashed every page with TypeError: ref.startsWith is not a function at resolveImageRef, truncating the SSR response mid-render (HTTP 200, ~13 KB, body cut off right after the header). Root cause: transform-seed-images.mjs's sibling shape rewrites repeater item images (fcr.logo-strip/gallery/footer items[].src) to an object {src, originalName}, but the LogoStrip/Gallery/Footer adapters called resolveImageRef(item.src) — which is scalar-only (ref.startsWith(...)) and throws on an object. The top-level-field adapters (Hero/About/ServiceGrid/TeamGrid) already used the shape-agnostic getImageSrc and were fine; these three were the stragglers.

Fix: every adapter that reads a possibly-migrated image field must resolve it with getImageSrc (packages/components-v3/src/lib/get-image-src.ts), which accepts the ADR-0005 ImageField (scalar string OR {src, originalName?, alt?}) and funnels through resolveImageRef internally. Use resolveImageRef only on a value known to be a scalar ref. Swapped all three adapters — this also pre-empts garvanbay's latent copy (its seed has 7 of the same unflattened objects that would crash identically once surfaced/redeployed).

Detection rule (loud-report exposes adjacent drops): a whole-page SSR truncation with a 200 status = an exception thrown mid-stream; wrangler tail names the throwing function in one shot (here resolveImageRefLogoStrip). And when a data migration changes a field's shape, grep every consumer for the scalar-only resolver before reseeding — a shape the top-level adapters tolerate can still crash a repeater adapter that skipped the shape-agnostic helper.

Status: shipped 2026-07-10 pt4 (Footer/Gallery/LogoStrip adapters → getImageSrc).

An image field that never renders on-page is still a shipped image field — the migration walker and the resolver must cover the INVISIBLE ones too

Bites: fcr.business.imageUrl is the og:image + LocalBusiness JSON-LD image — the picture every link preview and Google rich result uses, and the one image field that is never painted on a page. It sat as a raw /assets/logos/… path (dead on an R2-served worker) through every image migration, every loud-fail sweep, and a placeholder hunt that drove the visible placeholders to zero — because nothing that looks at pages can see it. Two independent misses, both caused by the field being invisible:

  1. transform-seed-images.mjs's FIELD_MIGRATIONS table had no fcr.business entry, so the walker never touched it.
  2. Base.astro fed the field straight into new URL(...) with no getImageSrc — the only image consumer in the CMS lane resolving nothing. So even a correctly-migrated value would have emitted garbage (r2:… is not a URL; the object shape stringifies to [object Object]).

The asset was in R2 the whole time, under the very key the header logo already used — so the reconciliation's referenced-but-missing bucket was the only instrument that could see the bug, and it is reporting-only.

Fix: FIELD_MIGRATIONS['fcr.business'] = [{ path: ['imageUrl'], shape: 'object' }]; Base.astro resolves via getImageSrc for both og:image and the JSON-LD image. Idempotent — the field hashed to the existing R2 object (0 uploads), which is itself the proof that the asset was never missing.

Detection rule: enumerate image fields from the schema, not from what you can see on a rendered page. Any field feeding <meta>, JSON-LD, a sitemap, or a feed is invisible to placeholder sweeps, screenshot diffs, and the whole loud-fail apparatus — it can only be caught by a data-side reconciliation. When rendered-placeholders is 0 but referenced-but-missing is not, believe the latter.

Status: shipped 2026-07-13. referenced-but-missing 1 → 0 on WCP.

Floating CTA + Floating Social — components built

Bites originally: Both translators emitted deferred passthroughs referencing component files that didn't exist; build failed with rollup import errors as soon as the matchers fired (wfpainters homepage).

Fix: - lib/components-v3/blocks/floating-cta-wcp.astro — pinned-corner pill button. position: 'bottom-right' for tel/mailto links, 'right' for enquire-page links (vertical side-rail rotated 90°). Hidden below 640px to avoid colliding with sticky-mobile-cta. - lib/components-v3/blocks/floating-social-wcp.astro — vertical icon stack pinned to viewport edge. Maps platform names (whatsapp / facebook / google-reviews / etc.) to lucide icons. Same mobile-hide rule. - lib/assembler-fulldev/translate.js — translators emit real components with cleaned items (filter empty url) and infer position from href shape.

Status: shipped 2026-05-09.

Map mapSrc points at parastorage

Bites: Wix's mapSrc extracted from iframes is a Wix-internal URL that won't resolve outside their CDN.

Fix: Ignore extracted mapSrc; embed Google Maps via the no-key maps.google.com/maps?q=ADDRESS&output=embed URL using the address text.

Status: shipped 2026-05-09.

Phase 1 captures desktop viewport only — mobile-only widgets miss

Bites: Wix mobile-only QuickActionBar (sticky bottom phone+email strip) renders only at mobile widths. Phase 1 captures at 1440x900 → QuickActionBar never enters our DOM extract.

Fix: Synthesise the bar from the TopBar entry's contact data. lib/assembler-fulldev/emit.js injects <StickyMobileCta phone email /> at </body> whenever phone or email exists. lib/components-v3/blocks/sticky-mobile-cta.astro hidden above 640px, reserves 3.25rem body padding-bottom on mobile.

Status: shipped 2026-05-08.

Multi-tone heading colour spans

Bites: Wix lets editors recolour individual words inside a heading via inline <span style="color: #...">. Plain-text cleanText loses that; "GARVANBAY ACCOUNTING" rendered as a single colour instead of the live yellow-green + cyan two-tone.

Fix: lib/matchers/About.js#extractHeadingHtml walks heading children preserving color / font-weight / font-style inline styles, sanitises URL/parens/angle-brackets to block XSS, drops unstyled wrappers. Translator prefers headingHtml over escaped plain text.

Status: shipped 2026-05-08.

Service-section accent leaking onto Hero / welcome / CTA-strip

Bites: [data-slot="section-prose"] :is(h1,h2,h3)::before/::after rule applied to every section-prose, so once garvanbay's --service-accent-above-display flipped to block, a yellow line appeared above Hero, content-1, and CTA-strip headings too.

Fix: lib/assembler-fulldev/theme.js scopes accent rules to .content-2 only. Service-heading-color rule too.

Status: shipped 2026-05-08.

Subpage routing — emit per-slug page + content files

Bites: First subpage build clobbered the homepage's index.astro and content.ts.

Fix: lib/assembler-fulldev.js threads slug through meta. lib/assembler-fulldev/emit.js writes src/pages/<slug>.astro and src/data/content-<slug>.ts (homepage + slug='index'/'homepage' stay at index.astro + content.ts).

Status: shipped 2026-05-08.

Cross-entry backfills must run before content.ts is serialized

Bites: Map address backfill mutated layoutMap inside emitIndexAstro, which runs after emitContentFile. The mutation never reached the runtime data.

Fix: lib/assembler-fulldev/emit.js#applyCrossEntryBackfills lifted out and called at the top of emitProject, before any file writes. Any future cross-entry prop derivation should go in there.

Status: shipped 2026-05-09.

Primitive emits nothing for unrecognised input (silent-fail class)

Bites: primitives that should always render visible content can silently emit nothing on missing/unmatched input. The whole class:

  • Icon with import.meta.glob("node_modules/lucide-static/icons/*.svg") — when consumed via Vite alias (no node_modules adjacent to the primitive file), the glob resolves to an empty set with no build error. Every name → no match → silent emit. Manifested as arrow-right failing across every ServiceGrid tile + phone/mail icons missing from StickyMobileCta.
  • Image with {src && (...)} — silently emits nothing when src is missing. Manifested as TeamGrid Dave card with no avatar (no img element, no broken-image indicator, no operator signal).
  • LogoImage, AvatarImage — same conditional-render pattern.
  • Rating with {rating && (...)} — silent on 0, NaN, malformed numerics.

Fix: any primitive that's expected to render something visible must render a loud-fail placeholder + log a console warning when input is missing/unrecognised. Pattern matches MissingBlock at the dispatcher layer:

{Comp && <Comp ... />}
{isUnresolved && (
  <span class="ui-missing-icon" role="img" ...>{placeholderText}</span>
)}

Plus console.warn in the frontmatter.

Detection rule: when reading any primitive, ask "what does this render given input it doesn't recognise?" If the answer is "nothing" and the primitive isn't structurally optional, it's a silent-fail bug.

Status: shipped 2026-05-09 — Icon (rewritten to explicit static map, see ADR-0004), Image, LogoImage, AvatarImage, Rating. Apply this discipline upward when any new primitive lands or when a layer above (matchers, schema validators) comes into focus.

Workspace-package utility classes need an explicit Tailwind @source

Bites: Tailwind v4 (@tailwindcss/vite) auto-detects its content root at the consuming app (apps/cms) 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 is never generated — the class lands in the HTML with data-* attributes intact but no CSS rule, so it silently doesn't paint. Same root shape as the Icon-glob silent-fail above (a node_modules/Vite-alias boundary breaking a scan) — one layer up, at the CSS build.

Manifested on Slice 2b: section tone bands (bg-muted/bg-secondary/ bg-foreground/plain bg-primary, only in section.astro) rendered white. bg-card/bg-destructive were also silently dropped. Classes that also appear in apps/cms source (bg-background, bg-accent, bg-primary/85 scrims) generated fine — which is what masks the gap.

Fix: emit @source "<rel-path-to-packages/components-v3/src>"; into the Tailwind entry (global.css). Because global.css is regenerated each prebuild, the directive is emitted from emitGlobalCss(theme, { sources }) (commit 13deb1b), not hand-added. The @source path is relative to the CSS file, so each consumer (CMS vs per-site static build, different output depth) must pass its own — the per-site lane still needs it (known-issues).

Detection rule — verify render via the built artifact, not the wiring. The data path and HTML threading all looked correct on inspection; only a production npm run build + grep of the emitted CSS (.bg-muted{…} present? note Tailwind groups selectors: .bg-muted,.bg-muted\/50{…}) revealed the gap. For any "does this style/class actually apply?" question, build and grep the CSS — a class in the HTML proves nothing about whether a rule exists for it.

Status: fixed for CMS 2026-07-05 (13deb1b, ADR-0007). Per-site assembler-fulldev/emit.js carries the same latent gap — see known-issues.

A per-site CSS override must be UNLAYERED to beat an unlayered :root default — @layer loses

Bites: ADR-0009 §5 frames per-site design variation as living in a "higher cascade layer (@layer site > @layer blocks)". Taken literally for the token values, that's a trap. emitGlobalCss emits its aesthetic-token defaults (--radius, --btn-radius, --section-py, --site-shadow-*) in an unlayered :root {}. In the CSS cascade, an unlayered declaration beats any @layer — so an @layer site { :root { --radius: … } } override would silently lose to the default and paint nothing. The classes/vars would all be present in the emitted CSS; only a computed-value check would reveal the no-op (same "verify via the built artifact, not the wiring" discipline as the shadow @theme inline self-reference trap in aesthetic-token-contract.md).

Fix: emitSiteCss emits a plain (unlayered) :root {} appended AFTER emitGlobalCss's output. Same selector, same specificity, same (unlayered) layer → source order decides, so the later site block wins cleanly with no !important. Both lanes concatenate emitGlobalCss(...) + emitSiteCss(design).

// theme.js — the override rides source order, not a cascade layer
const css = emitGlobalCss(theme, opts) + emitSiteCss(design);
// emitSiteCss returns '' for absent/empty design → byte-identical to before.

Detection rule: when a generated CSS var override "does nothing", check whether the thing it's overriding is declared unlayered while the override sits in an @layer. Custom-property values follow the same layer-precedence rules as any declaration: unlayered > any named layer, regardless of source order or specificity. @layer site is the right home for block-targeting rules (data-slot/data-tone selectors, later slices) — not for :root token values that must beat an unlayered default. For those, later + unlayered wins.

Status: shipped 2026-07-06 (ADR-0009 Slice 2a, commit dec213b; emitSiteCss in lib/assembler-fulldev/theme.js).

An unlayered universal reset in an Astro component style silently disables every utility class — the evil twin of "unlayered beats @layer"

Bites: apps/cms/src/layouts/Base.astro carried a hand-rolled reset in its <style is:global>: *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }. Astro component styles are unlayered, and unlayered beats any @layer regardless of order — so this one line nullified every Tailwind padding/margin utility across the whole CMS lane. The utilities were all in the built CSS (.px-3{padding-inline:calc(var(--spacing)*3)} present, @layer utilities), the classes were all in the HTML, and the computed padding was still 0. Manifested as the operator-reported header nav defects (items run together, zero item padding) and a visibly squashed hero — misdiagnosable as a block-CSS bug for hours. The entry above is the same law used deliberately (an unlayered site override outranking layered defaults); this is the law firing by accident.

Fix: delete the hand-rolled reset. Tailwind v4's preflight ships the identical box-sizing/margin/padding reset in @layer base, correctly ranked under utilities — the manual copy added nothing except the unlayered footgun. Fixed for the CMS lane 2026-07-08; scorecard re-run confirmed no regression (G_det actually rose 87.8→88.3 — the restored utilities moved the render closer to live).

Detection rule: when a utility class is in the markup AND its rule is in the built CSS but the computed value is still the reset value, don't stare at the block — enumerate the rules that match the element and their layer (rule.parentRule.name walk in the console): any *-selector rule reporting UNLAYERED after the utilities is the killer. Grep the repo for *::before resets whenever adopting a Tailwind-layered design surface — every Astro <style is:global> is unlayered and outranks all of it.

Status: shipped 2026-07-08 pt6 (Base.astro reset removed; nav metrics tokenized on header-wcp--nav-font-size/--nav-item-px/--nav-row-h, FCR-typical defaults measured on live garvanbay).

The local emdash admin is unreachable while the Access adapter is configured — run dev with CF_ACCESS_TEAM_DOMAIN= (empty) and enter via the dev-bypass URL

Bites: with auth: access(...) configured (which astro.config.mjs does unconditionally — the team domain has a non-empty default), emdash only injects the external-auth routes: injectBuiltinAuthRoutes (passkey and /_emdash/api/auth/ dev-bypass) is skipped entirely. But in DEV the middleware still demands passkey auth for /_emdash/admin. Net: the login page redirect-loops forever (/login → auth/mode → 302 /admin → /login …) and there is no door — the dev-bypass 404s. Easy to misread as a session/cookie bug.

Fix: the config already gates on the env var — set it EMPTY to disable the Access adapter locally, which flips auth to passkey mode and injects dev-bypass:

cd apps/cms && CF_ACCESS_TEAM_DOMAIN= npm run dev
# then authenticate the browser via:
#   http://localhost:4321/_emdash/api/auth/dev-bypass?redirect=/_emdash/admin

astro dev reads the miniflare D1 (.wrangler/state/v3/d1/*), not data.db and not remote — see "astro dev reads a miniflare D1". A fresh miniflare D1 lands on the admin's first-run /setup screen; "Include sample content" applies the baked seed (.emdash/seed.json, regenerate with scripts/emit-seed.mjs).

Detection rule: an admin login page that bounces 200 /login → 302 /admin forever in the dev log = auth-mode mismatch (mode endpoint says external, middleware wants passkey), not a broken session. Check whether the external-auth adapter is active in dev before debugging cookies.

Status: captured 2026-07-08 pt6 (header/nav slice, part A diagnosis).

"Saved ✓" that never persists — emdash 0.9.0 admin fires two racing PUTs per Block-Kit save and the stale one wins

Bites: editing any fcr.* block's fields through the emdash admin's Block-Kit dialog (pages AND globals) reports "Saved ✓", bumps updated_at, and persists nothing. One Save click fires two concurrent PUTs with different payloads (explicit save + autosave, serialized from doc snapshots one edit apart); on a drafts-enabled collection both create draft revisions and the stale one takes the row's draft_revision_id (the fresh edit survives only as an orphaned revision); on a no-drafts collection both write the content column and the stale one lands last — total silent loss. The operator experience is "the menu isn't editable"; the WS-3 "edit → publish → live" success was the race landing fresh-last that day. Direct API PUTs (curl, scripts) persist fine — the server path is sound; the client races itself, and the server compounds it by not serializing concurrent draft-creates (skipRevision only updates in-place when a draftRevisionId already exists — never true for two racers on a clean row).

Fix (pending upstream — the 2026-07-09 upgrade did NOT clear it): the platform was upgraded 0.9.0 → 0.28.1 (1.0.0 on npm is an accidental reverted publish, not the head). Upstream had closed our bug (#1158) as "fixed by #1119" (0.15.0) — but the race reproduced on 0.28.1: two divergent PUTs per save-click, and in 1 of 2 UI trials the stale one took the draft pointer (edit orphaned, gone after reload). The client's two unserialized mutations and the server's non-transactional draft-pointer write are unchanged 0.9.0→0.28.1; there is no autosave-disable option. File the repro against #1158. Harm reduction shipped 2026-07-08 pt6: the globals collection declares supports: ["drafts","revisions"] (seed-from-build.js + committed seeds + live _emdash_collections) so a lost edit is at least a recoverable revision, not gone. Content fixes meanwhile ship via surgical D1 patch, not the admin.

Detection rule: "saved but not persisted" + updated_at moving = count the revisions created per save-click and check which one the draft pointer references. Two revisions in the same second, pointer on the edit-less one → this race. And when diagnosing an admin bug, test the API directly before blaming the schema or the renderer — a clean direct PUT localises the fault to the client in one move.

Status: diagnosed 2026-07-08 pt6 (local repro, pages + globals); upgrade slice ran 2026-07-09 (→0.28.1) and the race persists — blocked on a real upstream fix. See known-issues "Header / nav menu".

The biggest version number on npm may not be the head — verify dist-tags and sibling-package lockstep before targeting a major

Bites: the emdash upgrade slice was scoped in the wiki as "0.9.0 → 1.0.0" because emdash@1.0.0 exists on npm and reads like the obvious target. It is an accidental publish: a changesets/workspace-cycle bug escalated a minor to a major and CI released every package as 1.0.0 (2026-04-27); upstream reverted the same day (PR #796), added CI guards that fail on any non-private 1.x, and continued at 0.8.0 → 0.28.x. Three tells, all checkable in two npm view commands before any scoping: (1) dist-tags.latest was 0.28.1, NOT 1.0.0 — the registry's own head pointer disagreed with the max version; (2) the sibling package we depend on (@emdash-cms/cloudflare) had no 1.0.0 at all, and its latest pinned emdash@0.28.1 exactly (lockstep family — mixed versions can't even install coherently); (3) 1.0.0's publish timestamp predates 0.8.0's. Installing 1.0.0 would have meant April-era code plus phantom peer-deps, sold to ourselves as "the newest release".

Fix: before locking an upgrade target, run npm view <pkg> dist-tags versions time and npm view <sibling> dist-tags dependencies for every lockstep sibling. Target = the dist-tag head that exists coherently across the whole family; pin lockstep families exactly (no ^) so a future install can't half-upgrade them.

Detection rule: max(versions) > dist-tags.latest is a red flag, not a bonus — check the publish timestamp and the repo's release/tag history before believing a version that outranks its own latest tag. And any monorepo family where one package pins another with an exact version is lockstep: verify the target version exists for every member, or the upgrade is unschedulable at that number.

Status: applied 2026-07-09 (emdash upgrade slice; target corrected 0.9.0 → 0.28.1 during scoping, exact pins in apps/cms/package.json).

A Wix site-level API call takes wix-site-id ONLY — adding wix-account-id returns HTTP 400, and a Wix HTML error means "fix the request", not "fix the key"

Bites: the first GET https://www.wixapis.com/site-media/v1/files call (Media Manager listFiles, ADR-0010 spike) returned an HTML 400 Bad Request — a Wix.com-branded page, NOT a JSON API error — which reads like a malformed URL, not an auth problem. The API key + site id were correct; the request just carried BOTH wix-site-id and wix-account-id headers. Site-level Wix APIs reject the account-id header: the identical call with wix-site-id alone returned 200 files=94.

Fix: site-level calls (site-media, business-info, blog) send Authorization: <key> + wix-site-id: <siteId> only. Account-level calls (e.g. Query Sites) send wix-account-id instead — never both on one call. And read the error SHAPE: a Wix HTML error page (vs a JSON {message}) is a gateway-level malformed-request signal (bad header/param) — "fix the request shape"; a genuine auth failure returns JSON 401/403 — "fix the credentials". (The account-level Query Sites also needs the "Get Sites List" scope, absent on a media-only key → 403; not needed when the site id is already known.)

Detection rule: when a REST call fails, branch on the body format before blaming credentials — HTML/gateway page ⇒ malformed request (headers, params, URL); JSON error object ⇒ the API reached its handler (auth/permission/validation you can read). Isolate by stripping to the bare endpoint + one header at a time.

Status: captured 2026-07-12 pt4 (Wix API media spike; ADR-0010). Auth shape baked into the client contract there.

A Wix media listFiles returns ONE FOLDER, not the library — the POOL is searchFiles, and a truncated pool reports PRESENT assets as MISSING

Bites: ADR-0010's spike enumerated WCP's media with GET /site-media/v1/files and got 94 images — which looked like the library and happened to answer the spike's questions correctly (the hero video and all 7 carousel frames sit in the root folder, so the by-id match hit 7/7). It is not the library. That endpoint lists a single folder, defaulting to media-root, and a real FCR site files its media into folders (Commercial Painting, Residital, Repair, logos, Misc, …). The true WCP library is 392 files. The error is silent and it fails in the dangerous direction: an "is this asset at source?" check answered from the root folder confidently reports a present asset as missing — the first asset-reconciliation run declared 91 of 94 library files "never pulled", which was nonsense.

Fix: the media POOL is POST /site-media/v1/files/search (body {mediaTypes:[…], paging:{limit,cursor}}, cursor at pagingMetadata.cursors.next, stop on hasNext:false) — it spans folders. Keep listFiles only for a deliberate single-folder listing. Everything that reasons about "what exists at source" (the video lookup, the reconciliation) reads searchFiles.

Detection rule: before trusting a third-party "list" endpoint as an enumeration of everything, check whether it is scoped (folder / collection / namespace) and whether the API has a separate search verb — a list that returns a plausible-looking count is the easiest kind of truncation to miss. Cross-check the count against an independent lower bound you already hold (we had 144 R2 objects for a "94-file" library — the contradiction is what exposed it). A spike that gets the right answer from a wrong pool is still a wrong pool.

Status: captured 2026-07-12 pt5 (ADR-0010 amendment; searchFiles in apps/cms/scripts/wix-api.mjs; both consumers switched). SUPERSEDED 2026-07-13 pt3 — "the POOL is searchFiles" is FALSE on the second site. See the next entry. The reasoning above still holds; the conclusion was one site's worth of evidence.

A media-pool enumeration derived from ONE site is a HYPOTHESIS — and an EMPTY pool is the lie that reads as success

Bites: the entry above corrected listFilessearchFiles after WCP showed listFiles was root-folder-scoped (94 of 392 files — a 4× under-report). Correct diagnosis, correct fix, and a contract that broke the instant a second site was added. garvanbay:

searchFiles listFiles (root) folders
WCP 393 94 7
garvanbay 0 41 0 (flat library)

garvanbay returns zero from searchFiles — it is absent from Wix's media search index (an older site). Not an auth failure: the call 200s, the key has access, the root listing works. So WCP alone "proves" search ⊃ list; garvanbay alone would "prove" list ⊃ search. Neither is the pool.

And the failure mode is inverted from the first one, which is what makes it worse. A truncated pool reports present assets as MISSING — loud, alarming, investigated. An empty pool reports zero asset debt — it certifies the site clean. Nobody audits an instrument that says everything is fine. Had we reconciled garvanbay from searchFiles, we would have shipped a report saying it had no asset gaps, and believed it, because it was the answer we were hoping for.

Fix: mediaPool() in apps/cms/scripts/wix-api.mjs — the union of searchFileslistFiles(root) ∪ listFiles(every folder), deduped by media id, returning {files, sources, warnings} and warning loudly when the two enumerations disagree. asset-reconcile.mjs records wixPoolSources + wixPoolWarnings in the report, so an under-reported pool can never be read as "no debt". Verified: WCP 393 (union == search, folder walk confirms it — no regression), garvanbay 41 (union == list, rescued from 0).

Detection rule: any contract of the form "X is the complete enumeration" derived from a single reference site is a hypothesis. Before locking it, ask what the other site would say — and if you only have one site, write the contract as a union or a cross-check, not a pick. The tell that you have this bug: an enumeration that returns a plausible zero. A zero is a claim, and it is the one claim that never gets challenged, because it looks like there is nothing to investigate.

Status: found + fixed 2026-07-13 pt3, immediately on bringing the second site onto the API — which is exactly when a one-site contract is cheapest to falsify.

A Wix gallery's VIDEOS are VOD-channel HLS, not Media Manager assets — the by-id pool can't see them, and the captured URL is token-gated

Bites: the gallery-video render slice measured WCP's 13 gallery videos cleanly (v8 per-section attribution, mediaRefs.videos[].sectionId) and stamped the refs into fcr.gallery.videos — then every one reported video-not-in-wix-pool, on a site whose Media Manager pool holds 46 videos. Not one of the 13 matched any id form in the pool (file id, media.video.id, or posters[].id) — and neither did the hero id, in the capture's id form. The obvious inference ("the pool enumeration is still incomplete — a fourth amendment to mediaPool") is wrong: these videos are not Media Manager assets at all. They are Wix VOD "channel videos" — a separate Wix product. The live network trace settles it: the player enumerates them from GET /_api/vod/public/v3-to-v2/public/lists/<channelId>?media_type=secure_video (items carry source_url: null — there is no plain mp4), gets a JWT from GET /_api/v1/access-tokens, and streams tokenized HLS (repackager.wixmp.com/.../master.m3u8?token=<JWT> + .ts segments). The captured mp4-shaped URL (gcp-repackager.wixmp.com/.../,360p,480p,/mp4/file.mp4) 400s/403s on direct fetch even with a browser UA + live-page Referer — the token is required and short-lived. So the FCR content plane has three distinct Wix media surfaces, not two: Media Manager images, Media Manager videos (mediaPool, ADR-0010), and VOD channels (a different API + HLS delivery).

Fix (deferred as its own capability): acquiring VOD-channel videos needs a VOD-list-API enumeration → per-video access token → HLS→mp4 remux (ffmpeg) → R2 upload — a new pipeline and an ADR-0010 amendment, not a mediaPool tweak. Until it lands, migrateGalleryVideos routes each ref through the Media Manager migrateVideoRef path, which honestly reports video-not-in-wix-pool and leaves the raw line recorded (never fabricates a URL, never ships a dead <video> — the producer/render half is inert without acquired refs). The render + producer + schema + adapter shipped and are reproducible-from-capture; only acquisition is blocked.

Detection rule: a Wix widget whose assets don't appear in the Media Manager pool is a signal it belongs to a different Wix product, not that the pool is truncated again (the pool-truncation lessons above condition you to reach for a pool fix — resist it). Confirm by reading the live network trace, not by re-deriving the pool: the _api/<product>/... path names the product (here _api/vod/...), and source_url: null + a ?token= HLS request means the asset is streamed, not downloadable. And a "measured but un-acquirable" asset is a legitimate split point — ship the measurement and render (proven, inert), defer acquisition, and never stamp a dead ref into a committed seed to make the numbers move.

Status: found 2026-07-15 (gallery-video slice; render/producer/schema/adapter shipped, VOD acquisition deferred — see known-issues "WCP gallery videos are VOD-channel HLS" + ADR-0010).

Before scoping an ACQUISITION pipeline, measure that the SOURCE still serves the asset — the live platform's own player can be broken

Bites: ADR-0010 Slice F was scoped to acquire WCP's 13 gallery videos: enumerate the VOD channel → mint a per-video token → pull the HLS → remux → R2. Every step of that plan presumes the VOD API answers. It does not. Measured 2026-07-16 on the live site: every _api/vod/.../lists/<channelId> and .../play/<id> call returns 403 "Internal server error of auth middleware", zero repackager/.m3u8/.ts requests ever fire, and zero <video> elements ever mount. The widget still paints its poster and a "Play Video" button, so the page looks like it has video — the 403s are in the console, not on the canvas. The 07-15 entry above describes that same endpoint returning items (source_url: null), so this is a change in the source, not a re-reading of it.

The consequence is bigger than one slice: acquisition, a channel-id producer, and the ffmpeg remux are all worthless while the endpoint 403s, and the same wall covers the fcr.about ×11 bucket (its player fires the same call, gets the same 403). ~24 of WCP's 27 missing videos sit behind one account-side question.

And it puts a question mark over the debt itself: 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 — the veto may be measuring references, not renders.

The instrument nearly told three different wrong stories first, and each was plausible enough to have been written down: 1. Headless detection — the signed ctToken literally embeds HeadlessChrome/149…. Discriminator: re-run with a real Chrome UA → still 403. 2. Cookie consent — the banner's own text warns that declining functional cookies "may disrupt the website experience", and the first probes never accepted it. Discriminator: accept, re-run → still 403. 3. The wrong tabgetByText(/^videos$/i).first() clicked the first of four category galleries, so a screenshot showed IMAGES still active and the player apparently idle. Discriminator: enumerate all four, click each.

Only after all three were eliminated (headed browser, real UA, consent accepted, every tab) does "the source is broken" survive.

Detection rule: an acquisition plan's first step is not "how do I fetch it" but "does the source still serve it?" — measure playback on the live page (mount, readyState, network) before designing the pipeline. And when a probe reports a uniform failure, spend the cheap discriminators on your own instrument before believing the finding: UA, consent, and did you click the thing you think you clicked are each one run, and each can manufacture a confident wrong story. A screenshot is what catches the third one — it is the only probe that sees what you did rather than what you meant.

Status: measured 2026-07-16 pt2 (5 runs: headless/headed × default/real UA × consent declined/accepted × all 4 tabs). Slice F blocked at source; owner checking the Wix Video app's status — see known-issues.

A set-difference is not a number until you have measured the INTERSECTION

Bites: known-issues recorded "live WCP references 27 distinct video ids (so of the pool's 46, 19 are library cruft)". The 19 is 46 − 27, and that subtraction is only meaningful if the 27 are a subset of the 46. Measured 2026-07-16: the intersection is zero — the sets are disjoint. The 27 referenced videos are all VOD (absent from the Media Manager pool); the pool's 46 are all unreferenced. So "19 cruft" described nothing real, and it had been read forward as if it did.

The tell was available and unread: the same entry notes 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 your positive control is not a join. (The hero is in the pool as 8b4be4_a0c791…; it reached R2 via its poster id, not its video id — so the capture's video refs and the pool's ids were never comparable spaces at all.)

Detection rule: before publishing |A| − |B| as a count of anything, measure A ∩ B and check it against a positive control — an element you know is in both. If the control misses, the join is broken and every number derived from it is decoration. Kin to "A reconciliation is only as honest as its JOIN KEY" (above); that entry is about the join being wrong, this one is about a number computed as though a join had happened when none ever did.

Status: corrected 2026-07-16 pt2 (re-measured with a widened net — every string at any depth in every pool record, the bare 32-hex tail, plus a raw substring sweep: still 0/13).

A reconciliation is only as honest as its JOIN KEY — and an "empty field" is not a "broken render"

Bites: the asset-reconciliation (what did we pull and never show?) produced three numbers in a row that were confidently wrong, each for a different reason, and each looked plausible enough to report:

  1. Wrong join key. "At source, never pulled: 91 of 94" — because it matched Wix files by display name (IMG-20260702-WA0028.jpg) while our R2 objects store original-name as the media id (8b4be4_2517…~mv2.jpg). Two identity spaces that never meet. (And the ids don't compare literally either — the R2 key stem is a lossy normalisation, so the join must run through normaliseStem.)
  2. Truncated source set. Even repaired, the pool was the root folder only — see the listFiles entry above.
  3. Proxy metric instead of the real one. "Empty image slots: 349" — counting every empty image field in the seed. But a hero with no videoUrl and a text-only About are structurally optional and paint nothing (ADR-0004: loud-fail is for unintended absence). The number bore no relation to what a visitor sees.

Fix: join on the identity the data actually carries (media id, through the same normaliser that produced the key), enumerate the source set with the endpoint that spans it, and — for "what is broken on the page?" — measure the rendered output, not a seed-side proxy: the loud-fail primitives emit ui-missing-{icon,image,logo,…} classes, so counting those in the served HTML is ground truth (276 real placeholders across WCP's 24 pages). Restrict the seed-side "empty slot" count to repeater items (_key-bearing), where an item exists solely to show an image and emptiness is always a gap.

Detection rule: a diagnostic that reports a big, surprising number about your own pipeline should be audited in both directions before it is believed — sample its "missing" set and check it really is missing, and sample the other set and check it really is accounted for. The bug is usually the join key or the scope of the source set, not the subject. And the same rule as the scorecard: for anything of the form "what does a visitor actually see?", re-measure the deployed artifact — never infer it from the inputs (cf. "Measuring fidelity means re-measuring the deployed OUTPUT").

Status: captured 2026-07-12 pt5 (apps/cms/scripts/asset-reconcile.mjs; all three errors caught and corrected before the numbers were reported).

An id the DESTINATION assigned is an INSTALL fact, not a site fact — resolve by name at run time, and key on a name the PLATFORM owns

The join-key rule above is about reconciling two datasets. This is its write-side twin, and it is louder: a stale id does not fail, it succeeds against the wrong object.

The bmparts enrichment chain (ADR-0012 pilot) addressed six posts by number — 14796 (header template), 14818/14819 (Woo templates), 14808 (homepage), 14793/14794 (thumbnail attachments). Every one was correct — on that install. WordPress assigns post ids at creation, so they describe one box, not the site. On a second site each number addresses something else, and these scripts call update_post_meta(): no error, no placeholder, no loud-fail — the wrong post is silently rewritten and you find out by looking at the page. The whole class is invisible to a lint, a syntax check, and a dry run that only prints what it would do to id N.

Config is the WRONG fix, and it is the tempting one. The ids do not exist at the moment a config is written — the deploy creates them. A config key for header_template_id is a promise the operator cannot keep on a fresh box; it just moves the staleness somewhere with a nicer name.

Resolve at run time by name — the contract ADR-0012 already sets for the product loader ("resolving URLs by exact name at run time — never cached slugs"):

thing key why that key
Elementor template _elementor_template_type (header/footer/product/product-archive) Elementor owns it. Exactly one per type.
static front page get_option('page_on_front') WordPress's own answer to the question.
uploaded image filename comes from the source catalog (Ecwid names images by media id), so it survives a rebuild; the attachment id does not.

The sub-rule is the part worth keeping: key on a name the PLATFORM owns, not one you author. The first pass planned to join templates on post_titleSite Header, {brand} Product Archive. Plausible, and wrong: Site Header is authored by no script in the toolkit, so it is a name we would have to keep in sync forever and cannot assert. Measuring the live box (wp post meta get <id> _elementor_template_type) surfaced a key Elementor maintains itself, which also dropped the brand name back out of a script that had only needed it to spell a title. One wp command replaced a design.

Fail closed. Every lookup exits non-zero on a miss or an ambiguity (0 templates = the deploy has not run; 2+ = refuse to pick). Nothing falls back to an id. The negative test is the useful one and it is free: ask for single-product — the Pro 3.35 name Pro 4 renamed (playbook §2) — and the resolver must stop, not quietly match nothing.

Proving a hoist is a no-op: substitute the values back. For a refactor that replaces literals with lookups, the cheap proof is mechanical — reverse-substitute the config values into the refactored file and diff against the pristine original. Anything that appears is an unintended change. Here it reduced five files to "only the docstrings differ", and the resolver half got the stronger version: a read-only harness against live WP re-derived all 6 ids the hardcoded numbers had. The proof is a no-op — same standard as the capture lane's stamps.

Status: shipped 2026-07-16 (replatform-woo/scripts/bmparts/_resolve.php); all six ids gone from the chain, 6/6 re-derived on the live box, fail path proven.

section:nth-of-type(2) is often a trust ribbon, not a content band — gate rhythm measures on section height

Bites: ADR-0009 Slice 2b needs the effective section-band vertical rhythm (the raw Wix <section> is padding:0; whitespace lives on an inner spacer/wrapper). The theme-extractor samples section:nth-of-type(2) — a selector chosen for typography (any text-bearing section works for font/colour). But that section is not guaranteed to be a representative content band. On WCP it is the 67px trust ribbon ("7-Year Guarantee / Fully insured / Reliable"); measuring its content inset (19px) and bucketing it as --section-py: tight would emit a bogus site-wide rhythm override off a thin strip — element-level measures (button/card borderRadius, boxShadow) are solid, but this derived geometric measure is fragile across Wix template diversity (the concentrated capture risk ADR-0009 names).

Fix: lib/theme-extractor.js#bandPaddingTop gates on a content-band height floor (secRect.height < 200 → null) so a ribbon/thin strip never emits a sectionBand.paddingTop; absent → deriveSectionPy omits the token → the house-style rhythm default stands (graceful). The section height is still recorded (sectionBand.sectionHeight) for the deferred tuning pass. This distinguishes the two sample sites correctly: garvanbay's 2nd section is a real 536px band (→ 45px → normal, matches default), WCP's is the 67px ribbon (→ omitted). Robust representative-band selection (find the real content band, not just gate a bad one) is deferred to the section-classifier slice (2b-band) — it is the same classification work Slice 3 needs.

Detection rule: any per-site design token derived from a positional selector (nth-of-type, "first section", "second child") rather than a classified element is suspect — the position that reliably carries one signal (typography) can be the wrong element for another (rhythm). Gate with a sanity floor and defer robust selection to the classifier; verify the derived token against what the element actually is (probe its height/role), not just that a number came out.

Status: shipped 2026-07-06 (ADR-0009 Slice 2b; bandPaddingTop gate in lib/theme-extractor.js, deriveSectionPy reads sectionBand.paddingTop in lib/design-derive.js).

Measured image-side beats the alternation heuristic — and verify a geometry classifier's orientation against a live screenshot

Bites: image side (which side the photo sits on in a text+image section) was guessed, never measured. The per-site assembler used a zig-zag alternation (variant-picker.js sets reverse on every 2nd content-2); the CMS seed used the About matcher's || 'left' fallback (uniform). Both are phase-blind: the alternation can start on the wrong side, so the whole page reads offset-by-one from live, and the fallback is uniformly wrong. On the garvanbay deployed demo, 4 of 5 home service teasers were mirrored vs live (emdash.dcathal.org showed PAYROLL image-left; live shows image-right).

Fix: measure it in-page — the largest qualifying side image's centre vs the text-column centre (lib/section-style.js#classifyImageSide, ADR-0009 Slice 2b-band), stamped onto the existing editable imagePosition field. Live getBoundingClientRect is viewport-truth even under Wix absolute positioning, which is exactly why it must be measured on the rendered page, not parsed from stored HTML.

Detection rule: an alternating layout that reads offset-by-one from live is a heuristic that guessed the phase, not a values bug — the fix is to measure, not to flip the seed offset. And because left/right is trivially invertible and a mirrored render still looks plausible, verify a geometry classifier's orientation against a live screenshot before trusting or deploying it. (This session nearly mis-read a correct classifier as inverted; one side-by-side screenshot of live vs the classifier's output settled it — same "verify via the rendered artifact, not the wiring" discipline as the @source and unlayered-CSS traps.)

Status: shipped 2026-07-07 (ADR-0009 Slice 3, imageSide; CMS lane).

Wix dims a hero by rendering the IMAGE at opacity<1 over black — an overlay probe must compute effective media opacity up the ancestor chain, and a wrong bounded value is worse than none

Bites: the hero scrim dimension (ADR-0009) measures how much the live site dims its hero so hero-4 can reproduce it. The obvious probe — translucent dark layers stacked ABOVE the media — read alpha=0 on every garvanbay page, and the first stamp shipped scrim=none fleet-wide. The scorecard immediately pushed back: book-an-appointment G_det jumped +23 (genuinely unscrimmed on live) but every subpage hero DROPPED ~20 points of similarity — live subpage heroes ARE dimmed, by a mechanism the probe couldn't see: <wow-image> wrapper at opacity: 0.5 over an opaque black underlay (the image is translucent, not the overlay), with the inner <img> the probe samples at opacity 1.

Fix: two-part effective-dim estimate in resolveSectionBg's media branch: passthru = effectiveMediaOpacity × Π(1−αᵢ dark overlays), where effective media opacity is the product of computed opacity from the media element up the ancestor chain to the section (the .5 lives on a wrapper), counted only when the under-colour is dark (over white, low opacity brightens — skip). scrimAlpha = 1 − passthruclassifyScrim buckets (none <.15 / light <.50 / medium <.66 / strong). Re-measured: subpages 0.5→medium, home 0.24→light, book-an-appointment 0→none. Re-stamped via sync-style-to-d1.mjs; G_det 88.3→90.8, composite 91.3→92.0, all three verdict-relevant renders converged.

Detection rule: when a measured bounded dimension ships and the perceptual axis moves in BOTH directions (some pages up, some down), the measure is half-right — one mechanism captured, a sibling mechanism missed. Enumerate how the platform can produce the same visual effect (overlay div / translucent media over dark / gradient background-image / baked-dark asset) and check which one each regressing page uses before trusting the value. The scorecard's per-page G list is the instrument: a fidelity metric that can catch YOUR OWN measurement bug is the whole point of re-measuring the deployed output.

Status: shipped 2026-07-09 (hero-dims slice; effective-opacity probe in lib/capture-section-backgrounds.js, classifyScrim/classifyTextTone in lib/section-style.js, 24 new validate checks).

imagePosition/tone and per-block content props live in D1, NOT baked — a content-only fidelity fix is a D1 UPDATE, not a rebuild

Bites: the theme (global.css) is baked at build time and needs a worker redeploy to change — but per-block content fields (imagePosition, tone, text, image URLs) are read from remote D1 at request time. Reaching for npm run deploy to ship a content-prop fix is a needless rebuild; worse, reaching for the full reseed (reseed-d1-from-json.mjs) rewrites every page + globals from seed.json, reverting any admin-diverged content. On the garvanbay demo the live D1 home carried a 10th native Portable-Text block the 9-block committed seed lacked — a blind reseed would have dropped operator content.

Fix: for a surgical content fix, read the live D1 row, mutate the one field, write the single row back (UPDATE ec_pages SET content=json(...) WHERE slug=...) — preserving every other block. No rebuild (content isn't baked); no reseed (no revert-risk). This session flipped 4 home imagePosition values live this way; all 10 blocks preserved, verified by re-screenshot.

Detection rule: before shipping a fix, ask "is this baked (theme / global.css / @source) or D1 content (block fields)?" — if D1 content, a targeted UPDATE beats a rebuild and sidesteps the reseed revert-landmine (see "Fixing a silently-broken sync is destructive when the two stores have diverged"). A reseed is only safe after diffing seed vs live and reconciling the seed up from live first.

Status: shipped 2026-07-07 (ADR-0009 Slice 3, imageSide deploy).

A measured section-style prop overrides a block's hard-coded alignment via attribute-specificity — not by editing each block

Bites: the alignment dimension (ADR-0009) has to sit on the shared Section primitive so every block can carry it, but the canonical blocks already hard-code their alignment — cta-wcp centres (text-center/items-center/justify-center), services-1/faqs-1/content-2 default left, content-1 centres via a centered prop. Ripping those out per-block to feed a single alignment system is a big, regression-prone edit across ~30 blocks; leaving them means a new align prop would have to fight them.

Fix: add align?: left|center to Section → emit data-align, and drive it with unlayered global CSS keyed on the attribute: [data-slot="section"][data-align="center"] [data-slot="section-prose"]{text-align:center} (+ content align-items, actions justify-content; left mirror). That descendant selector is specificity (0,3,0), which beats the blocks' Tailwind utilities (text-center etc., 0,1,0) with no !important — so a measured value wins, and absent data-align the block's own default stands untouched (zero-regression). The blocks need only forward the prop (align={align}); their hard-coded classes become the no-attribute default. A block's own <p>-level rule (content-1's [&_p]:text-left, 0,2,0) still beats the inherited prose text-align — so a centred heading with left body copy stays that way, which is what live shows.

Detection rule: when a new shared-primitive prop must override behaviour that individual consumers hard-code, prefer an attribute + a more-specific global rule over editing every consumer. Rank the specificities (attribute-descendant 0,3,0 > single utility 0,1,0 > but < a direct child rule the consumer sets on the leaf) and let the cascade do the override — then verify the computed value on the built page (same "verify via the artifact" discipline as the unlayered-CSS and @source traps), because a class being present proves nothing about which rule won.

Status: shipped 2026-07-07 (ADR-0009 alignment; data-align in packages/components-v3/…/ui/section/section.astro).

A style dimension that adds NEW render code needs a worker redeploy + a surgical multi-row D1 patch — imageSide's D1-only UPDATE doesn't generalize

Bites: imageSide shipped with a single-row D1 UPDATE and no redeploy — it reused an existing rendered field (imagePosition) so the deployed code already knew how to paint it. alignment doesn't: it adds a new render sink (Section data-align + CSS), new adapter threading, and a new field — so the deployed worker must be rebuilt + redeployed and the live D1 rows patched with the new align values. Two moving parts, not one. And the reseed shortcut is still barred: garvanbay's live home carries a 10th native block the seed lacks (a blind reseed drops it).

Fix: (1) npm run deploy (rebuilds the worker with the new render code — the new Base.*.css uploads as a static asset); (2) a surgical per-page patch that reads each live ec_pages row, sets align on the matching blocks, and writes the row back whole. Match blocks by (_type + normalised heading) with order-consumption, NOT by _key — emdash assigns its own random keys, the seed's are deterministic, they never collide (same divergence as the globals ULID trap). Every non-matching block (the native paragraph, blog-posts, hero) is preserved untouched; block counts stay identical per page (home 10→10). Verified live: computed text-align matches data-align on every eligible block; content-2 teasers centred to match live garvanbay.

Detection rule: before deploying a captured-style fix, ask "does this dimension reuse an already-rendered field, or add new render code?" Field-reuse → D1 UPDATE only. New render code → redeploy the worker too. Either way the D1 write is a field-merge onto live rows keyed by a stable natural key (type+heading), never a reseed, whenever live has diverged from seed.

Status: shipped 2026-07-07 (ADR-0009 alignment deploy; one-time patch logic — match-by-type+heading, preserve-native — should graduate from the scratchpad to a committed fleet tool, see known-issues).

A whole-content fleet regen+patch is a full reseed (not a surgical field patch) — but only after the graft-divergence check proves live has no operator surplus

Bites: the surgical-D1-patch patterns above (sync-style-to-d1.mjs, match-by- type+heading, preserve-native) are for a field-level fix that must preserve admin-diverged content. A whole-content regen (re-crawl + seed-from-build.js → new/changed blocks: split→gallery, cta bodies, section-style across many blocks) is a different shape — you want to replace every page's content wholesale, which is what reseed-d1-from-json.mjs does (DELETE+INSERT by slug per page, UPDATE globals by slug). Reaching for the surgical patcher here is the wrong tool (it merges fields, it doesn't restructure). But a blind reseed is the garvanbay nav-wipe landmine if live has diverged.

Fix: gate the full reseed on a graft-divergence check run before any write: dump live D1 (ec_pages + ec_globals) and diff it structurally against the regen seed — per-page block-count + surplus-type check (does any live page have more of a block type than the regen? → operator surplus to preserve) and a globals compare (is the regen site global thinner than live's header/topbar/footer? → the nav-wipe). A fleet site that was reseeded and never persistently operator-edited (the emdash save race blocks edits — see "Saved ✓ that never persists") comes back clean — zero page divergence, globals match — so the full reseed is safe with no graft needed. WCP 2026-07-10: 24/24 pages block-count-identical, site global identical (header 6 items both), slugs align (home present, no orphans) → straight reseed. Also verify regen page slugs match live slugs (seed-from-build remaps index→home; a slug mismatch orphans a live row and serves stale).

Detection rule: classify the delivery by shape first — field-level fix that must preserve divergence → surgical patch; whole-content regen → full reseed. Either way the divergence check is the non-negotiable gate, and its clean/dirty result — not an assumption about whether the site was edited — decides whether a reseed is safe. A never-edited fleet site is clean by verification, not by faith.

Status: shipped 2026-07-10 (ADR-0009 WCP regen+patch; graft-diff of live D1 vs builds/waterfordcountypainters.ie/seed.json, clean → reseed-d1-from-json.mjs).

Deploying a newly-registered site-global is a surgical ec_globals append, not a re-crawl — when the deployed worker already carries the render path

Bites: closing an already build-verified CMS silent-drop still needs the new global rows to reach live D1. The FloatingCTA/Social translators were registered in GLOBAL_TRANSLATORS and committed, but the overlays stayed absent live because the two fcr.floating-cta/fcr.floating-social globals weren't in WCP's ec_globals. The wiki's "regenerate the seed" path needs the site's build inputs (<slug>.body.html + captures) local — but builds/* is gitignored, a fresh checkout doesn't have them, and WCP's last regen came from a live re-crawl (the brand-nondeterminism landmine). A full re-crawl to deliver two cosmetic globals is disproportionate.

Fix: two questions decide the deploy shape. (1) Does the deployed worker already render this _type? Floating adapters + the Base.astro globals.find(b => b._type === "fcr.floating-*") lookup were already in the shipped build (6f2cb2cf), and globals are read from D1 at request time — so no rebuild. (2) Are the build inputs local for a faithful regen? No → derive the global from the live site (the real URLs the extraction would yield — the pinned WhatsApp StylableButton → CTA, the facebook/youtube rail → social, matching the topbar's empty-URL socials), run the graft-divergence check on the live site row (byte-identical to the committed seed → clean/never-edited), and append the new blocks with UPDATE ec_globals SET content=json(<array>) WHERE slug='site' (the content array is exactly what Base.astro searches by _type; order is irrelevant). Reconcile the committed seed so a future full reseed can't drop them. Verified live with no rebuild — overlays serve on every page including subpages.

Detection rule: to deploy a held/newly-registered global, don't reach for a re-crawl or a full reseed first — ask render-path-present? (yes → D1-only write, same as "content props live in D1, NOT baked"; no → redeploy first) then inputs-local? (no → derive-from-live + graft-check + surgical append beats a re-crawl for a small, well-understood global). The graft check on the single site row is the same non-negotiable gate as a full reseed, just scoped to one row. This is the deploy path for the next held globals capability too (header/logo, backlog #2).

Status: shipped 2026-07-11 pt2 (WCP FloatingCTA/Social deploy; capability-backlog #3; surgical ec_globals append, seed reconciled).

Migrating a raw-<section> block into shared <Section> — minimal-wrap, and give a heterogeneous dimension per-block sinks, not one universal one

Bites: the columns dimension (ADR-0009) needed two raw-<section> blocks (team-grid, checklist) to compose the shared <Section> primitive so they could carry data-slot/tone/columns. Two traps: (1) a thorough migration (adopt SectionProse, route the grid through the shared SectionGrid, drop the bespoke inner) changes the block's rendered lookSectionGrid's default is auto-fit minmax(), NOT team-grid's hard-coded 2-col; SectionProse restyles a heading the block coloured itself; <Section>'s width (80rem) ≠ checklist's 56rem. Each is a live regression on every page that already uses the block, across both lanes (CMS + per-site assembler share the canonical file). (2) columns looked like one universal SectionGrid data-columns sink, but the grid-shaped blocks are heterogeneous: services-1 uses SectionGrid, team-grid hand-rolls .team-grid-inner, gallery-wcp hand-rolls .gallery-grid, posts-wcp hand-rolls its own — one shared sink only cleanly covers services-1.

Fix: minimal-wrap — swap only the root <section> for <Section tone align …> (which now owns padding/width), and keep each block's bespoke inner markup (.team-grid-inner, .checklist-inner at its own 56rem, its own primary-colour prose). Remove only the block's outer padding (Section provides it). The block gains data-slot/tone capability with ~zero visual delta. For the heterogeneous render dimension, give each block its own data-columns hook on its own grid (the gallery-wcp [data-columns]{--cols} pattern), and add data-columns to the shared SectionGrid only for the blocks that actually use it (services-1). One enum, many sinks. Verified by reading the computed grid-template-columns on the built/deployed page (620px 620px = 2-col default preserved; 400px×3 when a value is set), per the "verify via the artifact" discipline — a class/attr being present proves nothing about which rule won.

Detection rule: before migrating a bespoke block onto a shared primitive, list what the primitive would change (default grid template, prose/heading styling, max-width, padding) and preserve each unless the change is the intent. Prefer wrapping over adopting sub-components. And when a "shared" dimension meets blocks that each hand-roll their layout, expect N sinks keyed on one enum, not one sink — audit every grid-shaped consumer before assuming reuse.

Status: shipped 2026-07-07 (ADR-0009 columns; team-grid/checklist migrated, SectionGrid + team-grid data-columns).

A measured dimension whose value equals the block default (or whose only eligible block is gap-tone-matched) is a faithful no-op — prove the render surface another way

Bites: ADR-0009 columns shipped fully wired, but garvanbay — the only deployed demo — has no service-grid/gallery, and its one team-grid (/about, empty block heading) matches its captured section via gap-tone. Geometric props (imageSide/columns) are correctly withheld on gap-tone (it proves tone agreement across a gap, not which section a block is). So the automatic columns stamp is a faithful no-op on garvanbay (and its team measures 2 = its default anyway). The done-criterion "deployed demo renders measured columns" was therefore unmeetable on this site for a legitimate reason — not a bug, but it silently leaves the live render surface unproven if you just declare victory on the build.

Fix: separate the two halves. The measure→stamp half is proven by the capture (team=2, FAQs=3/4) + the classifier validate + the type-gated stamp logic. The render half is proven live by temporarily forcing a value through the real path — a surgical D1 UPDATE set the block's columns=3, the deployed worker rendered grid-template-columns: repeat(3,1fr) (adapter → data-columns → CSS), and it was reverted immediately (throwaway proof, not a shipped value). Surface the no-op to the user rather than papering the criterion.

Detection rule: when a measured dimension's value on the sample site equals the block's default, or the only eligible block matches via gap-tone/positional-guess, the deploy will look identical — that's zero-regression, not verification. Prove the render path end-to-end by forcing a non-default value through it once (and revert), and defer "first automatic live instance" to a site that actually carries a distinguishing measured value.

Status: shipped 2026-07-07 (ADR-0009 columns deploy; garvanbay render-surface proof via temp D1, reverted).

Measuring fidelity means re-measuring the deployed OUTPUT — comparing a capture to what we stamped is an identity that measures nothing

Bites: the per-section capture records (section-backgrounds.json: tone/imageSide/align/columns) are the input to our render — we stamp imageSide onto imagePosition by construction. So a fidelity check that compares the capture to the value we stamped always agrees and proves nothing (did the prop reach the DOM? did the CSS paint? did mobile collapse? — all invisible). This is the trap the near-match scorecard (ADR-0009 §6) had to avoid, and the reason a literal "did we stamp it" check would have reported a perfect score on a visibly-wrong page.

Fix: run the same capture probe against our deployed output (capture-section-backgrounds.js --base <deployed-url> --out …ours.json) to get an independent section-backgrounds.ours.json, then diff live-record vs ours-record (lib/structural-diff.js). Only an independent re-measurement of the rendered page proves the value survived. Proof: on the first run this caught tone:muted→default on garvanbay's deployed CMS home — alternating grey bands that were stamped in the seed but do not paint on the live worker — which a compare-to-stamp check scores as 100%.

Corollary — canonicalise + junk-filter before diffing a clean replatform against a live Wix page. Wix emits each section as a near-duplicate pair (responsive/layer doubling: same heading+tone+style, ~50px height delta) and enumerates widget/chrome pseudo-sections (a <style> block leaking CSS into textHead, social-share bars, video-player strips). Our clean output has neither, so a naive diff reports the replatform "dropped" half its sections. Collapse adjacent same-tone-same-style pairs (incl. the empty-heading hero/welcome form) and filter the junk on both sides first, or the score is dominated by capture noise (garvanbay home: 6 dup-pairs collapsed; WCP: 6/31/9 junk sections filtered per page — S went 43.7→69.6 once filtered).

Detection rule: when validating a captured-then-stamped dimension, ask "am I comparing against an independent re-measurement of the rendered output, or against the value I stamped?" If the latter, it's an identity — re-measure the deployed page. Same "verify via the built/deployed artifact, not the wiring" discipline as the @source and unlayered-CSS traps above.

Status: shipped 2026-07-07 (ADR-0009 §6 near-match scorecard Step 0; lib/structural-diff.js + capture-section-backgrounds.js --base/--out; spec docs/pipeline/near-match-scorecard-contract.md).

The symmetric re-capture probe must parse every colour space OUR lane paints in — an rgb-only parser reads oklch fills as page-white and fabricates a render bug

Bites: the ours-capture reported tone: default on garvanbay's home service teasers while live read muted — recorded as "stamped muted doesn't paint on the deployed worker" and chased across two sessions as a render bug (pt5 ruled out the @source gap and D1 divergence, then concluded "the measured tone isn't reaching the rendered DOM"). The DOM was painting correctly the whole time: data-tone="muted" present, .bg-muted{background-color:var(--muted)} in the deployed CSS, computed background oklch(0.96 0 0)#f2f2f2 vs live's #f3f3f3 — visually identical bands. The instrument was blind: computed non-legacy colours serialize in their declared space per CSS Color 4 (Chromium returns the literal string oklch(0.96 0 0)), and resolveSectionBg's parseCssColor matched only rgba?(…) → null → "no fill" → the walk fell through to ancestor/page white → default. The blindness is asymmetric: live Wix pages paint in legacy rgb (parse fine); OUR replatformed pages paint via oklch tokens (all invisible) — so every live↔ours disagreement blamed our render, never the probe. _styleRaw.textColor: {heading: null, para: null} on ours-captures was the corroborating tell — same parser, same blindness, silently degrading textTone verification too.

Fix: keep the rgb fast path (live captures byte-identical) and normalise everything else through a 1×1 canvas — fillStyle = str; fillRect; getImageData resolves any browser-understood colour (oklch / oklab / color() / color-mix) to rgba bytes; a sentinel fillStyle detects invalid input (an unparseable colour leaves fillStyle unchanged). One definition covers all four call sites (section bg, ancestor walk, heading/para colour). Re-measured: home teasers #f2f2f2 → muted, home S 83.5→84.6; the fix also exposed a real divergence the blindness had been hiding (book-an-appointment's hand-crafted "Our Services" grid carries a muted band live doesn't have — S 39.7→37.5, an accepted divergence on that deliberately-rebuilt page).

Detection rule: when a live↔ours diff reports a whole class of styling (every tone, every text colour) as never landing on ours while the deployed HTML/CSS demonstrably carries it, suspect the instrument before the render: print the raw computed strings the probe consumes. Any parser in the capture lane (colour, geometry, fonts) must be tested against our own lane's serialization, not just the source platform's — a parser proven on Wix captures proves nothing about ours-captures. And nulls in secondary captured fields (textColor) are the cheap tell that a shared parser is failing upstream of the field you're actually debugging.

Status: shipped 2026-07-10 (parseCssColor canvas normalization in lib/capture-section-backgrounds.js; scorecard SHIP held at 92.2, all four validate suites green).

A hurdle+floor gate can carry an arithmetically dead floor — check each floor's reachability against the composite weights

Bites: the scorecard gate (Step 3) ships composite ≥ τ_ship AND S≥70 AND T≥80 AND G_det≥55. Under the seed weights (S 0.5 / T 0.25 / G 0.25, axes capped at 100), composite ≥ 85 already implies S ≥ 70 — with T and G maxed, S below 70 can't reach an 85 composite. So the S-floor can never be the demoting cause; a reader who assumes "the S-floor protects against structural failure at high composite" is trusting a check that never fires. Worse, it's latently live: a Slice-6 weight re-fit (e.g. S 0.4) silently activates it — behaviour changes without anyone touching the floor. A validate fixture written naively ("S below floor with composite ≥ τ_ship → demoted") is unwritable, which is how this was caught.

Fix: when setting a hurdle (composite threshold) plus per-axis floors, do the arithmetic per floor: max composite achievable with this axis pinned at floor − ε and every other axis at 100. If that's below the hurdle, the floor is dead under current weights — document it where the thresholds live (noted in near-match-scorecard-contract.md Step 3 status for lib/calibration.json) so a re-fit knows it's flipping a dormant check on, and write the validate fixtures against floors that are live (T and G_det here).

Detection rule: a gate fixture you cannot construct (no input satisfies "passes hurdle, fails this floor") means the check is arithmetically dead, not that the fixture is wrong. Surface it — dead checks that activate on a parameter re-fit are silent behaviour changes waiting in the config.

Status: shipped 2026-07-07 (ADR-0009 §6 scorecard Step 3; lib/scorecard-gate.js, lib/scorecard-gate.validate.mjs).

A section-alignment collapse serves TWO axes with different needs — anchor for S, visual rect for G — and Wix's both-empty dup form is easy to miss

Bites: structural-diff.js#canonicalise collapses Wix's responsive section-doubling so a clean replatform doesn't look like it "dropped" half its sections. The G_det (perceptual) axis reuses that same alignPage to know which sections to crop and compare. Two latent traps surfaced when G_det's outliers were triaged (garvanbay hero scored a false 33% sim, WCP a false 37.8%):

  1. The collapse optimised the retained member for the wrong axis. A collapsed unit carries two independent concerns: the anchor (heading/textHead/tone/ style) drives NW substitution cost for S, so keeping the heading-bearing member is right; the rect drives G_det's crop, so it must be the visually first member. For garvanbay's hero/welcome emptyDup collapse the code kept the welcome member for BOTH — so G_det cropped live's welcome band (intro + 3 photos) and compared it to ours' hero band (a thin blue title strip): apples-to-oranges → false 33%. S was blind to it (tone/style tuples agreed, q=1). Fix: decouple them — keep the anchor member's fields but override rect to the earlier member's ({ ...anchor, rect: prev.rect }). Hero G 33.4→63.6 (an honest score: the residual reflects a real hero-background difference, which G should show); garvanbay S unchanged at 96.

  2. The dup detector missed the both-empty-heading form. It handled headingDup (both members carry the same heading) and emptyDup (one empty image-h1 + a heading sibling whose text appears in the empty one's textHead), but NOT both members empty — exactly how Wix doubles image-heroes and brand ribbons (WCP's twin "Commercial Painting" heroes, "Schedule a site visit" / "Ask about flexible" ribbons). A missed twin doesn't just cost S a spurious drop — NW then force-matches the leftover twin to whatever ours section is next (a bad match at subCost ~0.9 still beats two gaps at 2×GAP=1.4), pairing WCP's hero-dup against ours' logo-strip → false 37.8% G. Fix: add bothEmptyDup — both headings empty + identical normalised textHead + same tone/style. This is what actually prevents the spurious force-match (the canonicalise pre-pass, not the aligner's cost model — which is left untouched): the leftover twin is removed before alignment, so ours' logo-strip becomes a clean insertOurs. Also single-penalises S for a genuinely-dropped twin instead of double (WCP commercial-painting S 68.3→72.3).

Detection rule: when one pure pass feeds two axes/consumers, list what each needs from the retained object — they can diverge (text anchor vs pixel rect), and optimising the pass for one silently degrades the other. And any dup/dedup detector keyed on "a heading matches" has a blind spot for heading-less duplicates; enumerate the empty-heading case explicitly (image heroes, icon ribbons, logo strips) or a whole class of Wix doubling slips through. Verify via the re-scored artifact, not the wiring: the fix was proven by re-running S+G+gate on both anchors (garvanbay inert where expected, WCP improved, genuine outliers — WCP's navy FAQ at 24.9% — preserved), per the "verify via the artifact" discipline.

Status: shipped 2026-07-08 (ADR-0009 §6 scorecard; bothEmptyDup + anchor/visual-rect decoupling in lib/structural-diff.js#canonicalise, locked by lib/structural-diff.validate.mjs — 42 checks). Unblocks G_det as a gate input.

A diagnostic that projects debt already inside a score must NOT re-penalise — surface it, don't subtract it twice

Bites: ADR-0009 §3 lists matcherGap (dropped-live / inserted-ours / props-withheld) as a structural sub-metric to "fold into S". Taken literally — adding a matcherGap penalty term to S — that is double-jeopardy: S's role-weighted alignment already puts every dropLive and insertOurs in its denominator (a drop contributes 0 to the numerator, a hard penalty) and every ours-missing dim already lowers a matched section's quality. Subtracting a second matcherGap term would penalise the same events twice, silently changing S and invalidating the calibration anchors (garvanbay S=96 / WCP S=60.9) and the 42 locked fixtures — for zero new signal, because the signal is already in S.

Fix: matcherGap is a reported projection, computed from the same alignment rows S scores, emitted as a sibling field — S is byte-identical before and after (a declaration-level check: re-score the anchors, confirm the number didn't move). What the projection adds is not score but legibility: role-weighted totals + a byRole drop/insert breakdown make matcher debt explicit and portfolio-rollup-able (the §5 "ceiling events are a deliverable" model — which section-types the pipeline systematically loses is the add-a-matcher decision input for Slice 6). The existing criticalSectionDropped veto reads the same drop rows and is left untouched — one drop set, one veto, one projection, no re-penalty.

Detection rule: when a spec says "fold metric X into score S", first ask "is X already inside S?" If the events X counts already move S (drops in the denominator, disagreements in the quality term), X is a diagnostic view of S, not an input to it — emit it alongside and prove S is unchanged. A metric that is both a score input and a reported breakdown of that same score is double-counting. Reserve genuine new penalty terms for signals S does not yet see (e.g. genericFallbackRate from a build-time matcher sidecar — a fact the capture-only S axis is blind to; deferred to Slice 4b).

Status: shipped 2026-07-08 (ADR-0009 §6 scorecard Slice 4a; matcherGap in lib/structural-diff.js#{summariseMatcherGap,rollupMatcherGap}lib/scorecard.js, locked by lib/structural-diff.validate.mjs — now 53 checks).

genericFallbackRate is a build fact, not a capture measurement — compute it from the build sidecar counts; join to the capture only to attribute a role for the veto (and bridge the home/index slug alias or the homepage silently drops out)

Bites: ADR-0009 Slice 4b-ii needs "the fraction of sections rendered by the measured GenericSection." The tempting shape — re-derive it from the ours capture — is wrong twice over: (1) the capture sees painted DOM, not which matcher produced a block, so it fundamentally can't tell a GenericSection band from a real one; (2) the S axis is a symmetric-capture diff, and threading a count through it invites the compare-to-stamp identity trap. The rate is a build fact: at seed time we know exactly which matcher emitted each block. The only thing the capture is needed for is the veto — "GenericSection on a role-weight ≥ 1.3 region" needs the role, which lives on the scored alignment rows, so the matcher identity must be joined onto the ours sections. That join has a landmine: the seed keys the homepage home (its pageId), but the capture may key it index (garvanbay crawls as home, WCP as index) — so a naive manifest[slug] lookup silently drops the homepage, the single highest-weight page (2× + hero-bearing). A GenericSection hero there would escape both the rate and the veto, and the anchors would still look green (neither uses GenericSection), so the gap ships invisibly.

Fix: emit a build-time {section→matcher} sidecar (section-matchers.json, {order,heading,matcher,type} per emitted block) from the transformer where entry.matcher.name + the block are both in scope; compute genericFallbackRate = Σgeneric/Σblocks directly from the sidecar counts (null when no sidecar — unknown, never a bogus 0, mirroring the deferred-null contract). Join the sidecar to the ours capture only to tag each row's oursMatcher (heading-first, content-order fallback — the same join makeStyleLookup proved), and bridge the home/index/homepage alias both ways (manifestForSlug, same alias set as makeStyleLookup). The veto then reads the tagged rows exactly as criticalSectionDropped reads drop rows. Keep the whole thing additive: S must be byte-identical before/after (the tag and rate are a projection, not a penalty — same discipline as the Slice-4a matcherGap).

Detection rule: when a metric is "how often did our pipeline do X", ask "is X a build fact (known at emit time) or a capture measurement (needs the rendered DOM)?" Compute build facts from the build, not by reverse-engineering the capture — and if you must join build identity onto capture rows, enumerate the page-key aliasing (home/index/homepage) or the highest-weight page slips the join. Prove the nonzero/veto path by forcing a synthetic value through the real join (doctor one sidecar block to GenericSection, confirm rate>0 + veto fires, revert) — on sample sites where the fallback is a faithful no-op, that is the only way to exercise the live path (cf. "A measured dimension whose value equals the block default … prove the render surface another way").

Status: shipped 2026-07-08 (ADR-0009 Slice 4b-ii; section-matchers.json from lib/cms/{transformer,seed-from-build}.js, genericFallbackCounts / makeMatcherLookup / manifestForSlug / rollup in lib/structural-diff.js, collectGenericFallbackVetoes in lib/scorecard-gate.js, locked by both .validate.mjs files). Alias bug caught during verification, not review.

Re-seeding a rebuilt CMS seed needs the AUTHORITATIVE basename→R2-URL map from object metadata — a fresh matcher rebuild loses image resolution, and the R2 key is a lossy hash you can't recompute

Bites: re-running seed-from-build.js to fix a matcher bug regenerates the seed with raw Wix image paths (/assets/images/c00d38_…~mv2.jpg) — but the deployed D1 + renderer use resolved R2 URLs (https://pub-…r2.dev/garvanbay/c00d38-05b…). Reseeding the raw-path seed breaks every image. You cannot recompute the R2 key from the basename: it is a lossy transform (ADR-0005 §3 _20/URL-encode ambiguity — e.g. c00d38_4a54dd77… → key c00d38j54dd77…, c00d38_3d05…c00d38-05…). And block-identity pairing (match rebuilt↔deployed blocks by heading, copy the URL) is too lossy — it only recovers images on blocks that still pair, missing every new/changed block. Worse, the images a dropped section referenced were never uploaded to R2 at all (garvanbay had only 16 R2 objects for ~40+ seed image refs), so no map can resolve them — they have no working URL to point at.

Fix: build the basename→URL map from R2 object metadata (x-amz-meta-original-name, set on every upload per ADR-0005) — list the bucket under the site prefix, HeadObject each key, map original-name → <PUBLIC_MEDIA_BASE>/<key>. This is the only complete, authoritative source. Apply it to the rebuilt seed; whatever isn't in R2 (images that were never seeded/uploaded) gets stripped to text-only if the block is text-led (an About intro band renders fine without an image) or re-sourced from an in-R2 sibling (e.g. a new service-grid card reuses the same service's home-teaser image). Then verify zero raw /assets/ paths remain before reseeding — a raw path left in is a guaranteed broken image.

Detection rule: before reseeding a regenerated CMS seed, diff its image scheme against the deployed D1's. If they differ (raw vs resolved), you need a resolution step, and the authoritative map is R2 metadata — NOT the seed, NOT block-pairing, NOT a recomputed key. If an image basename isn't in R2, it was never uploaded; don't fabricate a URL — strip to text-only or reuse a real sibling, and never leave a raw path.

Status: applied 2026-07-08 pt5 (garvanbay end-to-end: scripts/.tmp/r2-map.mjs built the map from R2 metadata; 44 refs resolved, 6 intro bands text-only, booking cards re-sourced from home teasers, 0 raw paths, 0 broken images). The R2-metadata map builder should graduate from the scratchpad to a committed helper when the next site is re-seeded from a rebuild.

A regenerated seed silently loses everything the generator cannot derive — diff against BOTH the committed seed AND live D1, then graft, before any delivery

Bites: (2026-07-10, recovering the crashed teaser-CTA slice) a fresh seed-from-build.js run produced a valid, warning-free garvanbay seed that had silently lost three classes of underivable content:

  1. A hand-crafted page. book-an-appointment's live page is a Wix Bookings widget — buildSections enumerates zero content sections, so the page emits empty with no warning. GenericSection can't catch it: the fallback claims unmatched section-tag entries, and here there are no entries at all to claim. The page's content (hero + 5-service grid + enquiry CTA) was hand-crafted in the pt5 pass and exists only in the old seed.
  2. Measured stamps. 7 heroes regressed scrim to the single-image 'strong' heuristic because the local section-backgrounds.json predated the effective-opacity probe; textTone vanished entirely (it existed only in live D1 — no seed ever carried it). A stale capture input degrades silently to heuristics; nothing flags it.
  3. Admin-native blocks. The two live empty-paragraph block entries (home, management-accounting) aren't in any build artifact.

The image-URL loss (the R2-map entry above) is a fourth member of the same family. Common shape: the regen output is valid and quiet; the loss is only visible in a diff against what's actually deployed.

Fix: after any seed regen, diff per-page block composition AND field values (_key-stripped) against both the committed seed and a fresh live D1 dump; graft what the generator can't derive (hand-crafted pages, measured stamps from their stamps artifact, native blocks, resolved image URLs). Then deliver via diff-gated surgical per-page UPDATE by slug — never a reseed. Reconcile-script shape: scripts/.tmp/reconcile-seed.mjs (2026-07-10); promote to a committed tool on its next use (same promotion contract as sync-style-to-d1.mjs).

Detection rule: a regenerated page with 0 blocks and 0 warnings means its live source is app-widget-shaped (no <section>s) — check the old seed for hand-crafted content before believing the page is empty. And any field whose value comes from a measured stamp (scrim/textTone/tone) must match the stamps artifact after regen; a mismatch means the capture input is stale, not that the site changed.

Status: applied 2026-07-10 (crash-recovery delivery, commit 3b71607; Time Travel bookmark taken before the 7-page D1 patch).

The graft check is a TWO-WAY diff — live can be STALE, not just surplus

Bites: the graft check above exists to stop a regen from destroying what only live has. That framing makes it easy to read every live↔regen difference as "live is right, the regen would regress it" — and to graft live's value through without asking which side is actually faithful. On 2026-07-13 the WCP globals diff carried four differences and they pointed in both directions:

  • fcr.floating-cta / fcr.floating-social / a native blocklive is right, the regen would regress them (the classic surplus case).

    CORRECTION 2026-07-13 pt3 — the floating two-thirds of that line was WRONG, and it is kept here because the error is the lesson. Measuring the live Wix DOM showed the regen was the faithful side on both floating blocks: live carries an ENQUIRE → /contact-us pill (which the matcher had right) and a WhatsApp social button, and no social rail at all. Live D1's values came from an untracked hand-written .tmp/*.sql patch. Only the native block was genuine surplus. So this very entry — written to warn against assuming a direction — assumed a direction, on the two rows nobody had measured. A third bucket was missing from the taxonomy: not surplus, not staleness, but fabrication. See "A hand-written D1 patch is a FABRICATION with a production address".

  • fcr.topbar.socialslive was WRONG: url: "" on both entries, and the TopBar adapter filters empty urls (.filter(s => s.url && s.platform)), so WCP's deployed topbar had been rendering zero social icons while the live Wix site shows Facebook + YouTube. The regen had the real URLs. The pt2 urlhref reconcile had landed in the code and the committed seed, but the values were never pushed to the deployed D1 — a fix that "shipped" into an artifact nobody redeployed.

Fix: treat each differing field as a question — which side did a measurement produce, and which side is a stale write? — not as a direction. A field where the regen carries a measured/extracted value and live carries an empty string is almost always live being stale, not the regen being lossy. Landed via sync-globals-to-d1.mjs (below).

Detection rule: an empty value on live opposite a populated one in the regen is the tell. Surplus looks like live having MORE (a native block, a richer label); staleness looks like live having LESS ("", [], a default). Grafting "live wins" uniformly silently preserves the bug.

Status: found + fixed 2026-07-13 (WCP topbar socials now render live).

A hand-written D1 patch is a FABRICATION with a production address — and the wiki will promote it to ground truth

Bites: on 2026-07-11 a session registered fcr.floating-cta / fcr.floating-social as globals and needed values to deploy. Nothing measured them, so it typed them in (apps/cms/scripts/.tmp/wcp-add-floating-globals.sql, untracked, no git history): a WhatsApp deep-link as the CTA, and the topbar's facebook+youtube copy-pasted into a floating social rail. Both were wrong — live WCP has an ENQUIRE → /contact-us pill as its CTA, a WhatsApp button as a separate bottom-right social button, and no social rail at all. The same file also wrote the socials: url: "" that a later session spent time diagnosing as a threading bug.

Then the damage compounded, and this is the part worth learning:

  1. The values reached live D1, so they became "what the site serves".
  2. The next graft check saw regen ≠ live and — with no measurement to arbitrate — recorded live as "faithful" and the regen as the thing that "would overwrite" it.
  3. known-issues.md published that table, and a follow-on session scoped a whole new capture dimension against it, reasoning forward from an inverted premise. The filed goal ("teach the capture plane to reproduce live's WhatsApp CTA") was the exact opposite of the truth (the matcher already had it right; live needed correcting).

A fabricated value in production is worse than a missing one: a missing value is loud (ADR-0004), a fabricated one is indistinguishable from a measurement — it has the same shape, the same address, and it accretes authority every time a document cites it.

Fix: - A value written to live must be traceable to a producer — a matcher, a classifier, a capture. If you cannot name the producer, you are fabricating. Say so, loudly, in the same commit. - The committed tools exist for exactly this: sync-globals-to-d1.mjs / sync-style-to-d1.mjs take a stamps file that should be generated from the pipeline artifact (node -e '…read seed…' > stamps.json), never hand-typed. A .tmp/*.sql file you wrote by hand is the smell. - When a dimension has no producer yet, the honest move is to leave the block absent (loud-fail / no render) until one exists — not to fill it with a plausible guess so the deploy looks finished.

Detection rule: in a live-vs-regen diff, before labelling a column "faithful", ask what measured this? for each side. Three buckets, not two: surplus (live has more — an operator authored it), staleness (live has less — ""/[]/a default), and fabrication (live has something plausible that nothing ever measured). Fabrication is the only one that looks exactly like success. The tell is provenance, not shape — so go find the producer, and if the trail ends at a .tmp file or a chat scrollback, it is fabrication.

Status: found + fixed 2026-07-13 pt3. Live WCP corrected toward measurement; the floating dimension now has a real producer (capture probe + classifyFloatingCta / classifyFloatingSocial); globals-diff.js grew a floating peer so the scorecard can see this class of divergence instead of it hiding in an untracked SQL file.

A new in-page probe makes EVERY existing capture artifact stale — so declare what each dimension needs and refuse to stamp without it

Bites: the sibling of "a capture-plane detector is not shipped until the capture has been re-run" (below) — that entry says re-run the capture; this one says make the code notice when you didn't. Every capture-plane slice that adds a probe leaves every existing section-backgrounds.json in the fleet one version behind, and the failure is silent by construction: the classifier reads a field that isn't there, dutifully returns null, the overlay reads that null, and the regen writes an empty value over a good one. Nothing in the pipeline noticed. schemaVersion had been written into the artifact since v2 and no consumer had ever read it — it was a comment with a colon in it.

Fix: lib/cms/seed-from-build.js#CAPTURE_REQUIREMENTS — each capture-fed dimension declares the schemaVersion that introduced its probe and the chrome key that probe is guaranteed to emit. captureStampable() checks both and returns the set of dimensions safe to stamp; anything missing is skipped with a loud warning, never stamped from a fabricated null, and never fatal (ADR-0004: warn, don't abort). Proven by running a regen against the pre-slice v3 artifact: it refused the floating dimension by name and still stamped header/topbar (which v3 satisfies) — surgical, not a blanket refusal.

The key-presence subtlety: gate on the presence of the key, not the truthiness of the value. A probe must emit its chrome key even when it finds nothing (chrome.floatingRaw = { candidates: [] }), because a null/absent value cannot distinguish "the probe ran and found nothing" (garvanbay: no floating widgets — a faithful null) from "the probe never ran" (a stale artifact). Collapsing those two is the whole bug. Dimensions whose probe legitimately emits null when empty (e.g. topbarContact) fall back to the version gate alone and are marked nullable.

Detection rule: adding a field to a capture artifact is a schema migration of every artifact in the fleet. Bump schemaVersion, add a CAPTURE_REQUIREMENTS row in the same commit, and re-capture the reference sites before believing anything downstream. If a new dimension "works" without a re-capture, you are reading a stale file.

Status: shipped 2026-07-13 pt3 (schemaVersion 3 → 4, guard + the floating dimension that forced it).

A capture-plane detector is not shipped until the CAPTURE has been RE-RUN and PERSISTED

Bites: pt4 built the pinned-outside-<header> CTA detector, verified it against live WCP (it correctly found SERVICE AREAS → /service-areas), and recorded the CTA as "auto-detected — reproducible-from-capture, not manual". Two sessions later the graft check found the regen emitting ctaText: "": the detector had only ever run against a live browser session, never against a persisted artifact. The stored section-backgrounds.json was captured mid-pt4 — it carried the slice-5a carousel slideUrls but no ctaCandidates on any of its 24 pages, so classifyHeaderCta was doing exactly its job and returning null from an input that had no candidates in it. The classifier was right; the artifact was old. A reseed would have silently reverted the live CTA.

Fix: a capture-plane dimension has two halves that ship separately — the in-page probe (new fields in the capture record) and the node-side classifier — and the pipeline reads the stored record, never the live page. So "verified live" proves only the classifier. The done-criterion is: re-run the capture, then assert the new field exists in the stored JSON, then prove the regen reproduces the value. Re-captured 2026-07-13 → ctaCandidates + headerCta on 24/24 pages → the regen produces SERVICE AREAS on its own, and the D1 stamp came back a no-op (live and the regen independently agree — that no-op is the proof).

Detection rule: if a dimension's value can only be demonstrated by pointing a browser at the live site, it is not in the pipeline yet. Grep the committed capture artifact for the field before believing a detector shipped — ctaCandidates: MISSING across every page is a one-command check. (Contrast the sibling pattern "A dimension whose inputs are already-BOUNDED capture values derives at the JOIN": that kind needs no re-crawl precisely because its inputs are already in the record. A raw-geometry dimension like this one always does.)

Status: closed 2026-07-13. The stale-artifact half is the general risk — any capture-plane slice that ships a new in-page probe leaves every existing section-backgrounds.json in the fleet one version behind.

One source section rendering as several blocks: the continuation dispatches on the RENDERED component, not the matcher name

Bites: translateAbout splits a 3+-image About section into content-1/2 plus gallery-wcp (splitIndex 0/1…). The CMS transformer dispatches translators by MATCHER name, so a split continuation re-ran fromAbout on the gallery half — emitting a duplicate heading/body text block per half (or, before the fix, dropping the half entirely).

Fix: lib/cms/transformer.js SPLIT_TRANSLATORS — entries with splitIndex > 0 dispatch on entry.rendered.component (gallery-wcpfromGallery) and are fed rendered.props only (merging the matcher props back in would re-attach the heading/text the first half already carries). Unmapped continuations warn + drop loudly (ADR-0004), never silently duplicate. This is what finally lets the home welcome-section image trio (the old "image deficit" entry) reach the CMS as a real fcr.gallery block.

Detection rule: whenever a translator returns an array (one section → N rendered blocks), audit every consumer keyed by matcher name — the continuation halves are a different component wearing the same matcher, and matcher-keyed dispatch will double the text or drop the media.

Status: shipped 2026-07-10 (commit 3b71607; designed in the crashed 2026-07-09 pt3 session).

Re-matching a single-instance-dedupe loser must re-run dup-collapse — Wix responsive-doubles produce TWO losers that both re-match to duplicate blocks

Bites: the Hero single-instance dedupe re-match (falling a losing branded band to About so its content survives — see "Single-instance dedupe — earlier position always wins for Hero") interacts badly with Wix responsive-doubling: Wix emits the welcome band twice, so BOTH copies lose Hero and BOTH get re-matched to fcr.about — yielding two identical About blocks (same heading + same body). The normal dup-collapse (dedupe.js#sigBody) ran before the re-match assigned the new matcher, so it never saw them as About dups. Manifested on the garvanbay end-to-end pass: home rendered the "GARVANBAY ACCOUNTING" welcome paragraph twice (a visible demo flaw); 4 exact dups across home/accountspreparation/management-accounting/payroll.

Fix (interim): a post-process pass over the seed collapsing exact-adjacent same-_type+heading+body blocks. Proper fix: in the dedupe pipeline, after the single-instance pass swaps a loser's matcher/props, re-run the dup-collapse (or re-key sigBody on the new matcher) so re-matched duplicates collapse in-pipeline, not via a downstream seed patch. Lives in lib/assembler-fulldev/dedupe.js (shared by both lanes) — so fixing it there fixes the static lane too.

Detection rule: any dedupe/collapse that keys on a matcher/type computed before a later pass can change that key has a blind spot — the later pass (here: re-match) can create fresh duplicates the earlier collapse already ran past. Re-run the collapse after any pass that mutates the key it collapses on.

Status: interim seed post-process applied 2026-07-08 pt5; pipeline fix (re-collapse after re-match) is an open follow-up (known-issues "garvanbay end-to-end pass").

Adapter silently filters incomplete items via .filter(x => x?.src)

Bites: CMS adapters defensively filter out items missing required fields, before the canonical primitive sees them. Manifested in LogoStrip and Gallery adapters as items with no src being silently dropped — the operator never saw the gap.

// Wrong — silently drops the operator's data
const logos = (node.items ?? [])
  .filter(l => l?.src)
  .map(l => ({ src: l.src!, alt: l.alt ?? "" }));

// Right — pass through, let the primitive's loud-fail fire
const logos = (node.items ?? [])
  .map(l => ({ src: l?.src, alt: l?.alt ?? "" }));

Fix: adapters pass through. Loud-fail belongs at the primitive layer, not in defensive adapter logic.

Detection rule: any .filter(x => x?.<required-field>) in an adapter is suspect. The adapter shouldn't be the gatekeeper for what the primitive renders.

Status: shipped 2026-05-09 — LogoStrip and Gallery adapters de-filtered. Re-check any new adapter for this pattern.

A grid with NO imagery is a TEXT grid — decide media presence per BLOCK, not per item, or a faithful link/text grid renders as a wall of loud-fails

Bites: WCP's /service-areas rendered 27 img? loud-fails and /accessibility-statement 12. The instinct (again) is "the capture lost the images". It hadn't: live /service-areas "CLICK AN AREA" is a link grid — 9 items of title + href with zero images on the live page — and /accessibility-statement is an InfoCards block of text cards (title + description). Our extraction was faithful. services-1.astro simply rendered <TileMedia><Image {...image}/></TileMedia> unconditionally, so every legitimately image-less card tripped the Image primitive's (correct, deliberate) loud-fail. The adapter was already doing the right thing (image: src ? {...} : undefined) — the block ignored it.

Fix: the media decision belongs to the BLOCK, not the item:

const hasMedia = (items ?? []).some((it) => it?.image?.src)
{hasMedia && <TileMedia><Image {...image} /></TileMedia>}
  • no item has an image → a text/link grid → render no media slot at all
  • some item has an image → an image grid → keep the slot, so an item that is missing one still loud-fails — that gap is real and an operator must see it

This preserves the loud-fail exactly where it earns its keep (a service teaser that lost its photo) while not inventing one where the design has no imagery. It is the same law as "Avatar-conditional vs loud-fail" (below) — unintended absence is loud, structural absence is guarded — but the scope of "structural" here is the block, which is the bit that's easy to get wrong: judged per-item, every empty card looks like a defect.

Detection rule: before silencing a loud-fail, ask whether the LIVE section has the thing at all. If the answer is "none of them do", the absence is structural and the guard belongs at the container level; if it's "some do", keep the loud-fail — a blanket per-item {image && …} short-circuit would hide the real gaps (exactly the short-circuit deliberately removed from team-grid.astro). And prove the retained signal still fires: force a mixed grid through the real path (blank ONE image of three, confirm the placeholder appears, revert).

Status: shipped 2026-07-13 pt7 (hasMedia guard in packages/components-v3/…/blocks/services-1.astro; WCP ui-missing 39 → 0 across all 24 pages, mixed-grid loud-fail proven live and reverted; garvanbay unaffected — its grids carry images, so hasMedia is true and the markup is byte-identical).

Avatar-conditional vs loud-fail (intentional absence vs missing input)

Bites: loud-fail discipline says "missing input → visible placeholder". But for structurally optional fields, that produces false-positive noise — every Reviews block rendered 4 red "missing avatar" placeholders because the CMS schema legitimately has no headshot field.

Fix: the canonical block guards optional fields conditionally so the primitive isn't called when absence is intended:

{item?.image?.src && (
  <ItemMedia>
    <Avatar>
      <AvatarImage {...item.image} />
    </Avatar>
  </ItemMedia>
)}

Compare with the inverse pattern in team-grid.astro — we removed the {image?.src && ...} short-circuit there. TeamGrid items should have an image; a missing one is a real gap. Reviews items don't have one in the schema; a missing one is structurally correct.

Detection rule: a missing field is loud-fail if the schema declares it and the operator should know it's missing. It's structurally optional if the schema doesn't declare it — guard at the canonical level.

Status: shipped 2026-05-09 — reviews-1.astro avatar conditional, team-grid.astro short-circuit removed. See ADR-0004 for the broader loud-fail discipline.

Workspace-package path alias: literal-vs-regex trap

Bites: apps/cms/astro.config.mjs and tsconfig.json had literal Vite/TS aliases for @/lib/utils mapped to packages/components-v3/src/lib/utils.ts (the only helper at the time). Adding a second helper at the same dir (e.g. resolve-image-ref.ts) → import fails to resolve, falls through to the app-side @/* catch-all, looks for a non-existent app-side file. Build error: Rollup failed to resolve import @/lib/<new-helper>.

Fix: Switch literal aliases to regex patterns the moment a second helper lands. Mirrors the existing @/components/ui/* pattern in the same files.

// astro.config.mjs vite alias — before
{ find: "@/lib/utils", replacement: `${PKG_SRC}/lib/utils.ts` }
// after
{ find: /^@\/lib\/(.+)$/, replacement: `${PKG_SRC}/lib/$1.ts` }
// tsconfig.json paths — before
"@/lib/utils": ["../../packages/components-v3/src/lib/utils.ts"]
// after
"@/lib/*": ["../../packages/components-v3/src/lib/*"]

Detection rule: any literal-string alias for a single file in a workspace-shared package is suspect — it works for one helper, breaks the second. Default to regex from the start when you set up workspace package aliases.

Status: shipped 2026-05-10 (commit 9c99bd2 — Slice 1 of ADR-0005).

Lock current behaviour as expected behaviour (don't sneak fixes into tests)

Bites: when a normaliser, parser, or matcher has a known imperfect behaviour that's documented in an ADR — e.g. ADR-0005 §3's Test_20cm.jpg → test-cm.jpg false-positive (the _20 literal-vs- URL-encode ambiguity decodes a literal _20 as %20) — the temptation when writing a test is to assert the correct behaviour and quietly "fix" the case. That's a silent ADR amendment.

Fix: the test asserts the current documented behaviour, including known imperfections. If the imperfection should be fixed, that's an ADR amendment + a test update, not a sneaky one-line change.

// In upload-image.validate.mjs — Test_20cm.jpg locks the §3 false
// positive as expected. Fixing the literal-vs-URL-encode ambiguity
// is an ADR amendment, not a Slice 2 change.
['Test_20cm.jpg', 'test-cm', '.jpg'],

Detection rule: if a test you're about to write asserts behaviour that diverges from what the ADR / spec / pattern entry documents, stop. Either the doc is wrong (open the doc-update first) or the test is wrong. Don't paper over the gap with an aspirational test.

Status: shipped 2026-05-10 (Slice 2 of ADR-0005, validation script).

Lib doesn't quite match ADR — post-process to match the spec

Bites: an ADR §3 specifies the contract for a transformation ("non-[a-z0-9] runs collapse to -"). The lib chosen to do most of the work — transliteration.slugify() — does most of it but leaves underscores intact and doesn't collapse all-non-alphanumeric input to empty. Two divergences from the spec.

Fix: keep the lib for the heavy work (transliteration, diacritic-folding, the part you don't want to write) and add a tiny deterministic post-process to enforce the ADR contract. Don't swap libs; don't relax the ADR.

let stem = slugify(input, { lowercase: true, separator: '-' });
// slugify keeps underscores and doesn't collapse all-non-alphanumeric
// to empty; ADR-0005 §3 specifies "non-[a-z0-9] runs" — post-process
// to match the spec.
stem = stem.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
if (!stem) stem = 'image';

Detection rule: when reading a lib's output against an ADR's documented expected output, check every case the ADR enumerates. A single divergence is a post-process fix. Multiple divergences is a "wrong lib" signal — switch libs before the post-process accumulates.

Status: shipped 2026-05-10 (Slice 2 of ADR-0005, apps/cms/scripts/upload-image.mjs#normaliseStem).

Architectural fix fails for a reason orthogonal to the hypothesis — re-diagnose

Bites: a hypothesis is formed ("throughput is HTTP/1.1 connection-pool contention") and an architectural fix is queued ("switch to HTTP/2"). The fix fails for a reason that has nothing to do with the hypothesis ("R2's S3-compatible endpoint doesn't accept HTTP/2 — ERR_HTTP2_ERROR: Protocol error"). The temptation: revert and try the next item on the options list. That's a guess on top of an unconfirmed hypothesis.

Fix: when an architectural fix fails for a reason orthogonal to what it was meant to test, the hypothesis is neither confirmed nor falsified. Re-diagnose before queuing another change. Otherwise you're picking option B because A was unavailable, not because the data points at B.

Today's run: - Hypothesis: HTTP/1.1 pool contention → fix: HTTP/2 handler. - HTTP/2 fails because R2 S3 API doesn't speak it. Hypothesis not tested. - Wrong move: pivot to "skip-HEAD on fresh uploads" because it's next on the list. Right move: surface, re-think. - Re-diagnosis surfaced two findings (corpus is 6 MB total → not bandwidth- bound; sequential RTT 125ms × 50 concurrency theoretical = 200 PUT/sec ceiling vs measured 43 → ~22% of theoretical) that pointed to a different experiment (concurrency sweep, see next entry).

Detection rule: before reverting an architectural fix and trying the next one, ask: "did this attempt actually test the hypothesis I was holding?" If the answer is no, the next move is re-diagnosis, not the next item on the option list.

Status: shipped 2026-05-11 (Slice 4 of ADR-0005, throughput investigation; see docs/pipeline/session-2026-05-11-handover.md).

Workspace-package exports map: literal-file vs wildcard trap

Bites: packages/components-v3/package.json declared an exports map with "./lib/utils": "./src/lib/utils.ts" — a literal pin for the only helper that existed at the time. Adding a second helper (get-image-src.ts, resolve-image-ref.ts) and importing it as @fcr/components-v3/lib/get-image-src from a CMS adapter resolves fine in Vite (the regex alias ^@fcr/components-v3/lib/(.+)$ → .../lib/$1.ts matches), but astro check (TSC) fails because TypeScript walks the package's exports map first and the literal-pinned path doesn't match. The error surfaces as ts(2307): Cannot find module '@fcr/components-v3/lib/<helper>' or its corresponding type declarations.

This is the package.json-side counterpart to the 2026-05-10 known-patterns entry "Workspace-package path alias: literal-vs-regex trap". That earlier entry covers Vite/TS aliases. This one covers the npm exports-map subresolver. Both fail the same way: a literal single-file pin works for one helper, breaks the second.

Fix: Switch the literal entry to a wildcard the moment a second helper lands. Mirrors the Vite/TS-alias fix.

// packages/components-v3/package.json — before
"exports": {
  "./lib/utils": "./src/lib/utils.ts"
}
// after
"exports": {
  "./lib/*": "./src/lib/*.ts"
}

Detection rule: any ./lib/<single-name> literal in a workspace package's exports map is suspect — it works for one helper, breaks the second. Default to wildcards in exports from the start when seeding a workspace package's lib directory. Same wildcard discipline applies to ./components/<name> and ./blocks/<name> mappings in the same file.

Status: shipped 2026-05-12 (Slice 3 of ADR-0005, package exports wildcard added).

Parameter sweep distinguishes hypotheses a single data point can't

Bites: a single throughput measurement (43 PUT/sec at concurrency=50) fits multiple hypotheses simultaneously: H1 (rate-limited remote), H2 (under-tuned client), H3 (network/source-IP path), H4 (HEAD-before-PUT doubles round trips). No additional single measurement at the same concurrency can distinguish them — they all predict the same point.

Fix: sweep across the variable the hypotheses make different predictions about. The curve shape, not any single point, is the diagnostic.

Today's sweep at concurrency = [50, 100, 200, 500, 1000]:

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

Flat with noise → H1 confirmed (per-token or per-account rate-limit), H2/H3/H4 ruled out as the binding ceiling. Five data points told a story no single point could.

Curve-shape decoder: - Flat across the sweep → remote-imposed ceiling (rate-limit, capacity) - Linear scaling → client under-tuned, more headroom available - Climbs then plateaus → mixed (client under-tuned to a remote ceiling) - Climbs then degrades → connection-pool / TCP / resource exhaustion - Drops at one point then recovers → likely retry behaviour from a triggered limit

Detection rule: at a hypothesis-distinguishing decision point, prefer a sweep to a single-point retest. The compute cost is N× the single-point cost; the diagnostic value is much greater than N×. Five minutes of parameter-sweep saves an hour of guess-and-check.

Status: shipped 2026-05-11 (Slice 4 of ADR-0005, throughput investigation, apps/cms/scripts/.tmp/sweep-throughput.mjs).

emdash seed targets local data.db, not remote D1

Bites: after editing apps/cms/seed/seed.json (Slice 3 image transformer or any direct content edit), running npm run seed (which calls emdash seed) reports success — but the deployed Worker still serves stale content. The deployed CMS reads from the remote D1 bound to the Worker; emdash seed writes to a local data.db SQLite that's only relevant in dev. The two stores can drift indefinitely with no warning.

This surfaced post-Path-A on 2026-05-12 when the deployed CMS rendered an empty hero despite seed.json carrying the rich content. Looked like an adapter bug; was an unsynced data store.

Fix: apps/cms/scripts/reseed-d1-from-json.mjs — reads seed.json, generates DELETE-by-slug-then-INSERT SQL per page (to avoid the unique (slug, locale) constraint when ids drift between runs), upserts globals by id, applies via wrangler d1 execute --remote --file. Idempotent.

Detection rule: any change to apps/cms/seed/seed.json that's meant to be visible on the deployed Worker needs node apps/cms/scripts/reseed-d1-from-json.mjs applied to remote D1. The npm run seed script is for local-dev only — treat its success message as orthogonal to "the deployed site has my changes".

Future fix candidate: patch emdash itself so emdash seed accepts a --remote flag and applies the same pattern; would fold the operational gotcha back into the standard command. Out of scope for now — single-line wrapper script handles it.

Status: shipped 2026-05-12 (commit 7a5ffa3).

astro dev reads a miniflare D1, NOT data.db either — a THIRD store

Bites: the store-drift problem has three tiers, not two. emdash seed writes apps/cms/data.db; the deployed Worker reads remote D1 (the entry above); and astro dev (via @astrojs/cloudflare) reads a local miniflare D1 under apps/cms/.wrangler/state/v3/d1/*.sqlite — a third store that emdash seed never touches. On 2026-07-04 (pt2) every CMS page rendered its empty-state in local dev — getEmDashEntry returned no content — even though data.db was fully seeded (verified via node:sqlite). Globals/header/footer still rendered, so it looked like a page-only bug; the real cause was the empty miniflare D1 (confirmed: wrangler d1 execute DB --local --command "SELECT ... FROM ec_pages" returned 0 rows).

Fix / how to verify content in local dev: seed the miniflare D1 directly, then curl — wrangler d1 execute DB --local --file <reseed.sql> (same DELETE-by- slug + INSERT SQL that reseed-d1-from-json.mjs generates for remote, but binding DB, flag --local). A running astro dev picks up the new rows live, no restart. The deployed Worker needs no equivalent — remote D1 already holds the content.

Detection rule: "every page renders empty-state in local dev but the deployed site is fine" = the dev-runtime D1 is unseeded, not a render bug. Three stores, three seed steps: emdash seeddata.db (dev-only, and NOT even what astro dev reads); --local execute → miniflare D1 (what astro dev reads); reseed-d1-from-json.mjs → remote D1 (deployed). See the entry above for the remote half.

Status: captured 2026-07-04 (pt2, slug-routing slice verification).

Icon static-map gaps surface as truncated placeholder text — scan the deployed HTML to enumerate them

Bites: the static Icon map (ADR-0004) renders a loud-fail placeholder for any unmapped name, and the placeholder's visible text is String(name).slice(0, 8). So a missing circle-check — the default checklist / feature bullet, defaulted portfolio-wide in translate.js and lib/cms/transformer.js — showed on the live page as the literal text circle-c, which reads like corrupt content, not a missing icon. The map had been grown reactively (one name at a time as a block happened to use it), so the default bullet icon itself was never registered and every checklist across every site rendered the red placeholder.

Fix: enumerate the whole gap set empirically rather than one-at-a-time — curl every deployed page and grep -oE 'missing icon: [^"<]+' | sort | uniq -c. That surfaced 5 unmapped icons at once (circle-check 78, menu 20, linkedin 20, clock 20, search 14). Register the lucide-static ones (verify the .svg exists in the package first), add aliases where the CMS enum name differs from the glyph (email→mail, twitter→X post-rebrand). Additive-only edits can't regress already-resolving icons.

Detection rule: literal short lowercase-hyphen text on a rendered page (circle-c, menu, arrow-ri) that looks like a truncated identifier = an unmapped Icon, not content. And when you fix one, run the deployed-HTML missing icon: scan to catch the rest in the same pass — the map drifts incomplete because it's grown reactively. Shared primitive: scaffold.js copyTree's icon.astro into per-site builds, so the map is one source for both the CMS and the assembler.

Status: shipped 2026-07-04 (pt2, commit fa92cd6). linkedin closed pt4 (commit 3069ee4) — simple-icons drops it (trademark) and lucide-static has no brand glyphs, so the official mark is vendored locally as a fill-style linkedin.svg in the Icon dir and mapped in SIMPLE. Pattern for any brand glyph the icon packages omit: drop a fill-style SVG next to icon.astro, import it, map it — Astro compiles the local .svg as a component just like the package ones. Portfolio was placeholder-free after this. (google-reviews still glyphless but not firing — see known-issues.)

Phase 3 regression check — deployed-output comparison, not just file-level shared-surface

Bites: the implementation-slice protocol's Phase 3 regression check says "list the consumers, exercise one of each, confirm no visual regression." The temptation is to interpret this at the file level: "did my slice touch shared code? if no, declare N/A."

Slice 3 of ADR-0005 on 2026-05-12 declared Phase 3 N/A — strictly true (no shared canonical block surface was touched; only a new helper file with no preexisting consumers, plus widening the package exports map). But the user reported afterwards that the deployed CMS "doesn't look anything like our prior migration or the original site." The pre-existing visual divergence was 9 hand-rolled adapters violating ADR-0004's Path A pattern — and Slice 3 didn't cause the divergence, but it made it newly visible by fixing the image-404s that previously dominated the page's broken-ness.

Fix: Phase 3 regression check expands to include a deployed output sanity check: render the deployed page after the slice lands, eyeball or programmatically diff it against the canonical "good" output (the per-site assembled build at master.<domain>-fulldev.pages.dev or live as a baseline). If the deployed output materially differs from baseline in ways the slice's scope wouldn't predict, surface to the user — even if no shared code was touched.

The strict file-level check is necessary but not sufficient. The two together are the contract.

Detection rule: before declaring Phase 3 N/A, ask: "would a user opening the deployed page after this slice notice anything different that I didn't intend?" If yes — or if pre-existing brokenness was just newly exposed by your fix — that's still in scope for surfacing.

Status: captured 2026-05-12. The 2026-05-12 Path A enforcement slice was the followup that fixed the pre-existing divergence surfaced by this gap.


emdash is headless — all visual behaviour is FCR component code

Bites: it's tempting to expect the CMS to "handle" layout, image placement, or which block renders. emdash does none of that. It stores the block-type field values (heading, imageUrl, imagePosition: "left") and provides the editor UI — nothing more. It has no concept of what "left" means visually. imagePosition sat in the data doing nothing because no renderer honored it.

Fix / mental model: every visual decision — layout, image side, which canonical block a type maps to — lives in our code: the FCR block components (content-1/content-2), the adapters (apps/cms/src/components/fcr-blocks/*), and the Section primitives. When something renders wrong, the fix is on our side, never an emdash setting.

Detection rule: if a schema field (like imagePosition) exists but "doesn't do anything", the renderer isn't consuming it yet — that's an adapter/canonical change, not a CMS config.

Status: framing established 2026-07-04 (WS-3 spike).

emdash media_picker stores a URL string — image fields must be scalar, not objects

Bites: ADR-0005 stored block image fields as { src, originalName } objects. The emdash editor declared them as scalar text_input, which can't display an object → the field showed blank; the operator couldn't see or edit the image. Switching to the native media_picker element doesn't change this on its own — the picker also stores a plain URL string (per @emdash-cms/admin BlockKitMediaPickerField: "interchangeable with text_input").

Fix: operator-editable image fields = media_picker element + scalar URL-string data. Migrate legacy objects → resolved URL string (r2:<key>PUBLIC_MEDIA_BASE/<key>), provenance to R2 metadata. See ADR-0005 "Option A" followup + migrate-about-imageurl-to-string.mjs.

Detection rule: any emdash editor field showing blank over data that renders fine on the page = a data-shape/editor-type mismatch (object vs scalar). The editor field type dictates the data shape.

Status: shipped 2026-07-04 (fcr.about; commit 8ffcf5f). Extended 2026-07-04 (pt3) to all top-level image fields — fcr.hero.imageUrl, fcr.cta-strip.imageUrl, fcr.header.logoUrl — via the generalized migrate-block-image-fields-to-string.mjs (commit 8a51bc5). Repeater-item images stay open (Block-Kit repeater sub-fields are scalar-only). Adapters needed no change — getImageSrc was already shape-agnostic, so the flatten couldn't regress rendering.

Reseeding a global must UPDATE by slug — emdash's ULID id never matches the seed id

Bites: reseed-d1-from-json.mjs's globals path did INSERT INTO ec_globals (id, content) … ON CONFLICT(id) DO UPDATE. But emdash creates the globals row with a ULID id (e.g. 01KR6MXEWE…), while seed.json's global carries id: "site". The two never collide, so ON CONFLICT(id) never fired the UPDATE — every reseed INSERTed a junk id="site", slug=NULL, status=draft row and left the real published row untouched. getEmDashEntry("globals","site") reads by slug, so the site kept serving the stale globals content; the reseed looked like it worked (exit 0, "changes: N") but silently no-op'd the globals. Invisible for months because getImageSrc tolerates the stale object-shaped image field — surfaced 2026-07-04 (pt3) only when the header logoUrl migration to a scalar string didn't take on the live row.

Fix: globals reseed = UPDATE ec_globals SET content=json(…) WHERE slug=<slug>. Hits the row the site actually serves and preserves its id + revision links. (Pages were never affected — their reseed already does DELETE-by-slug then INSERT, which is id-independent.) Junk rows a prior run created must be deleted out-of-band (DELETE FROM ec_globals WHERE id='site' AND slug IS NULL).

Detection rule: an INSERT … ON CONFLICT(<key>) upsert only works if the seed's <key> equals the value the CMS assigned. When the CMS generates its own ids (ULIDs) and the seed hard-codes a human id, upsert- by-id is a silent no-op-or-duplicate. Match on the stable natural key (slug) the read path uses, not the surrogate id. A reseed reporting success is orthogonal to "the deployed globals changed" — verify the row count and shape (SELECT id, slug, status FROM ec_globals), same discipline as the "emdash seed → local data.db" gotchas above.

Status: shipped 2026-07-04 (pt3, commit 1d31300).

Fixing a silently-broken sync is destructive when the two stores have diverged

Bites: the corollary to the entry above, and it bit hard the same session. Because the globals reseed had never actually written the live row (the id-vs-slug bug), the live globals were the original rich seed (8-item nav menu, topbar, 3 footer columns) — and seed.json's globals had drifted to a thin skeleton (header with 0 menu items) that nobody noticed because the broken sync never applied it. The moment the sync was fixed to UPDATE-by-slug and re-run, it did the thing it never used to do: it faithfully wrote the stale seed over the good live content. Half the site (menu/topbar/footer) vanished. The bug fix was correct; running it against a diverged seed was the damage.

Fix / recovery: emdash keeps a revisions table (id, collection, entry_id, data, author_id, created_at); the entry's live_revision_id still pointed at the pre-clobber revision (an in-place UPDATE … SET content doesn't repoint it). Recovery: pull revisions.data for that id (shape { content: [...blocks] }), reconcile any stale sub-values (the old revision's header logo was the original /assets/… local path → re-point to the migrated R2 URL), write it back into both D1 and seed.json so the seed is the source of truth again. No redeploy — data-only.

Detection rule: before running a sync/migration/reseed that writes one store from another, ask "have these two diverged, and which is actually canonical right now?" A sync you just repaired is exactly when the source may be staler than the destination — dry-run and diff the payload against live before applying. And when a write-from-seed can overwrite operator/live content, the seed must be reconciled up from live first (or the write scoped to only the fields you intend to change). This is the concrete form of the standing "reseed reverts admin-only edits / seed-vs-D1 source of truth" known-issue.

Status: recovered 2026-07-04 (pt3, commit 8ce82af); rich globals restored from revisions, seed.json reconciled up from live.

SectionSplit needs a container-query context — the CMS <main> must be an @container

Bites: content-2 / SectionSplit render side-by-side via @5xl:grid-cols-2 — a container query, not a viewport media query. It only becomes two columns inside a sized @container ancestor. The per-site assembler provides that via its section-provider wrapper; the CMS rendered <main><slot/></main> with no container, so every split stayed single-column — a side image fell full-width below the text even though the content-2/section-split/order-2 classes were all present in the HTML.

Fix: apps/cms/src/layouts/Base.astro <main class="@container/section-provider">. The classes being in the DOM is not enough — the container context has to exist for the query to match.

Detection rule: when a canonical block's responsive layout "does nothing" in the CMS but works in the assembler, check whether the CMS replicates the assembler's wrapper context (section-provider / @container), not just the block call. Container queries fail silently without an ancestor container.

Status: shipped 2026-07-04 (commit 63beb5f).

Native Portable Text types (block / code / image) need FcrBlocks renderers

Bites: the emdash editor lets an operator insert native blocks — paragraph/heading/quote/lists (all _type: "block"), _type: "code", _type: "image". The dispatcher only knew fcr.*, so any native insert (even an empty paragraph left behind while editing) tripped the loud-fail "Missing renderer" banner on the live page.

Fix: render the native types — Prose.astro (block → emdash PortableText, empty skipped), CodeBlock.astro, ImageBlock.astro (native image via asset.url, loud-fail if none). Register under non-fcr.* keys so check-renderers (which only enforces fcr.*) ignores them. The image renderer doubles as the operator's toolbar image-insert path.

Detection rule: any editor that exposes native block insertion needs the dispatcher to cover the native PT types, not just the plugin's custom types. New emdash block type in the toolbar → new renderer or a banner.

Status: shipped 2026-07-04 (commits 25112bc, 5394c81).

Cloudflare Access roleMapping: {} crashes One-time-PIN logins

Bites: the access() adapter's resolveRoleFromGroups does if (!config.roleMapping) return defaultRole; for (const g of groups)…. An empty object {} is truthy, so it skips the early return and iterates identity.groups — which is undefined for One-time-PIN (OTP carries no groups) → TypeError: groups is not iterable → emdash returns "Authentication failed" after the user passed the Access doorman. Cost a deploy to diagnose (via wrangler tail).

Fix: omit roleMapping entirely unless the IdP actually returns groups. Related security note: don't pair a broad defaultRole with syncRoles: true — that silently re-promotes demoted users on every login (retroactive privilege escalation; flagged by the commit security review, narrowed to defaultRole: 30, no syncRoles).

Detection rule: "Authentication failed" after the Access prompt = the worker rejected a valid Access JWT; wrangler tail shows the real throw. Don't pass empty config objects to provider adapters.

Status: shipped 2026-07-04 (see ADR-0006; commits a41544c, dde9cf2).

The .output transcript of a background subagent is 0 bytes until it completes

Bites: diagnosing a hung/slow background agent from its transcript file size is unreliable — the JSONL is only flushed on completion, so it reads 0 bytes the whole time it runs. On 2026-07-04 a Fable pressure-test run was wrongly inferred to be a lane cap from an hour-long 0-byte file; the lane was healthy (a 5-second health check confirmed it), the run was just a genuine one-off hang.

Fix / detection rule: judge a background agent by the completion notification, not file size. For Codex use codex-companion.mjs status <taskId> (real phase + elapsed). Never cat the .output file to check progress — it's the full transcript and overflows context. Degrade lanes on confirmed failure, not inferred.

Status: captured 2026-07-04.

Wix serves the page description in og:description, not meta[name=description]

Bites: seed-from-build.js read the page description from meta[name="description"] in the captured <slug>.head.html. Wix's rendered head has no plain meta description — the copy lives in og:description (and the JSON-LD). So the lookup returned '' and every CMS page shipped a blank seo_description while the live site had real, keyword-rich descriptions. The crawl wasn't losing the data; the transform was reading the wrong tag. Surfaced 2026-07-05 when a side-by-side vs live showed the emdash build had titles but no meta descriptions at all.

Fix: fall back meta[name=description]og:description in the extractor. All 24 wfpainters pages went 0 → non-empty. Same tag applies to the render layer — the head already carried og:description, only the plain meta description and downstream fields were empty.

Detection rule: when a Wix-sourced SEO field comes through blank but the live site clearly has it, check whether Wix put it in an OG/Twitter tag or JSON-LD instead of the vanilla meta tag. The static head snapshot carries the OG + JSON-LD payload even when the plain meta tag is absent.

Status: shipped 2026-07-05.

Regenerate structured data (JSON-LD) from extracted fields — don't re-ship the crawled block

Context: the crawled LocalBusiness JSON-LD is captured intact in <slug>.head.html, so re-emitting it verbatim is tempting and cheap. But the Wix block carries an empty url, a stale wixstatic image path, and Wix-specific @id anchors — shipping it as-is re-publishes off-domain, broken structured data and isn't operator-editable.

Pattern: flatten the crawled NAP into a structured fcr.business global block (name / telephone / PostalAddress parts / image / sameAs), register it in the marketing-blocks plugin so the globals admin accepts and edits it, and have Base.astro rebuild clean on-domain JSON-LD from those fields at render time (url: Astro.url.origin, absolute image). The crawled block is the extraction source, never the shipped artifact. Chosen over verbatim-carry on 2026-07-05.

Detection rule: any captured third-party metadata destined for our output that embeds the source's own URLs/ids is a regenerate candidate, not a copy candidate — re-shipping it leaks the source domain.

Status: shipped 2026-07-05 (fcr.business + Base.astro JSON-LD).

emdash template's static route files ship hardcoded demo SEO — audit them on onboard

Bites: apps/cms/src/pages/index.astro (a static route that takes precedence over the [slug].astro dispatcher) rendered the real CMS home content but passed a hardcoded description="Build products people actually want. The all-in-one platform for modern teams." to Base — the emdash scaffold's demo string. It never read page.data.seo_description the way [slug].astro does. Invisible until the live homepage was diffed after the SEO fix — subpages had real descriptions, the homepage had the template's marketing copy.

Fix: read page.data.seo_description and pass it as description, matching [slug].astro.

Detection rule: static route files (index.astro, contact.astro, pricing.astro) bypass the dynamic dispatcher and can retain scaffold defaults. When onboarding a site, diff every static route's head against a dynamic one — divergence in og/description/title wiring means a template leftover.

Status: shipped 2026-07-05.

Wix transform-suffixed image paths carry the clean media id as the final segment; logos are local-only

Bites: transform-seed-images.mjs's basename deriver matched only /assets/images/<one-segment>$, so it returned null — "cannot derive basename", field skipped, ref 404s on the deployed worker — for two shapes:

  • Wix transform/srcset paths/assets/images/<id>~mv2.jpg,h_244,q_90,enc_avif,quality_auto/<id>~mv2.jpg. The embedded slash (transform segment) defeats a single-segment match. But the final path component is the clean wixstatic media id — take split('/').pop() after stripping ?#.
  • FCR logos/assets/logos/<Name>_logo.png. Clean path, but under /assets/logos/, not /assets/images/. Logos are FCR build assets, not wixstatic media — there is no static.wixstatic.com/media/<name> to download; the local build dir (builds/<domain>.ie/public/assets/logos/) is the only source. Because the logo doubles as og:image + JSON-LD image, this shape's failure breaks link previews + structured-data image even when every SEO tag is present.

Fix: resolveAssetSource returns { basename, subdir, isWixMedia }subdir from /assets/(images|logos)/, basename = final segment (drops any transform tail), isWixMedia = subdir === 'images'. ensureLocalFile(basename, dir, isWixMedia) loud-fails for a non-wix asset whose local file is missing rather than 404'ing wixstatic. --selftest locks the shapes network-free.

Detection rule: a deriver that anchors on a single trailing segment under one fixed dir is suspect — Wix serves the same image at a clean path and a transform-suffixed path, and FCR-generated assets (logos, and still-open /assets/videos/*.mp4) live under sibling dirs with no wixstatic fallback. Enumerate the --dry "cannot derive basename" set against a real seed before assuming coverage.

Status: shipped 2026-07-05 pt2 (commit 77bdb33). Video shape (/assets/videos/) still open — see known-issues.

Correction (2026-07-13): "logos are local-only" is false — they are in the client's Wix library; what's missing is the ID, not the asset. The derivation rule above stands (you cannot reconstruct a wixstatic URL from /assets/logos/<Name>_logo.png), but the inference drawn from it — that the logo has no wixstatic source — is wrong, and it sent a session looking for a file to upload when the asset was already there. WCP's searchFiles pool holds Waterford County Painters_logo (8b4be4_4651bc7c…~mv2.png, 1267×898). The reason the deriver can't resolve it is that the scraper saved the logo under its display name (URL-encoded: Waterford_20County_20Painters_logo.png) instead of its media id — every other image keeps the id, which is exactly what makes the id-join work. So the correct statement is: a logo path carries a name, not an id, so the by-id plane can't see it — and the API can still find it by name. Consequence for ADR-0010 slice C: logo provenance is recoverable from the API (real filename + dimensions), it just can't ride the media-id join like the rest.

Swapping the CMS demo tenant — reseed D1 AND rebuild (theme is build-baked); pin the restore SHA

Bites (two):

  1. The 2026-07-05 handover's restore recipe was git checkout apps/cms/seed/seed.json. But the wfpainters swap had been committed (HEAD a7a3bbf) with a clean working tree — so git checkout <file> restores it from HEAD, i.e. wfpainters, the opposite of the intent. A file-restore recipe only reaches a prior state if that state is HEAD; when the thing you want to undo is committed, pin the parent SHA (git checkout 8ce82af -- apps/cms/seed/seed.json).
  2. A tenant swap is not a D1-data-only change. global.css is emitted at build time by prebuild (emit-theme-css.mjs) from builds/<seed.meta.name>/theme.json. Reseed D1 alone and the worker serves the new tenant's content with the old tenant's palette/fonts. The rebuild+deploy re-bakes the theme.

Also: reseed-d1-from-json.mjs only DELETE-by-slug's the slugs it re-inserts, so swapping between sites with disjoint slugs (garvanbay 10 vs wfpainters 24, only home shared) leaves the old site's pages as orphans — clear ec_pages fully first.

Detection rule: any "restore/undo via git checkout <file>" recipe is correct only if the target state is HEAD — pin the SHA otherwise. And any CMS change that should alter appearance (not just content) needs the build step, because the theme is a build artifact, not runtime data.

Status: captured 2026-07-05 pt2 (commit 77bdb33).

Provisioning a fresh tenant D1 — schema is created by a human /_emdash login, not a migrate command

Bites: a brand-new wrangler d1 create'd database is empty — no ec_pages/ec_globals. reseed-d1-from-json.mjs does raw INSERT INTO ec_pages (…, content, title, seo_description, …) and fails on a schemaless DB. There is no emdash remote-migrate command: system tables come from a Kysely migration runner, but the content tables are created dynamically by SchemaRegistry.createContentTable only when a seed is applied (node_modules/emdash/…/schema/registry.ts), and the per-collection field columns (content/title/seo_description) are ALTER TABLE ADD COLUMNs driven by the seed's collection defs. So a hand-written CREATE TABLE ec_pages would still miss the field columns.

Fix / sequence for onboarding a new per-site worker: 1. wrangler d1 create <db> → record database_id into sites/<site>.jsonc. 2. Deploy the worker so its DB binding points at the empty D1. 3. Reach an /_emdash route once and log in (Cloudflare Access OTP). The emdash runtime init runs runMigrations + auto-seed on an empty DB (emdash-runtime.tsapplySeed, astro/middleware.mjs:806-808), creating the tables and the correct field columns. A bare public request instead 302s to /_emdash/admin/setup. Either path needs an /_emdash hit, which on the deployed worker is behind Access — so a human login is the bootstrap. 4. SITE=<site> node scripts/reseed-d1-from-json.mjs now succeeds.

Detection rule: "reseed fails with no such table / no column named content" on a new tenant = the schema was never bootstrapped. There is no headless remote-migrate; either drive the /_emdash login (Slice 3 automation target) or reproduce runMigrations+applySeed against remote D1 yourself. A fresh D1 is not ready for reseed until a seed has been applied through the runtime.

Status: captured 2026-07-06 (ADR-0008 Slice 2, waterfordcountypainters onboarding).

emdash bakes the seed from virtual:emdash/seed — a per-site fleet must generate .emdash/seed.json

Bites: emdash embeds the seed at build time via a Vite virtual module (generateSeedModule, emdash/dist/astro/index.mjs:1241) that reads, in precedence order: (1) .emdash/seed.json → (2) package.json#emdash.seed → (3) seed/seed.json. The runtime auto-seed imports the same baked module (loadSeed → import("virtual:emdash/seed"), load-…mjs:9). There is no emdash({ seed }) option — it's filesystem discovery. So every per-site worker baked whatever package.json#emdash.seed pointed at (garvanbay), and a fresh tenant's first-login auto-seed applied the wrong site's content (the WCP D1 came up garvanbay; had to be cleared + reseeded by hand).

Fix: apps/cms/scripts/emit-seed.mjs (prebuild step) copies the active site's seed/<SITE>.json.emdash/seed.json (precedence slot #1, gitignored), so both the build bake and the runtime auto-seed use the right tenant. Garvanbay is unaffected (same content, now via .emdash/ instead of the package.json pointer). Verified via the built artifact: a SITE=waterfordcountypainters build has zero garvanbay strings in dist/ (per the "verify the bake, not the wiring" rule — same discipline as the Tailwind @source gap).

Detection rule: any emdash per-tenant deployment that relies on auto-seed must generate .emdash/seed.json per site — a static package.json#emdash.seed bakes one tenant's content into every worker. emit-seed.mjs runs in prebuild alongside emit-theme-css/emit-wrangler; all three read the active SITE via scripts/lib/resolve-site.mjs.

Also (Slice 1 finding): the seed file path is an implicit contract beyond the CMS build scriptslib/capture-section-backgrounds.js and lib/cms/apply-section-tones.js read apps/cms/seed/<site>.json too. Renaming seed.json<site>.json had to sweep lib/ as well; any future seed-layout change must check both trees.

Status: shipped 2026-07-06 (ADR-0008 Slice 2, emit-seed.mjs).

CMS seed extraction — matcher text lives in entry.props, not the translated slot

Bites: lib/cms/seed-from-build.js builds a CMS seed by running the assembler matchers then transformLayoutMap (lib/cms/transformer.js). The transform read block props as entry.rendered?.props || entry.props — preferring the fulldev-translated props. But the fulldev translators (lib/assembler-fulldev/translate.js) move every text field into a slot HTML string (translateHeroslot: '<h1>…</h1>', etc.), leaving rendered.props structure-only. So every heading/body/subtext read '' and the CMS pages rendered structure with no text (waterfordcountypainters, 2026-07-06). The matcher's extract output (entry.props) had the text all along. garvanbay never hit this — its seed was reconciled from live/revisions, not generated through this path.

Fix: sections merge { ...rendered.props, ...entry.props } (matcher extract wins; translated props fill normalised extras like merged links, items, columns). Globals (Header/TopBar/Footer) keep the translated-props preference (they need the normalised menus/contact/columns shapes). transformer.js is imported only by seed-from-build.js, so the per-site build lane can't regress.

Detection rule: CMS pages render block structure but blank text = the seed transform is reading the translated slot string instead of the matcher's extract() fields. The text ground truth is entry.props, not entry.rendered.

Status: shipped 2026-07-06.

Block image fields must be scalar resolved-URL strings at render — objects abort SSR; flatten repeater items too

Bites: transform-seed-images.mjs rewrites image refs into the ADR-0005 §6 object shape { src: "r2:<key>", originalName } — for top-level fields (imageUrl/logoUrl) AND repeater items' src. But the render path expects a scalar string: a raw-<img> adapter calls resolveImageRef(), which does ref.startsWith('r2:') — on an object that throws, and the exception aborts the whole page SSR. Manifested on waterfordcountypainters (2026-07-06): every page rendered blank body / the themed 404, globals-only, after the image transform+reseed. garvanbay dodged it because its home has no item-images and its top-level fields were already flattened by the 2026-07-04 media_picker migration.

Fix: flatten { src, originalName }resolve(src) (a PUBLIC_MEDIA_BASE URL string) for every image key including repeater items — migrate-block-image-fields-to-string.mjs now walks imageUrl/logoUrl/src comprehensively (the earlier "items not touched" note was an editor-only concern; rendering needs strings everywhere). SITE-aware via the fleet resolver, idempotent.

Onboarding pipeline order (any new CMS site): seed-from-build.jstransform-seed-images.mjs (§6 objects) → migrate-block-image-fields-to-string.mjs (flatten to strings) → reseed-d1-from-json.mjs. Skip the flatten and the site SSR-crashes to a blank/404 body even though the data looks present in D1.

Detection rule: a CMS page that 404s / renders globals-only after an image transform, with valid content in D1, = an object-shaped image field reaching resolveImageRef. Grep the seed for "src": { / "imageUrl": { — any image key whose value is an object is a latent SSR crash. Better long-term fix (deferred): have transform-seed-images emit resolved strings directly, retiring the separate flatten step.

Status: shipped 2026-07-06 (ADR-0008 WCP onboarding).

A wrong winner in a RANKER may be a POOL rule, not a score bug — and the fix the wiki proposes can regress the very site the rule exists to protect

Bites: (2026-07-13 pt4) known-issues recorded WCP's brand nondeterminism as "the ranker isn't purely score-ordered and a transient surface signal can displace a stable logo-derived brand", with a fix candidate: "make the ranker prefer a high-confidence logo cluster over a lower-scored surface candidate". Both halves are dangerous.

The observation was right — #092b57 (score 9.22) beat #183a6f (score 16.97). The inference — "so the ranker is buggy, make logo win" — was wrong twice over:

  1. It is not a ranking bug. deriveBrandPalette deliberately gives painted-SURFACE families priority over logo-only families, and score orders only within that pool. The rule exists 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. The surface preference is the only thing getting garvanbay right.
  2. The proposed fix would invert garvanbay. "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, sitting in the repo.

What was actually wrong: the surface preference was an absolute override, so it also fired in a case it was never designed for — when the two "competing" families are the same colour. WCP's two navies are hueDist 2.71° and rgbDist 32 apart: one brand navy that the clusterer had split into two families. A 9-point family deposed a 17-point one purely because it was painted.

Fix: make the override a two-condition tie-break — a surface family may depose the score leader only if it is (a) a genuinely DIFFERENT colour (hueDist ≥ 25° between families) and (b) actually PAINTED (≥ ROLE_WEIGHT['button-bg'] of fill evidence). Otherwise the top scorer wins. When the leader is itself a surface family the challenger is the leader, the gap is 0, the tie-break declines, and nothing changes — so the common case is untouched, and the tie-break can only ever depose, never block. It fails safe.

Two traps found while fixing it, both worth carrying forward:

  • Do NOT discriminate on rgbDist/lightness ("is one a shade of the other"). It runs backwards on the real data: garvanbay's DOM-must-win pair is 41.3 apart, WCP's logo-must-win pair only 32.0. Δlightness is 0.080 vs 0.077 — indistinguishable. Hue between families is the signal (136.8° vs 2.7° — a 50× separation).
  • Do NOT use a score MARGIN. logoAgreement awards +4 to whichever family agrees with the logo — so the logo family gets a bonus precisely when the logo colour differs from the painted brand colour, the exact case the gate exists for. Strip garvanbay's incidental cyan logo clusters (an ordinary single-colour logo) and its cyan surface drops 17.07 → 13.07 while the yellow-green holds 17.49: any margin under ~4.5 flips it. The floor must be absolute, never relative.

Residual (not fixed, deliberately): clusterFamilies takes the first matching family, not the nearest, and logo candidates are concat-ed last — so the strongest signal (the logo, weight 7, and the only one that unlocks the permissive logoInvolved && hd ≤ 12 merge) can never seed a cluster. That is what split WCP's one navy in two. Sorting candidates by weight before clustering merges them correctly — but then repScore ranks ctaAccent (4) above logo-cluster (2.5) and the same wrong hex becomes the family's representative, so the clustering fix cannot ship alone; it forces a repScore change whose blast radius is every site's emitted hex. Reopen with a corpus (Slice 6's 30–50-site batch), not with two sites.

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).

Detection rule: before "fixing" a ranker that picked the lower score, read the selection code and find out whether the ordering is even the mechanism. A deliberate priority rule that misfires looks exactly like a sorting bug — and the difference decides whether your fix repairs one site or breaks another.

A CSS custom property that only ONE lane passes means every site on the OTHER lane silently gets the hardcoded fallback

Bites: (2026-07-13 pt4) topbar-wcp.astro styles itself from var(--topbar-bg, #5DC8E8). The static lane passes the measured value; the CMS lane's TopBar.astro adapter passed zero colour values. So every CMS site in the fleet rendered the same FCR-typical cyan strip regardless of brand — WCP's live topbar is light grey (#e8e6e6), and its own theme had measured that correctly. The operator's report was "the top strip is the wrong colour — it's garvanbay's", which is exactly what a shared cyan fallback looks like from the outside.

Fix: emit the vars from emitGlobalCss (the theme emitter both lanes already share), not from an adapter prop — --topbar-bg / --topbar-text from theme.colors.topStripBg / topStripText, and emit nothing when the theme has no value so the component's own fallback still applies (never emit null, never fabricate a colour).

Detection rule: a component fallback (var(--x, <literal>)) is a silent default, and a default that is only overridden on one lane is a lane-wide bug wearing a plausible colour. When you add a themed custom property, grep both consumers for who supplies it — and prefer the shared emitter over per-adapter props, so a new lane cannot forget.

Bites: (2026-07-13 pt4) every WCP menu item went to the homepage. lib/matchers/Header.js read const href = $label.attr('href') || '/'. On live, FARMING / COMMERCIAL / RESIDENTIAL are not anchors at all — they are dropdown toggles (closest('a') === null), and their children are JS-mounted, invisible to the static body.html (grep the child slugs in the crawled HTML: zero hits). The matcher saw a label with no href and invented one. aria-current="page" then lit up all of them at once, because / matches the homepage.

The render was already correct. header-wcp.astro routes an item with children to a <button> trigger; the toggles only fell into the <a href="/"> branch because subItems was empty. This was 100% a data-plane fabrication — the same shape as the hand-written D1 patch, produced by a matcher instead of a human.

Fix: a capture-plane probe (chrome.navRaw, schemaVersion 4→5) measuring the post-JS header: href: null for a toggle (never /), children grouped by DOM containment — URL-prefix grouping provably fails (WCP's RESIDENTIAL → roof / dry-verge / tarmac share no prefix). The seed overlay REPLACES rather than fills-if-empty, because a fabricated / reads as "already set" and fill-if-empty would preserve the bug.

Detection rule: || '/', || '#', || '' on an extracted href is a fabrication site. Absent is a measurement, not a gap to paper over — emit null and let the render decide what a link-less item is (ADR-0004).

pgrep cannot see node.exe under git-bash on Windows — poll the ARTIFACT, never the process

Bites: (2026-07-13 pt4) a long capture was waited on with until ! pgrep -f "capture-section-backgrounds"; do sleep 20; done. pgrep never matches a Windows node.exe, so the loop exited immediately, and the next 20 minutes of analysis ran against the previous section-backgrounds.json — same schemaVersion, same page count, real data, hours stale. It was then copied onward to section-backgrounds.live.json, propagating it. Caught only by checking the file's mtime against its capturedAt.

Fix: wait on a condition the artifact itself asserts — until node -e "const c=require('./out.json'); process.exit(c.schemaVersion===5 && Object.keys(c.pages).length>=25 ? 0 : 1)"; do sleep 20; done — or on the harness's own background-task completion signal. Both can be trusted; a process-name grep cannot.

Detection rule: this is the stale artifact family again (ADR-0010's empty pool, the hand-written SQL, the un-persisted detector). A liveness check that can silently answer "done" when it means "I can't see it" is indistinguishable from success. Make the freshness signal come from the thing you actually care about.

The near-match scorecard is BLIND to content destroyed INSIDE a matched block — it scores section PRESENCE, not fidelity

Bites: (2026-07-13 pt4) a clean-room-rebuilt WCP scored S 72.5 / T 100 / G_det 72 → composite 79.3, MANUAL-POLISH, no vetoes, with globals 7/7 agree and 0 loud-fail placeholders on all 25 pages. The operator looked at the deployed site and said 4/10, and listed four real defects — every one of which the scorecard is structurally unable to see:

defect why the scorecard cannot see it
topbar rendered in another brand's cyan header/topbar/footer are excluded from the scored axes by construction
globals reported topbar agree live measured null, ours measured null — a null-vs-null match checks nothing
4 icon+text cards rendered as a bare logo strip (text destroyed) the section exists on both sides, so S counts it PRESENT; content lost inside a matched block is invisible
every nav item linked to / nav/chrome is 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 the block says what live says. "200 OK, zero placeholders, globals agree" was reported as success over a visibly broken page.

Detection rule: the scorecard is a regression detector, not a fidelity oracle. Never report a verdict as "the site is good" — report it as "no structural regression". Before claiming a site is shippable, look at the rendered page (or have the operator look). The axes' blind spots are enumerable and now are: chrome, intra-block content, assets, links. Closing them is real Slice-6 work, not a tuning pass.

A junk FILTER that condemns a section on ANY match will delete real content — gate it on the thing that makes a section content

Bites: (2026-07-14) structural-diff.js#isJunk had three rules. Two were gated on "the section has no real heading"; the style-leakage rule was not, and fired on any section whose text contained a CSS rule. Wix injects per-comp SVG colour styles into ordinary content bands, so live's real "Our Commercial Repair Services Include:" section — a heading-bearing services band present on every WCP service page — was deleted from the live side. Our faithful copy of it then had nothing to pair with and scored as insertedOurs. 42 real live sections were being filtered; 27 of a reported 51 "sections we over-produce" were phantom. The function's own comment promised "a genuine section is never removed" — the rule that broke it was the one the comment didn't cover.

Fix: gate every rule on the same content signal (a real heading). A pure Wix <style> pseudo-section has no heading and is still filtered; a content band with a style tag leaked into its text is content.

Detection rule: a filter is a silent deleter, and an asymmetric one (here: 9 filtered on live, 0 on ours) manufactures phantom diffs in whichever direction it leans. When a diff says "we produce N things the source doesn't have", check the filter on the source side before believing the producer is at fault — and check the filter's rules are gated consistently. A filter whose rules disagree about what makes something junk is a filter with a hole in it.

Wix's responsive DOUBLE is a NESTED WRAPPER, not an adjacent sibling — and collapsing it by matcher NAME cannot work

Bites: (2026-07-14) /commercial-repairs rendered the heading "Commercial Repairs" three times (hero + cta-strip + about); live renders it once. Across WCP this over-production was the largest structural drag.

The mechanism the wiki recorded was WRONG, and implementing it would have collapsed zero pairs. known-issues said the doubles are two adjacent siblings that diverge because the Hero single-instance pass re-matches the loser to another matcher. Measured: every double is a nested ancestor/descendant pair — an outer <section> wrapping an inner one with byte-identical text — 67/67 on WCP, 20/20 on garvanbay, zero siblings. And the re-match never fires on that page: the inner half matches CTAStrip natively, because ctx.position differs between the two halves and Hero gates on position <= 1, so the outer wins Hero at pos 1 and the inner falls through at pos 2.

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 observation ("duplicates survive because the dedupe keys on matcher name") was right; the causal story was invented around it.

Fix: collapse on content signature + DOM ancestry, independent of matcher name, as a pass after the existing matcher-keyed passes. 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. Keep the earlier _matchPosition, which for a nested double is the outer half and is the Hero winner.

Detection rule: before implementing a wiki-prescribed fix, verify its stated mechanism against the DOM, not just its conclusion. Here the conclusion was right and the mechanism was fiction; had the fix been written to the mechanism (sibling adjacency, post-re-match) it would have matched nothing and "proved" the diagnosis wrong.

A veto must fire on the ABSENCE of a thing, not on a metric's failure to PAIR it

Bites: (2026-07-14) removing 23 genuinely-duplicated blocks made the scorecard worse: five WCP pages flipped to HOLD on criticalSectionDropped — "the hero was dropped" — 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), so our single hero block pairs with the exact heading match (a content row) and live's hero row — whose "heading" is the section's whole concatenated text — is left unmatched. The duplicate had been absorbing that mis-pairing; deleting it exposed it.

The site got better and the verdict got worse. A veto that fires on a section we visibly render is not measuring what it claims to.

Fix: a critical role is dropped only if our page hasn't got one. collectStructuralVetoes now checks the page's oursMatcher tags for the role's producing matcher (heroHero, ctaCTAStrip) and suppresses the veto when we render one. It fails closed: a genuinely absent hero still vetoes, and when the build sidecar is missing (all rows untagged, we cannot prove we rendered one) the veto still fires. Locked by +10 checks in scorecard-gate.validate.mjs (19 → 29), including "a real drop still vetoes" and "no sidecar still vetoes".

Detection rule: distinguish "the thing is missing" from "the metric could not pair the thing". Any veto keyed on an unmatched row is keyed on the aligner's behaviour, not on reality — cross-check it against something the aligner cannot influence (here: does our side render a block of that role at all?) before letting it hold a release.

Removing a duplicate can UNMASK a data-loss bug the duplicate was accidentally covering

Bites: (2026-07-14) collapsing WCP's nested wrapper doubles removed 23 spurious blocks — and /thank-you-contact-form lost the line "We'll respond to your request shortly.". It was not the collapse's fault: Hero.extract() sources subtext only from $el.find('p, h4'), and that hero band is <h1> + <h2> with no <p> at all, so subheading had always been empty. The phantom duplicate block had been accidentally surfacing that <h2>. A bug was masking a bug, and the fix for one revealed the other — as a regression.

Fix (and its safety gate): fall back to the heading level below the one that supplied the heading — but only when the section contains exactly ONE of them. A hero's subtitle is a single line; several same-level headings are an item list, not a subtitle (ungated, the fallback hoists a trust-badge item — "Fair Pricing" — into the homepage hero). Measured: fills exactly 1 WCP hero, changes 0 on garvanbay.

Detection rule: when a de-duplication removes blocks, diff the surviving CONTENT, not just the block count. "22 of 23 removals lose zero content" is only reassuring if you checked the 23rd. And when a fix produces a regression, ask whether the thing you removed was load-bearing by accident before assuming the fix is wrong.

CRITICALITY and VISUAL WEIGHT are two different concepts — sharing one number makes a gate that CANNOT FAIL

Bites: (2026-07-14) a site the owner rated 4/10 scored MANUAL-POLISH with zero vetoes. The defect he named — the FAQ rendering navy where live is white — was already measured, nineteen times, and reported as zero:

variantMismatch.count  (the HEADLINE) : 0
variantMismatch.items  (the DETAIL)   : 38   (x19 faq tone, x14 ribbon tone, x5 content tone)

structural-diff tags a mis-render critical iff ROLE_WEIGHT >= criticalRoleWeight (1.3). But ROLE_WEIGHT answers "how much does this section contribute to S?"hero 1.6, cta 1.3, content 1.0, faq 0.75, ribbon 0.5. So faq / ribbon / content / blog sit below the bar and can never be critical BY ARITHMETIC. The owner's entire complaint list lived in the set of things the gate was mathematically incapable of failing on.

"How much does this matter visually" and "should a defect here hold the release" are not the same question. Conflating them produced a gate that could only fail on heroes and CTAs.

Fix: the veto uses neither role nor weight — only SYSTEMATICNESS. The same (role, dim, live→ours) disagreement across ≥N sections and ≥M pages is a broken PRODUCER: one bug, N symptoms. A single wrong section is noise (a matcher edge case, an operator override); 19 pages of the same wrong FAQ is a producer emitting a wrong value at scale — and that is exactly what a client sees. The guards matter as much as the rule: many sections on one page is a page bug, not a producer bug, and must not veto.

Detection rule: when a gate never fires, check whether it CAN. Multiply out the thresholds against the actual weights before believing a clean verdict. A number that is doing two jobs is doing at least one of them badly — and the measurement is usually fine; it is the roll-up that lies.

The instrument that ALREADY EXISTS but was never wired in — look before you build

Bites: (2026-07-14) faced with a defect class no axis could see (content destroyed inside a matched block), the reflex was to design a new instrument — a vision model. Wrong. lib/verify-content.js (10 KB) already detected exactly that class and had simply never been connected to the scorecard. Likewise globals-diff (chrome) and asset-manifest (45 of 46 videos missing) were already computing — both literally "mode": "reporting-only".

Four of the owner's five defects were already measured somewhere in the repo. The problem was never perception. It was gating and aggregation.

Why it was never wired in — the reusable lesson: verify-content.js had its own bespoke aligner. Two instruments that align sections differently cannot be reconciled: they disagree about which sections matched, so their findings cannot be attributed to the same row. The fix was to reuse structural-diff#alignPage — then C and S agree by construction. A detector that re-implements a shared primitive will not land, however good it is.

Detection rule: before designing a new instrument, grep for the one that already exists. Then ask why it is not wired in — the answer is usually a contract mismatch (a second aligner, a different key, its own crawl), and fixing that is far cheaper than a new instrument. Corollary: an instrument that does its own crawl cannot join a pipeline whose axes are pure over stored artifacts — move the signal into the capture instead.

A bounded dimension that does not MOVE THE RENDER is a silently dead contract

Bites: (2026-07-14) cta-wcp.astro accepts tone, passes it to <Section tone={tone}>, and then hard-paints an inline background:

<Section tone={tone} … style={`background: linear-gradient(to right, var(--primary), …)`}>

An inline background shorthand beats any tone-driven stylesheet rule. All 19 WCP cta-strips already carry tone: "brand" — and the deployed ribbon still measures wrong. The value in the data never reaches the paint. The capture measures the dimension, the seed carries it, the gate scores it, and it does nothing.

This is the load-bearing objection to any config-writing agent (ADR-0011): it would see the wrong ribbon, propose set tone=brand, apply it, observe no change, and propose the identical stamp forever — each one logged as an applied fix. A config-writing agent cannot fix a threading/override bug and cannot detect that it is looking at one.

Detection rule: ADR-0009's whole premise is "fidelity comes from measured parameters" — which silently assumes every parameter actually paints. Nothing checks that. For every (block, bounded-field, value), assert the rendered DOM/pixels change. Until that audit exists, a dimension's presence in the schema is not evidence that it works.

The lever audit: prove every bounded select-value MOVES THE RENDER — and expect its first convictions to include the INSTRUMENT's own blind spots

Bites: (2026-07-14, ADR-0011 Slice 1d) The audit (apps/cms/scripts/lever-audit.mjs + apps/cms/src/pages/lever-audit.astro, DEV-only) renders every (block, top-level select, value) the schema declares — derived from the schema via the _definition introspection export, so a new lever is audited automatically — through the REAL path (FcrBlocks → adapter → canonical), and asserts each lever's values do not all produce an identical computed paint/layout signature. DOM claims (classes, data-*) are deliberately NOT in the signature: data-tone changing while the pixels don't is precisely the failure it exists to catch.

First run convicted six levers; four were real, in THREE distinct mechanisms: - cta-strip.toneinline style beats tone (the founding case, see the dead-contract entry); - checklist.tone + team-grid.tonethe adapter never passed the field (schema declares it, canonical honors it, the middle layer drops it); - blog-posts.tonea scoped (UNLAYERED) block style (.posts-wcp { background-color: … }) beat the layered tone utilities (the "unlayered beats @layer" trap, applied to a block's own CSS).

And two were the audit convicting its own blind spots — check the instrument before believing it: - hero.scrim/hero.textTone "dead" because the FIXTURE routed to hero-1 (base carried imageSide, variant unset → legacy inference → the hero that takes no scrim BY DESIGN) — pin per-lever fixtures to the block variant that IMPLEMENTS the lever; - hero.imageSide "dead" twice: the signature had no media-x field (a side flip moves ONLY where the media sits), and the audit page lacked the @container/section-provider context (splits render stacked outside a sized container — the SectionSplit pattern — so left/right moves nothing). A degenerate cell (fixture didn't render) FAILS the audit rather than producing a verdict, and the allowlist is two-way: an allowlisted lever that measures alive also fails (stale allowlist).

Also measured: the Image primitive silently kills the SSR stream on a data: URI src (the page truncates at the first section-media, 200 logged, no error anywhere) — fixture images must be production-shaped refs (r2:/URL paths), which is also the more honest fixture.

A comp-id join is only as honest as Wix's WRAPPER STRUCTURE — guard the id-join with heading/text consistency

Bites: (2026-07-14, ADR-0011 Slice 1d) All 20 WCP fcr.faq blocks carried tone: "brand", _bg: #1e73be — the cta ribbon's paint — while the capture measured every live FAQ band white. The capture was right; the SEED was wrong; and the join key was the liar. In the static crawl, one Wix wrapper (comp-mi7ft7iu7) spans BOTH the cta ribbon and the FAQ band, so the build hung the FAQ block on that wrapper's id — and the live capture enumerates the same id as the (headless) cta band. comp-mi7ft7j05, the live FAQ section's id, does not exist in the static DOM at all. The id-join then stamped the ribbon's tone onto every FAQ, systematically (one wrapper pattern, N pages), and the wrongly-stamped tone was a WORKING lever — the block faithfully painted the navy the data told it to. Producer chain: wrapper-id → mis-join → wrong stamp → correct render of a wrong value.

The guard (makeStyleLookup): a join — id or positional — is accepted only when the block's extracted heading doesn't CONTRADICT the capture section: agree when the capture section's heading contains/is contained by the block's, else when the section's textHead (its opening text) contains the block heading — a section's own heading surfaces in its text prefix (the headless service-page hero case), while a WRONG section's text doesn't carry the block's heading at all. A side with nothing measurable is unguardable and passes (never punish an unmeasured value). A guarded-out join is rescued by heading lookup, else REFUSED loudly — no stamp beats a wrong stamp. Calibrate the guard against the false-conviction case before shipping it: the first draft preferred a unique-heading match over any headless id-join and promptly re-joined the (correctly paired, headless-capture-side) service heroes onto same-heading about bands — textHead containment is what separates "headless but right" from "headless and wrong". A/B regen proof: garvanbay 0 value diffs; WCP exactly 19 FAQ blocks × {tone, _bg, align}, nothing else.

A presence dim computed from two blind probes FABRICATES agreement — false === false is not fidelity

Bites: (2026-07-14, ADR-0011 Slice 1c) globals-diff's topbar.present reported agree: true on a site whose deployed topbar had rendered in another client's cyan — one of the "7/7 chrome dims agree". Both sides computed present: false, and equality was scored as agreement. But neither false was a measurement: the LIVE probe measured Wix's pinned container (zero-height → 'zero-size'), and the OURS probe selected [id="pinnedTopCenter"]a Wix-only id our lane never emits — so ours had no record at all. Two probes, blind for two different reasons, agreeing in their shared blind spot. This is the same correlated-blindness shape as globals reporting the topbar null vs null — but worse, because it laundered the blindness into a positive comparable dim that inflated the agree count.

The rule: a dim may only compare when both sides carry a POSITIVE measurement, and "measured absence" must be distinguishable from "probe never ran / probe cannot see this lane". The fix shape (v7 topbarStrip): the probe always emits its key ({found:false} = ran, found nothing — comparable data; absent key = stale — agree: null, excluded from the rollup), it knows both lanes' idioms (Wix pinned-layer walk AND our .topbar band — a lane-specific selector on a symmetric probe is a requirement, not a smell; cf. the oklch-parser entry), and derived booleans like "present" are computed from the measured record, never from the absence of one. Audit trigger: any diff dim whose two inputs can BOTH be produced by failure paths — if nothing === nothing can reach agree: true, it will, on exactly the site where it matters.

The "used on live" asset census is the RENDERED DOM — the media pool over-counts, the per-section census under-counts, and the veto needs neither

Bites: (2026-07-14, ADR-0011 Slice 1c) "45 of 46 videos missing is a veto, full stop" was unimplementable from every existing instrument: the media POOL (ADR-0010 mediaPool) holds 46 videos but mixes live-rendered assets with library cruft nobody ships (never separated — gating on it manufactures debt); the per-section capture assets.videos count is a lazy-mount floor that read 0 across all 25 WCP pages while the live site visibly plays video; and asset-manifest.json is written pre-migration (63 raw refs on the SHIP-grade reference — a veto would false-HOLD everything; see known-issues).

The instrument that works: the capture is already a rendered-DOM Playwright crawl, so a page-level harvest rides it for free — every video URL/id the rendered page references, in two tiers kept separate (dom: mounted <video>/<source>; markup: the serialized DOM incl. lazy player configs — on live Wix the players never mount pre-interaction, so the markup tier is the one that sees them, through gcp-repackager.wixmp.com prefixes). First run measured the split the pool never had: live WCP references 27 of the pool's 46 (19 = cruft), ours references 1. Two disciplines make it veto-grade: count-based per page, no identity join (a live Wix media id and our r2 key share no derivable key — claim "fewer videos than live", nothing stronger), and floor honesty (host-less /file.mp4 JSON-escape tails are junk-filtered per side; "0 found" means "none observable", so the veto fires on measured SHORTFALL and never credits absence).

A video's SECTION HOME is its POSTER id, not its mp4 URL — Wix keeps the URLs in page-level script blobs, so containment attribution over the serialized DOM finds NOTHING

Bites: the video-segmentation slice (2026-07-15) attributed the mediaRefs harvest to sections by containment — dom-tier element ancestry plus a per-section serialized-DOM scan for the mp4-URL regex, deepest section first. Measured result on the fresh WCP capture: 0 of 36 refs attributed; all page-level. Both containment joins are structurally blind here: 0 <video> elements mount pre-interaction (the dom tier is empty), and every mp4 URL lives in page-level script blobs (wix-warmup-data + inline player configs) — outside every enumerated section, which is exactly why the page-level harvest could see them (the entry above) and a section-scoped scan cannot.

The join that fires: the player paints the video's POSTER inside its section<videoId>f000.jpg, the same poster identity the carousel video-slide link uses (linkVideoSlide). Measured on live /roof-painting before building anything: the media id appears in exactly one section subtree (as a poster <img>), while the mp4 URL scores 2 page-level script hits and 0 section hits. So the capture gained a second pass: a still-unattributed ref whose id has the distinctive Wix media-id shape (6hex_16-64hexguarded, or a short generic id like file.mp4 would substring-match everywhere) is homed to the deepest enumerated section whose outerHTML carries the id; attributedBy: 'containment' | 'media-id' records which pass homed each ref. Result: 28/28 real refs attributed (all media-id), 0 page-level; garvanbay 0 videos, faithful no-op.

Detection rule: when a containment join over the serialized DOM attributes nothing, don't widen the regex — ask what identity the platform paints inside the section and join on that. On Wix it is the media id via the poster. And keep the null honest: a ref that neither pass can home stays sectionId: null (a measured page-level residue), never fabricated to the nearest section.

Status: shipped 2026-07-15 (capture schemaVersion 7→8; lib/video-segmentation.js + .validate.mjs 18 checks; consumer gates on version + the sectionAttribution evidence key itself — no CAPTURE_REQUIREMENTS row, because nothing stamps from it). Report: builds/<domain>/video-segmentation.json.

A provenance getter that FALLS BACK to a display label fabricates provenance — gate on filename-grade, return null loudly

Bites: wix-api.mjs#originalNameOf fell back to file.displayName when the record had no media.*.filename — and for 19 of WCP's 46 videos that field holds the literal folder label Misc. Migrating on it would have collided 19 files onto misc-<hash>.mp4 stems with x-amz-meta-original-name: Misc (provenance destroyed at the moment it was being "preserved"), and the name-based pool join in transform-seed-images would have matched "Misc" === "Misc" across unrelated files.

Fix: explicit field precedence; displayName accepted only when it looks like a filename (carries an extension); otherwise null — and the consumer says so (transform-seed-images warns "using DERIVED basename … as provenance" before falling back). Derived provenance is honest as long as it is labelled; a plausible wrong string never is. Same family as "A reconciliation is only as honest as its JOIN KEY": the API record does not always carry the field you assume, and the failure is a plausible value, not an error.

Status: shipped 2026-07-15 (originalNameOf filename-grade gate + selftest checks; the known-issues precondition on any video migration is closed).

Embedded-app content lives in the APP's OWN API, not the page — harvest its public token from the page source (third media/content surface this week)

Seen: bmpartsandtools.ie (Wix chrome + embedded Ecwid store), 2026-07-15, the ADR-0012 pilot. Three content classes were invisible to the DOM crawl and to the structure map: category descriptions (the owner's "we lost this text" — it renders inside the Ecwid iframe), category images, and the FAQ tabs beyond the SSR-active one (Wix FAQ app loads per-tab via /_api/faq-server/v2/question-entries/query).

Rule: when a captured page embeds a third-party app (Ecwid, Wix FAQ, VOD player — same shape as the 07-15 pt2 VOD finding), the missing content is reachable through the app's own API, and the credential is usually already in the page source: Ecwid storefronts embed a read-only public_… token; https://app.ecwid.com/api/v3/<storeId>/… then serves categories (name, parentId, description, originalImageUrl) and products (sku, categoryIds). Intercepting the app's network calls in Playwright (click the tabs, capture faq-server responses) beats selector archaeology every time.

Corollary (join key): the Ecwid tree's identity is id/parentId — names carry " / " as literal text ("Door Receivers / Keeps"), so any consumer that splits path strings on / fabricates hierarchy (loader did; three-feed saga in the ADR-0012 pilot's known-issues entry).

Where the recipes live: ADR-0012 §Context lists the seven WordPress/Elementor-lane platform gotchas from the same pilot; the executable scripts are wordpress/scripts/bmparts/* (separate repo).

A vendor API is defined by its RESOURCE LIST, not its URL path or product name — read the endpoints before you believe the label

Seen: 2026-07-17. A "Wix CMS" API (.../business-solutions/cms/) was pointed at the portfolio in the hope it would unblock the missing gallery videos. It cannot: "CMS" is Wix's rebrand of the Content Manager, and the actual resource list is data collections (/wix-data/v2/... — Data Items, Collection Management, Operations, External DBs). There is no media/video/VOD endpoint anywhere in it. The videos stay behind the VOD 403 (ADR-0010 fourth amendment).

Rule: the tell for what an API does is its resource enumeration, never its path segment or marketing name — the same discipline as the VOD finding (read the live network trace, not the widget's appearance), arriving from the opposite direction. This one turned out empty for FCR by construction: the Wix Data APIs require the site's code editor to be enabled, and the portfolio is brochureware built without it — zero collections to read. Recorded as DROPPED, not deleted (ADR-0010 fifth amendment + Slice G), so the "new CMS API!" reflex doesn't re-chase it. Reopens only if FCR onboards code-editor sites.

A calibration instrument is buildable AHEAD of its corpus — but a precision-first ship cut needs ≥1 SHIP-labelled positive, and veto-held reference sites give ZERO

Seen: 2026-07-17, ADR-0009 Slice 6. The fit (scorecard axes → thresholds) needs a 30–50-site built+labelled batch that does not exist locally (2 builds). Rather than fake it or fit on two sites, the corpus-INDEPENDENT half shipped: the aggregator (scripts/batch-scorecard.mjs) and the fitter (lib/calibration-fit.js + scripts/fit-calibration.mjs), validated on synthetic separable data + a positive control that reads the two known-good anchors (the §4 sanity anchor — the reader is validated even though the fit can't run).

Two rules bank from it. (1) Instrument-before-corpus is legitimate and de-risks the expensive lane — write and positive-control the code now so that when the labelled batch lands, fitting is one command; keep production calibration.json at fitted:false and have the CLI refuse to overwrite it (promotion is a reviewed step). (2) A precision-first P(ship) cut is un-fittable without ship positives — and both local anchors are veto-held (garvanbay criticalSectionDropped, WCP videoDebt), so they supply zero ship labels. The corpus is mandatory not merely for sample size but because the fit has nothing positive to learn from; the fitter proves this by refusing (loud warnings, fitted:false, non-zero exit) on the 2-anchor run.

A local HTTP probe is only as honest as the PORT'S OWNER — assert responder identity, never a status code

Seen: 2026-07-18, batch-runner Step 0. Probing our freshly-built worker on 127.0.0.1:8787 returned a plausible JSON 403 ("protected by Cloudflare Access — authenticate through the protected domain") that survived TWO rebuilds with the Access adapter progressively stripped out — because the responder was a leftover workerd from a different repo (quick-spike) sharing the port. The string existed nowhere in this workspace; two builds were misdiagnosed before Get-NetTCPConnection named the owner. Same family as the pgrep entry: a probe that can be answered by the wrong producer is indistinguishable from success.

Rules (all encoded in scripts/run-cms-batch.mjs): - Before booting a local server, assert the port answers NOTHING (any HTTP response = refuse loudly, naming the interception risk). - After serving, assert responder identity from content — the site's own title must appear in the served HTML. A 200 alone proves only that someone answered. (Limit: chrome renders the title even over an empty body — it is an identity check, not a content check; sweeneyofwexford proved that.) - Two adjacent gotchas from the same spike: wrangler dev -c <file> BYPASSES the Astro adapter's .wrangler/deploy/config.json redirect (it then tries to bundle src/worker.ts and dies on astro: virtuals — plain wrangler dev only); and an EMPTY local D1 302s every public route to /_emdash/admin/setup — that redirect is the "not seeded yet" signature, and migrations run on first REQUEST, not on boot.

A batch whose failure MODE migrates upstream over time is the ENVIRONMENT dying, not the sites — and every "ok" from the degradation window is poisoned

Bites: corpus run 1 (2026-07-18) ended 8/42 ok, and the 34 failures read at first like corpus data — capture robustness, dead sites, matcher gaps. They were none of those. Read in sequence, the failure stage marched steadily upstream: sites 1–8 clean → cms-build timeout (spawnSync npm ETIMEDOUT, bmac) → theme-extractor exit-1s (bray/brian/bright/browns) → instant discover failures for the entire tail ("No site …"), plus one raw ✗ spawn. Interleaved with the failures, "successful" stages ballooned: discover 1s→30–71s, one crawl to 40 min. That signature — later-and-cheaper stages failing as time passes, successes slowing — is a box-side resource dying progressively, and treating the rows as per-site evidence would have written 34 fictional site failures into the calibration story. Root cause was the box's network: networkd declared ens5 Failed under load at 16:32, the DHCP lease lapsed ~17:30 (scope doc §2026-07-19 incident).

The diagnosis chain, each step cheap and reusable: - journalctl --list-boots first: the "crashed" box had ONE continuous boot until IT's stop/start — so it never crashed, never OOM'd; something around it died. This one command killed the OOM/disk/CPU theories. - sshd bot-noise silence is a free network-cut timestamp: internet background scans hit a public box every minute or two; their last log line (17:30:07) dates the inbound cut to the minute, no monitoring required. - Overnight cron durations discriminate starvation from network death: a ~1s timer job still taking ~1s all night proves CPU/memory were fine and isolates the loss to connectivity.

Fix (box-side, both live): a networkd self-heal cron (ping the metadata IP every 5 min, restart networkd on failure, log to /var/log/networkd-selfheal.log) and /swapfile2 added to fstab (a reboot had silently dropped 6 G swap → 2 G — an unasserted box fact, same disease as the unasserted baked seed).

Detection rule: when a batch report shows failures whose stage drifts upstream over the run's timeline, suspect the runner's environment before any per-site theory — and treat every stage that "succeeded" during the degradation window as poisoned even though its status says ok: it passed through a dying resource, and presence-based skips will happily resume on top of it. Force-redo those stages; the proof it mattered here was braylaunderette re-crawling in 31.8s vs 858s degraded. Corollary: a batch's failure rows are only corpus data when the environment was healthy — an incident's rows must be excluded from any robustness/prevalence read.

Correction (2026-07-20): the detection rule above is NOT sufficient, and applied on its own it misdiagnoses. Run 2 (2026-07-19) produced the same signature — 2/42 ok, a wall of upstream discover failures across the whole tail — and the environment was fine. journalctl --list-boots shows one continuous boot from 07-19 13:51 to 07-20 08:09 (the box only stopped when IT took it down for the RAM resize), no networkd/ens5 errors in the window, and no OOM events in July at all. The cause was code: quick-crawl navigated the page to /sitemap.xml, Wix redirects that path on sites without one, the swallowed failure stayed in flight, and the nav-crawl fallback then collided with it (Navigation to <home> is interrupted by another navigation to <sitemap>). Deterministic on sitemap presence — which is exactly why it looked environmental: it hit most of the tail at once. Fixed in db1bff8; the same run redone 2026-07-20 scored 41/42.

The original run-1 diagnosis stands — that network death was real and well-evidenced. What was wrong was the inference that the signature implies it. Mass upstream failure means "something common to all sites", and the environment is only one candidate; a shared code path is another.

The discriminator, one grep, no box access: ask whether any OTHER network-dependent stage SUCCEEDED in the same minutes. On run 2 the log shows images ✓ (28.9s) and images ✓ (30.1s) — that stage downloads assets from static.wixstatic.com over the public internet — interleaved with the discover ✗ wall. Network egress was demonstrably working while the network-shaped stage failed, which sends you to the code and not to the box. Run this before --list-boots: it is cheaper and it fails fast in the direction the 07-19 chain cannot.

Status: run 1 lost to the genuine incident; run 2 (2026-07-19) lost to the quick-crawl bug above, NOT to the box; run 3 (2026-07-20) clean at 41/42 on the resized 8 GB host. Watchdog + fstab swap live but never fired and remain untested — no failure has recurred for them to catch.

A reference site can be blind to a whole defect class BY COINCIDENCE — a green dim on the tuning site proves nothing about the portfolio

chromeDisagree fired on 33 of 38 corpus sites, and the standing hypothesis was that a defect at 87% uniform prevalence must be an instrument artifact (the top-bar-missing crop precedent). It was not. The dominant component — footer.bg, 27 of 38 — is a missing producer: footer-wcp.astro reads var(--footer-bg, …), emitGlobalCss never emits --footer-bg, and theme.json#colors.footerBg is null on every site. Every CMS site in the fleet paints a white footer regardless of brand.

It survived a month of scorecard work because WCP's live footer is genuinely white, so the reference site reported footer 7/7 agree — a true agreement that carried zero information about the other 37 sites. The dim was green for a reason unrelated to the code being correct.

This is the ADR-0010 second-amendment shape a third time (searchFileslistFiles "proved" on WCP, inverted by garvanbay). The generalisation:

A dim that agrees on the reference site is evidence only if the reference site's value could have DISAGREED. Before trusting a green dim portfolio-wide, ask what the reference measures on each side — if both sides land on the same value for a reason the code doesn't control (white footer, absent CTA, no slideshow), the dim is untested, not passing. Prefer a site whose live value is unusual as the control for a chrome dimension.

Corollary — a composite veto hides its own prevalence ranking. chromeDisagree is one name over four dims (footer.bg 27 · header.layout 17 · header.cta 10 · header.bg 7). Read as one veto it looked like a single artifact worth one adjudication; read per-dim it is three known-shaped producer gaps with wildly different payoffs, and the fix ladder shows each one alone clears only 2–3 sites while all four clear 16. Roll a veto up for gating; never rank work from the rolled-up name — go back to globals.summary.disagreements (or the equivalent per-dim detail) before scoping. Same lesson as ADR-0011's variantMismatch.count: 0 over 38 items, arriving from the opposite direction: there the roll-up hid defects, here it hid their shape.

A veto built to catch a PARSE bug false-fires once the value becomes measurable

transparentBlackRemnant vetoed any headerBg/footerBg/background equal to #000000. It was written (with the deriver's alpha guard, same commit) to catch the old bug where a transparent rgba(0,0,0,0) chrome fill parsed as opaque black — at a time when no code legitimately measured a black chrome bg, so #000000 in those fields could only mean the bug. The moment a new producer (pickCoverBgEl, 2026-07-21) started measuring genuinely-black headers/footers, the veto false-fired on real data — plus a −20 T penalty — on exactly the sites the producer existed to help (shinnersfinancial's live header AND footer are #000000).

When you add a producer that makes a previously-impossible value possible, grep for every rule that treated that value as a bug signature. A veto, assertion, or === SENTINEL check encodes "this value can only mean the bug" — an assumption that silently expires when the value becomes legitimately measurable. The real guard against the parse bug lives at the deriver (transparent → null); the downstream veto was a redundant echo that outlived its premise. Fix: narrow the check to the field where the value is still bug-shaped (a black page canvas is still suspect; black chrome is not), don't blanket-remove — and confirm zero existing-corpus rows move (none did: the fields were all null before the producer).

Out-of-scope / open questions

Why Choose Us section absent from captured DOM

Bites: wfpainters live shows "Why Choose Us" content; nothing in our captured DOM matches the wording. Either Wix lazy-renders it client-side after Phase 1's load event, or it's ascii-encoded inside a sub-tree we're not visiting.

Status: open. Need to: 1. Confirm presence on live (manual eyeball) 2. If present in source, find what tag wraps it and why our enumeration misses it 3. If JS-rendered, decide whether to wait longer in Phase 1 or add a second enumeration pass

Booking — Cal.com Teams require paid plan

Bites: chosen direction was Cal.com self-host for portfolio. Cal.com free tier doesn't support Teams (the multi-tenant primitive).

Status: deferred. Open alternatives — self-host Cal.com (AGPLv3 free on our infra), schedule-x + custom backend, lightweight request form. See memory/project_booking_solution.md.

Bottom CTA bar styling — bg/text per-site

Bites: Live garvanbay's mobile QuickActionBar is black/white. Other FCR sites may use brand colours. We hardcode black/white as portfolio default.

Status: open. Per-site override via theme.json field would generalise.

verify-design --selector body only sees above-the-fold

Bites: Gemini run only checks the hero region; below-the-fold issues slip through. placement-check.js does full-page but is heavier.

Status: open. Either run multi-region per page (header / hero / each content section / FAQ / footer) or extend verify-design to scroll-capture.