Replace the flat 9-key mapping (data/event-mapping.json) with a structured rule set addressed by (property × pageType × logicalEvent × source). Each rule defines: when it applies, what event name to expect per source, count rule, required parameters, viewport scope. Dual-read transition behind EVENT_RULES_V2=true.
The runtime keeps reading the old mapping until a feature flag flips, behind a dry-run gate identical in shape to Phase 5/6's cutover pattern. BI team edits data/event-rules.json via PR; no UI in v1.1.
Out of scope: capturing UI evidence (Phase 10 — appliesWhen.uiEvidence is parsed but rejected as active rule until Phase 10), default flip / removal of old mapping (separate v1.2 cutover phase), authoring UI.
{
"version": 2,
"rules": [{
"id": "premium-article-view",
"logicalEvent": "Premium Article View",
"appliesWhen": {
"propertyKey": "blesk|aha|...",
"pageType": "article_premium_unlocked",
"viewport": ["desktop", "mobile"],
"uiEvidence": { "isPremiumUser": true, "hasPremiumBadge": true }
},
"sources": {
"dataLayer": { "eventName": "premium_article_view", "count": "exactlyOnce", "requiredParams": ["article_id","premium"] },
"ga4": { "eventName": "premium_article_view", "count": "exactlyOnce" },
"gemius": { "eventName": "premium_article_view", "count": "atLeastOnce" }
}
}]
}
Rule id is the dedup key for Slack incidents (Phase 11 contract). propertyKey accepts "*" for cross-site defaults.
EVENT_RULES_V2=true env flag, default false in v1.1.npm run dry-run:rules replays the engine against captured eventStorage.json fixtures, emits old-vs-new verdict diff.warn.strict — missing param fails the row with failureKind: "param-mismatch".warn — missing param surfaces in paramWarnings array but doesn't fail.ignore — params not checked.v1.1 ships everything as warn to avoid mass-failing rows during rollout. BI team upgrades critical rules to strict after first week.
requiredParams can't be checked at all.Codex blocker. Current storage is event names only — Set<string> per source. The rule schema specified requiredParams and paramValues with no path to actually check them.
Fix: add eventPayloads as an additive parallel singleton:
// existing — untouched eventStorage[media][fn][source] = Set<string>; // new — parallel eventPayloads[media][fn][source] = Array<{ name: string, params: Record<string, unknown>, capturedAt: string }>;
eventPayloads resets in lockstep with eventStorage via the same resetFunctionEventStorage call, preserving the CLAUDE.md mutable-singleton drain/reset contract.
eventPayloads.length.Original contradiction (codex flag): "byte-for-byte unchanged storage" and "new parallel map in eventStorage" were inconsistent. Resolution: eventStorage proper untouched; eventPayloads is a separate exported singleton.
specificity(rule) = 1000 * hasSpecificPropertyKey
+ 500 * hasSpecificPageType
+ 100 * hasSpecificViewport
+ 10 * uiEvidenceFieldsCount
+ 1 * hasParamConditions
Highest specificity wins. The validator rejects ties unless rules are explicitly mutually exclusive (e.g. one for isPremiumUser:true, one for isPremiumUser:false — checked for provable disjointness).
npm run rules:validate — schema check + uiEvidence-without-runtime check (Q8).npm run rules:diff <old> <new> — semantic diff.npm run rules:explain <propertyKey> <pageType> [--viewport=…] [--uiEvidence=…] — codex flag: original couldn't actually explain rules using those dimensions.npm run rules:coverage — cross-checks rules against data/site-inventory.json. Emits gap: and shadow: warnings.uiEvidence until Phase 10.Codex high-risk flag. Original draft said the schema ships with appliesWhen.uiEvidence but the runtime ignores it until Phase 10 — analysts could author rules that silently never fire or shadow each other.
Resolution: rules:validate rejects any rule with non-empty appliesWhen.uiEvidence unless EVENT_RULES_V2_UIEVIDENCE=true. v1.1 ships with that flag false; Phase 10 flips it.
data/event-rules.json covers every (existing-property × existing-pageType × current-9-logical-events) cell currently in data/event-mapping.json.rules:validate passes; typo ("exactlyOne") fails with line/column. Rules with uiEvidence rejected unless EVENT_RULES_V2_UIEVIDENCE=true.rules:coverage reports zero gaps against data/site-inventory.json for the current 9 events on every in-scope property.dry-run:rules emits zero verdict diffs vs current matcher for the subset of rules whose specificity matches the fixture context. Caveat documented because fixtures don't carry viewport / uiEvidence.EVENT_RULES_V2=true, a full local run on blesk desktop produces the same passed values as EVENT_RULES_V2=false.eventPayloads parallel singleton populated for every captured event, drained by the same reset; existing Set<string> storage and serialization unchanged byte-for-byte.rules:explain accepts --viewport and --uiEvidence flags, prints specificity score in decreasing order.npm run check + npm run test:logic pass; no new npm deps.Current storage stores names only. Without eventPayloads, requiredParams and paramValues couldn't actually be checked at runtime. Promoted into Phase 9 scope.
Shipping a schema field the runtime ignores invites silent rule-author errors — two rules differing only by hasGallery:true/false would both match every page until Phase 10. Validator now rejects until the flag flips.
"Most-specific wins" left viewport and param specificity unranked. Replaced with a fully-ordered score. Added rules:coverage CLI to cross-check against the inventory and surface shadowed rules.
Existing fixtures don't carry viewport, uiEvidence, or param dimensions; "zero verdict diffs" only proves parity for the subset where those fields are absent. Success criterion #4 now states the explicit caveat.