ADR-0011 — Make the gate see what the pipeline already measures; a VLM only for CORRELATED BLINDNESS¶
Status: Accepted (promoted 2026-07-14; Slices 1a + 1b shipped the same day)
Date: 2026-07-14
Deciders: Cathal Dempsey
Consulted: an adversarial review that ran the actual scorecard instead of reasoning from
the brief, and inverted the conclusion.
Related: ADR-0009 (measured near-match — this fixes its gate, not its axes), ADR-0004
(loud-fail; never fabricate), ADR-0005 §6 (provenance in-data),
near-match-scorecard-contract.md §2 (which already rejected per-region LLM scoring)
Context¶
The forcing event¶
waterfordcountypainters was rebuilt clean-room (2026-07-13/14) and scored S 78.1 / T 100 / G_det 78.6 → composite 83.7, MANUAL-POLISH, no vetoes, 0 loud-fail placeholders, globals 7/7 agree. The owner opened the deployed page and rated it 4/10. He was right.
The obvious inference — "the deterministic axes are blind; buy eyes" — was wrong, and this ADR exists because checking the artifact killed it.
The scorecard was NOT blind. It measured the defects and threw them away.¶
builds/waterfordcountypainters.ie/fidelity-scorecard.json, as it stands today:
variantMismatch.count (the HEADLINE) : 0
variantMismatch.items (the DETAIL) : 38
x19 faq tone: default → brand <-- the owner's "FAQ is the wrong colour"
x14 ribbon tone: brand → default
x 5 content tone: brand → default
The FAQ colour defect was measured nineteen times and rolled up to a headline of zero.
The mechanism is a single constant. criticalRoleWeight = 1.3, and:
| role | weight | can it ever veto? |
|---|---|---|
| hero | 1.6 | yes |
| cta | 1.3 | yes |
| content | 1.0 | never |
| faq | 0.75 | never |
| blog / ribbon | 0.5 | never |
By arithmetic, no FAQ, ribbon, content, link, chrome or asset defect can trip a veto. The owner's entire complaint list lives in the set of things the gate is mathematically incapable of failing on. That one constant explains 83.7-vs-4/10 better than any theory about vision.
And the other four defects are also already measured — just not gated¶
| owner's defect | where it already lives |
|---|---|
| FAQ wrong colour | variantMismatch.items ×19 (above) |
| FAQ wrong heading | dropLive role=faq "Waterford County Painters FAQ" — the aligner couldn't match it because the heading is wrong |
| topbar in another client's cyan | lib/globals-diff.js computes chrome — reporting-only, and it compares chrome structure, not palette (a real gap) |
| 45 of 46 videos missing | asset-manifest.json, literally "mode": "reporting-only" |
| icon cards → logo strip, text destroyed | the one true blind spot — and lib/verify-content.js (10 KB) already exists, already detects it, and is not wired into the scorecard |
Zero of the five required a VLM. The scorecard scored 83.7 and was right; it simply shipped
no punch-list, and the 38 mis-renders it did find rolled up to count: 0.
This is a GATING and AGGREGATION failure misdiagnosed as a PERCEPTION failure.
The lever that does nothing — why a config-writing agent cannot be trusted here¶
packages/components-v3/src/components/blocks/cta-wcp.astro accepts tone and then hard-paints
an inline background:
An inline background shorthand beats any tone-driven stylesheet rule. All 19 WCP
fcr.cta-strip blocks already carry tone: "brand" — and the re-measurement still reports the
ribbon tone as wrong. tone is an INERT lever on that block.
A vision editor 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. The levers are unaudited, at least one is inert, and nothing in the system knows.
Decision¶
1. Slice 1 — make the gate see what the pipeline already measures. No VLM.¶
Five deterministic moves. Every one uses machinery that already exists.
(a) Decouple CRITICALITY from VISUAL WEIGHT. They are two concepts sharing one number
(ROLE_WEIGHT), and that conflation is why a 4/10 site passed with zero vetoes. A section's
contribution to S (how much it matters visually) is not the same question as whether a
defect in it should hold a release.
(b) Enable the already-written stub. lib/scorecard-gate.js carries
collectVariantMismatchVetoes as a documented stub awaiting Slice 6. Wire it now with a
SYSTEMATIC rule, not a per-section one:
the same decisive-dim disagreement on ≥N sections across ≥M pages ⇒ HOLD.
On WCP this fires immediately and correctly on 19 FAQs + 14 ribbons. A systematic rule is also the right shape: one producer bug, one veto — not 33 tickets.
(c) Wire lib/verify-content.js in as a C (content) axis, with a veto. It exists and is
unused. Needed: swap its bespoke aligner for structural-diff#alignPage so C and S agree on what
matched; add href comparison (catches "every nav item links to /" — a fabrication, the
loudest defect class this project has); emit a contentDestroyed veto (live text present,
ours empty on a matched section — the icon-card case). This closes the one true blind spot.
Correct a load-bearing premise while we are here: the earlier draft of this ADR claimed "content is already checkable deterministically — the S axis compares text." That is FALSE.
PRESENCE_SHARE = 0.5(a matched section banks half its credit for merely existing) andDIMS = ['tone','imageSide','align','columns']— S never compares one word of body text. Content is deterministically verifiable, but the verifier is not wired in. Making that claim true is (c).
(d) Promote globals-diff and asset-manifest from reporting-only to VETOING. Both already
compute. 45 of 46 videos missing is a veto, full stop. And globals-diff must compare chrome
palette, not only chrome structure — it currently reports 7/7 agree on a site whose topbar
was another client's cyan.
(e) LEVER AUDIT — prove every bounded dimension actually does something. For every
(block, select-field, value), assert the rendered DOM/pixels change. tone on cta-wcp is
inert today; nothing knew. A bounded dimension that does not move the render is a silently
dead contract, and no amount of correct stamping will fix a page it governs.
Expected outcome: WCP drops to HOLD — correctly — and the gate emits a punch-list that matches the owner's 4/10 almost item for item. That is the deliverable: the gate agreeing with the owner. No VLM gets there faster.
2. The VLM's ONLY structural justification: CORRELATED BLINDNESS¶
This is the real argument for eyes, and it is not the one this ADR was originally drafted on.
S re-measures OUR deployed output with the SAME probe that measured live
(capture-section-backgrounds.js runs on both sides). So any dimension the probe does not
measure is invisible on BOTH sides — and the two sides therefore AGREE in the probe's blind
spot. That is correlated blindness, and more of the same probe can never fix it. (It is
exactly how globals reported the topbar as agree: live null vs ours null.)
A VLM is an instrument with uncorrelated blind spots. That — and only that — justifies it. It follows that its output must be a dimension-gap detector, not a value proposer:
One structured call per critical page. Forced output:
{ readsAsSameSite, confidence, primaryReason, unexplainedDefects[] }. Every defect must resolve to either: (a) an existing scored dimension — in which case it is a scorecard bug report, because a deterministic axis should have caught it; or (b) explicitly unmappable — in which case it is a proposal for a NEW bounded dimension, which is the highest-value output this system can produce. It writes NOTHING. Its precision is scored against the deterministic axes on the Slice-6 batch.
≈1 call/site, not ~200. This is precisely the G_llm that
near-match-scorecard-contract.md §2 already sanctions — "one structured Gemini call per
critical page (not per region — that is verify-design.js's cost model and its drift
surface)".
Be honest about what it would have found on WCP: "the icon cards lost their text", "the topbar is the wrong colour", "there is a video here and not there" — i.e. even the good version of the VLM would mostly have told us to turn on detectors we already have. That is the argument for doing Slice 1 first and then re-asking, honestly, whether Slice 2 is worth building at all.
3. REJECTED OUTRIGHT — permanently¶
- Any VLM write path to live D1. Non-negotiable. A VLM value can never become a no-op: this project's proof standard is "the proof is a no-op" (an independent re-measurement re-derives the value from an artifact and agrees). There is no artifact from which to re-derive a VLM's guess. It re-arms the exact compounding loop that produced the floating-CTA disaster — regen ≠ live, nothing to arbitrate, live recorded as "faithful", the wiki promotes it — automated across 1800 sites.
- Per-section VLM calls as a standing stage. Already rejected by the contract as "verify-design.js's cost model and its drift surface". ~432k calls/pass; and every matcher change invalidates every proposal, in a pipeline that changes weekly.
- A VLM writing content fields (
text_input/media_picker/ repeater strings). Fabricated client copy. Never. - Any VLM influence on the gate — including INDIRECTLY, by writing the inputs the gate
scores. "The gate stays deterministic" is hollow if a nondeterministic agent authors what the
gate measures:
Sre-measures the deployed DOM and cannot tell who authored it. The gate would then measure the VLM's own edit and pronounce it good. This alone forces report-only. lib/verify-design.jsas a gate, or as the seed of this. It routes fixes to a developer file (.astro/theme.js) — an unbounded per-site code-edit action space, i.e. the per-site CSS hackery ADR-0009 §2 exists to prevent — and it fails nondeterministically. Retire it as a gate; keep it as a human-invoked single-site triage tool.
4. If a stamp path is EVER built, these are the load-bearing mechanisms¶
Not adopted now (nothing writes). Recorded so a future session cannot re-derive a weaker version:
- Refuse to stamp a dimension the CAPTURE PLANE OWNS.
CAPTURE_REQUIREMENTS(seed-from-build.js) already declares which dims have a producer.sync-style-to-d1.mjsgains an ownership check and exits non-zero. This makes "fix the producer, not the symptom" a type error, not a virtue, and leaves ADR-0009 §6's manual-polish escape hatch exactly where it belongs: dimensions with no producer. - Duplicate-proposal collapse. The same
{_type, field, value}on ≥3 pages of a site, or ≥2 sites, is definitionally systematic ⇒ refuse to stamp, file one defect against the producer. This converts WCP's 20 identical FAQ stamps into 1 bug report with zero discipline required. Without it, "defect-report-first" is a fiction: 20 correct stamps ship, the deploy goes green, the one upstream bug survives — and is re-fixed 36,000 times across the portfolio. - Provenance in-data (ADR-0005 §6): a non-measured value carries
_provenance: {producer, model, promptHash, at}in the block, and the graft check ranks it below any measurement, so a later measured value silently overwrites it. This is the only thing that makes a guess distinguishable from a measurement. - Stamps expire (
expiresAfter: N regens); on expiry the defect reappears loudly. "Top 10 stamps by re-application count" is the matcher backlog, ranked. Shortcut-taking becomes self-reporting. - A stamp must predict its own axis delta, re-measured post-deploy. No movement ⇒ the lever
is inert ⇒ auto-revert + file a render-lane bug. This catches
cta-wcpautomatically.
Consequences¶
Positive¶
- The gate starts agreeing with the client. That is the entire point, and it needs no new instrument — only wiring what is already measured.
- The 38 mis-renders already in
fidelity-scorecard.jsonbecome a punch-list instead of a rounding error. - The lever audit surfaces a live bug class (inert bounded dimensions) that nothing currently detects, and that would have silently defeated any config-writing agent.
- If a VLM is built later, it is aimed at the one thing no deterministic probe can ever reach (correlated blindness) — and it cannot fabricate, because it writes nothing.
Negative / risks¶
- Slice 1 will make the numbers WORSE. WCP should drop to HOLD. That is correct and must not be "fixed" by relaxing thresholds — the site did not change, the instrument stopped lying.
- Enabling vetoes on faq/ribbon/content will make other sites fail too. Expect a backlog. That backlog is real and was always there.
- Slice 6 calibration must now fit thresholds on a gate that can actually fail. Strictly better, but it is more work than fitting a gate that cannot.
Slice tracker¶
| # | Slice | Status |
|---|---|---|
| 1a | Decouple criticality from visual weight; systematic-mismatch veto. | Done 2026-07-14 (26c026f). collectSystematicVariantVetoes — role-agnostic and weight-agnostic, firing on SYSTEMATICNESS (same (role, dim, live→ours) across ≥5 sections and ≥3 pages = a broken producer: one bug, N symptoms). structural-diff.js untouched, S byte-identical — the conflation was a policy bug, fixed where policy lives. WCP → HOLD with the punch-list faq tone ×19 / ribbon tone ×14 / content tone ×5. garvanbay correctly silent (1 item, not systematic) but HOLDs independently on /contact having no hero at all (it renders only fcr.map + fcr.contact — the known pre-canonical page), surfaced by the isJunk fix. Gate validate 29 → 41. |
| 1b | The C axis — content destroyed inside a MATCHED block. | Done 2026-07-14 (9222fc8). verify-content.js was never wired in because it is a standalone Playwright CLI with a bespoke aligner; so the signal moved into the capture (content census per section, schemaVersion 5 → 6) and lib/content-diff.js reuses structural-diff#alignPage — C and S now agree by construction on what matched. Vetoes contentDestroyed + fabricatedLink. C is NOT in calibration.weights (an unfitted 4th weight would re-scale S/T/G_det and silently re-rank the portfolio); the vetoes do the gating, scored promotion → Slice 6. S/T/G_det byte-identical, proven twice. +38 checks. First run found a real portfolio-wide bug: fcr.gallery has NO TEXT FIELD in the schema, so live's 218-char gallery intro is destroyed on every site that has one. fabricatedLink 0/16 on WCP — the negative control passes. |
| 1c | Promote globals-diff + asset-manifest to vetoing; globals-diff must compare chrome PALETTE, not just structure (it reported 7/7 agree on a site whose topbar was another client's cyan). 45 of 46 videos missing is a veto, full stop. Also: an ours-side background-image probe so imagesDropped can gate (see the judgement call below). |
Done 2026-07-14 — with two measured corrections to this row's own wording. (1) The "7/7 agree" was not un-compared — it was FABRICATED: topbar.present computed from two blind probes (live: Wix's zero-height pinned container → absent; ours: [id=pinnedTopCenter] is a Wix-only selector → no record at all) and scored false === false as agreement. Fixed at the CAPTURE (schemaVersion 6→7: chrome.topbarStrip measures the strip a viewer sees, on both lanes — WCP live+ours both measure #e8e6e6 for real now) + at the DIFF (a palette tier over hexes — same-bucket navy-vs-green disagrees; not-measured can never agree) + at the GATE (chromeDisagree: chrome is site-global ⇒ any positive disagreement is systematic by construction). (2) asset-manifest was NOT promotable: it is written PRE-migration (garvanbay's shows 63 raw refs + 47 media-missing on the SHIP reference — a veto would false-HOLD every site) and its expected side is the per-section assets.videos lazy-mount floor (0 across all 25 WCP pages). The veto basis is instead the new page-level mediaRefs harvest (v7, rendered-DOM video refs, dom+markup tiers) → lib/asset-debt.js count-based shortfall → videoDebt veto; plus deadAssetRef from the POST-migration asset-migration-gaps.json. First run measured the used-vs-cruft split the pool never had: live WCP references 27 distinct videos (pool holds 46 → 19 are library cruft), ours serves 1 → shortfall 26–27, WCP HOLDs on it, with a per-page id punch-list (gallery 13, home 4, …). garvanbay: 0 videos, 0 debt, HOLD only on its real criticalSectionDropped. (3) imagesDropped recomputed over images + bgImages (both-sides-only, never mixed-version): garvanbay 34 → 14, WCP 10 → 11 — better but NOT ~0, so it stays reporting-only (residual = mechanism 2, anchor drift). S/T/G_det code paths untouched (structural/theme/perceptual diffs unmodified); axis deltas are re-crawl drift only. Validates: gate 41→60, globals →54, content-diff →41, asset-debt 20 (new). |
| 1d | Lever audit: for every (block, select, value), prove the rendered output actually changes. tone is INERT on cta-wcp (it accepts tone then hard-paints an inline background: that beats any tone rule) — very likely the producer behind 1a's 14 ribbon tone rows. A bounded dimension that does not move the render is a silently dead contract. |
Done 2026-07-14. The audit is now a durable instrument (apps/cms/scripts/lever-audit.mjs + DEV-only /lever-audit fixture page; the lever matrix derives from the schema via _definition, so a new select is audited automatically). First run convicted six levers: four real, in THREE mechanisms — cta-strip.tone (inline style beats tone), checklist.tone + team-grid.tone (the adapter never passed the field), blog-posts.tone (a scoped unlayered block style beat the layered tone utilities) — and two were the audit's own blind spots (hero scrim/textTone: fixture routed to hero-1 which takes no scrim BY DESIGN; hero imageSide: no media-x in the signature + no @container context on the page). All four fixed (zero-change proven: reference seeds carry only default/unset on the adapter-drop blocks; Section's default paints the identical var(--background) to the deleted posts-wcp rule); cta-wcp now paints a SET tone via Section's CVA (solid brand — faithful to live's solid #1e73be AND probe-visible) and keeps the legacy gradient for UNSET (unmeasured must not change; the adapter passes unset through un-coerced). All 26 levers now measure alive; the KNOWN_DEAD allowlist is empty. — And the FAQ ×19 rows were NOT a lever bug but its mirror image: a working lever fed a wrong value by a dishonest join key (one Wix wrapper id spans the cta ribbon AND the FAQ band; the live FAQ section's id doesn't exist in the static DOM). Fixed with a heading/textHead-consistency guard on makeStyleLookup's joins (A/B: garvanbay 0 diffs; WCP exactly 19 FAQ blocks × tone/_bg/align); 19 FAQs re-stamped via surgical sync-style-to-d1 + committed-seed patch; the 20th (home) is blocked on the known FAQ heading-EXTRACTION bug (its block heading is cta copy, so the join honestly agrees with the cta band). Result (worker b7b37455, re-measured): variant mis-renders 38 → 0; systematicVariantMismatch veto GONE; WCP holds only on contentDestroyed + videoDebt; S 77.9→82.2, G_det 78.5→84.4, composite 83.6→87.2 (above τ_ship — only real defects hold it). garvanbay untouched (no redeploy; seed A/B 0 diffs). The 20th FAQ closed 2026-07-16 pt2 — lib/matchers/FAQ.js now anchors the heading to the accordion by document order (headingBeforeAccordion), so home's block heading became its own ("Waterford County Painters FAQ") and the 1d join guard then paired it with the real capture section: tone brand→default, _bg→#ffffff, align→left fell out of the same regen. Blast radius measured first (45 sections unchanged / 0 declines / 2 changed, both the home wrapper double); garvanbay seed 0 diffs; both lanes exercised (47 static-lane FAQ sections, 0 throws); landed by surgical sync-style-to-d1 (1 block patched, 13 preserved) + committed-seed patch, no redeploy (existing rendered fields, D1-read-at-request). It unmasked a pre-existing loss the wrong heading had been masking — home's cta band has no block at all (one Wix section, two bands) — which honestly re-attributes home's S drop faq(w0.75) → content(w1.0), S 81.6 → 79.6. Segmentation slice, filed in known-issues. |
| 2 | IF STILL WARRANTED after Slice 1: the report-only dimension-gap detector (§2). ~1 call/critical page, writes nothing, precision scored against the deterministic axes. | Re-asked 2026-07-14, after 1d: NOT built. The test this ADR set was "if the gate's punch-list already matches an owner's read of the page, the VLM's marginal value is small." Measured against the owner's original 4/10 list: topbar colour → chromeDisagree (v7 strip probe measures BOTH lanes for real); FAQ colour → fixed (join guard) + systematicVariantMismatch machinery; icon-card text → contentDestroyed; nav / links → fabricatedLink; videos → videoDebt. Every item the owner saw now has a deterministic detector, and WCP's remaining HOLD reasons are known render/schema work (gallery body field, video segmentation), not perception gaps. The correlated-blindness argument also weakened where it was strongest: the chrome probe no longer shares a blind spot across lanes. Not closed permanently — the reopen trigger stands (a defect that maps to NO existing dimension and no deterministic probe could reach). |
| 3 | A stamp path. Not planned. If ever revisited, §4's five mechanisms are mandatory. | Rejected for now |
Slice 1b judgement call — imagesDropped is REPORTED, never scored or gated¶
Included in siteC it scored garvanbay 47.7 vs WCP 92.7 — ranking the SHIP-grade reference
below the site its owner rated 4/10. Probing the live DOM (rather than assuming a detector bug)
showed why: Wix paints a full-bleed section background as an <img>; our Astro output paints the
same image as a CSS background-image. That is representation drift, not loss — the same class
as the columns over-fire already in known-issues. So siteC = 100 × (1 − destroyed / textBearing)
(WCP 99.3 / garvanbay 100), and imagesDropped is surfaced for triage only (10 WCP / 34 garvanbay).
Making it gate-able needs an ours-side background-image probe → Slice 1c.
Promotion record¶
Promoted DRAFT → Accepted on 2026-07-14. The decision was inverted before promotion by an adversarial review that ran the scorecard instead of reasoning from the brief — see §Context. The original draft proposed a VLM that writes bounded config to live D1. That draft was wrong:
- the defects it aimed to find were already measured (38 of them, rolled up to
count: 0); - its action space (the 18
selectfields) is exactly the set of dimensions that already have deterministic producers; - and at least one of its levers (
toneoncta-wcp) is inert, so it would have proposed the same no-op stamp forever, each one logged as an applied fix.
Sign-off criteria at promotion: (a) Slice 1a shipped and WCP HOLDs with a punch-list matching the owner's read of the page; (b) Slice 1b shipped, S/T/G_det byte-identical, and it found a real bug on its first run; (c) the write path is rejected permanently, not deferred.
When to revisit¶
- After Slice 1 lands, honestly re-ask whether Slice 2 is worth building at all. If the gate's punch-list already matches an owner's read of the page, the VLM's marginal value is small.
- A defect is found that maps to no existing dimension and no deterministic probe could reach it — that is the correlated-blindness case, and the only thing that justifies §2.
- The levers stop being unaudited (1d) — a config-writing agent is conceivable only after that.
References¶
- ADR-0009 §2/§3/§6;
near-match-scorecard-contract.md§2 (per-region LLM scoring already rejected) - ADR-0004 (loud-fail; never fabricate); ADR-0005 §6 (provenance in-data)
- known-patterns: "A hand-written D1 patch is a FABRICATION with a production address", "The near-match scorecard is BLIND to content destroyed INSIDE a matched block"
lib/verify-content.js(exists, unwired — Slice 1b),lib/verify-design.js(developer triage; retired as a gate)lib/scorecard-gate.js(collectVariantMismatchVetoesstub — Slice 1a)packages/components-v3/src/components/blocks/cta-wcp.astro(the inerttonelever — Slice 1d)- Session:
session-2026-07-14-handover.md