Robots capture uiEvidence before validating events. A pure classifier maps (uiEvidence × rule × outcome) → failureKind. Extends Phase 7's enum with param-mismatch, duplicate, under-counted, unclassified. The headline BI win: kills the false-positive class where "article has no gallery → gallery_view missing" gets reported as tracking-broken.
1. UI evidence capture per page load. 2. Deterministic triage classifier extending Phase 7's FailureKind. Never silently passes a row.
Out of scope: Slack output (Phase 11), the rule schema itself (Phase 9), the inventory (Phase 8), new/repeated dedup (Phase 11).
export type UiEvidence = { pageType: PageType | "unknown"; device: "desktop" | "mobile" | "tablet"; hasArticleBody, hasGallery, hasVPlayer, hasVideo: boolean; hasPaywall, hasPremiumBadge: boolean; isLoggedIn, isPremiumUser: boolean; consentAccepted, modalBlocking: boolean; // CNC-specific (added in review) consentProvider: "cpex" | "didomi" | "other"; paywallVariant: "hard" | "soft" | "metered" | null; collectedAt: string; selectorHits: Record<string, boolean>; };
consentProvider routes Phase 12 Z3's TestError classifier correctly. paywallVariant: "metered" shouldn't expect paywall_view until the meter trips.
CLAUDE.md says "fixed positional signature" — preserved by appending, not changing existing positions. Verified against src/robots/homepage.ts and pagination.ts — both accept trailing optional params already (dynamic?: boolean).
Alternative considered: read uiEvidence from a module-level singleton. Rejected — makes the call site implicit, breaks the explicit positional contract robots already have.
evidence:selftest for rot.{
"hasGallery": ["[data-role='gallery']", "//div[contains(@class,'gallery-component')]"],
"hasVPlayer": ["#vplayer-instance", "[data-vplayer]"],
"hasPaywall": [".paywall-overlay", "#cnc-paywall"],
"isPremiumUser": ["[data-user-tier='premium']"]
}
Cross-property defaults in _default.json. npm run evidence:selftest <propertyKey> probes every selector on a known-good URL nightly; warns on ≥30% miss.
| # | Condition | Verdict |
|---|---|---|
| 0 | uiEvidence === undefined or status:"collection-failed" | Fall through to Phase 7 behavior |
| 1 | Technical pre-check failed (consent, login, timeout, modal blocking) | robot-broken / timeout / site-broken |
| 2 | Rule's appliesWhen.uiEvidence doesn't match | outcome: "rule-not-applicable" (non-failure) |
| 3 | Rule applies, expected event missing | tracking-broken |
| 4 | Event present, count rule violated (too many) | duplicate |
| 4b | Event present, count rule violated (too few) | under-counted |
| 5 | Event present, count OK, required param missing & strict | param-mismatch |
| 6 | All checks pass | passed: true |
| 7 | No rule matched, no technical failure | unclassified → triage |
Edge case documented: a row with BOTH technical failure AND tracking miss reports technical (branch 1 wins). Once consent is blocking, no tracking conclusion is trustworthy.
param-mismatch · duplicate · under-counted · unclassified.Keeping duplicate and under-counted separate (not merged into count-mismatch) — they route differently in Phase 11: duplicate inflates BI numbers and is urgent; under-counted may be a timing issue and is investigative.
robot-broken — not tracking-broken.Evidence collector retries consent once, then sets modalBlocking: true. Triage branch 1 short-circuits the row.
Every classifier read of uiEvidence is guarded by if (!uiEvidence) return phase7Classify(...). EV2-parity test asserts bytewise equality for fixture rows with EVENT_RULES_V2=false.
collectUiEvidence catches its own errors and returns {status:"collection-failed",error:"..."}. Classifier branch 0 falls through to Phase 7.
uiEvidence populated on every ReportDetailRow written after Phase 10 ships.gallery_view row is outcome: "rule-not-applicable", NOT a tracking failure. Primary BI ask.failureKind: "duplicate".unclassified row — proves we never silently drop.EVENT_RULES_V2=false, behavior is byte-identical to Phase 7 — proven by a regression test running both paths against the same fixture.npm run check + npm run test:logic pass; no new npm deps.Reviewed against the same 7-point checklist using cross-cutting findings from the Phase 9 / 11 / 12 codex reviews.
Added consentProvider (cpex / didomi / other) — different surfaces have different cleanup paths. Added paywallVariant (hard / soft / metered) — different UX produces different expected event sequences.
Appending one positional param doesn't violate the CLAUDE.md hard-no; existing robots already accept trailing optional params (dynamic?: boolean). Module-level state alternative rejected as too implicit.
Original draft didn't specify what happens when collectUiEvidence itself throws. Added {status:"collection-failed"} sentinel + branch 0 in the classifier.
Parity test fixtures are frozen eventStorage.json snapshots. New rules added to data/event-rules.json don't invalidate them — with EVENT_RULES_V2=false, the runtime reads the old mapping. Test owners maintain snapshots, not BI authors.