Phase 09 · v1.1 · Shipped 2026-05-26

Event rules catalog.

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.

Gathered2026-05-26 ModeSchema + dual-read Depends onPhase 8 · Phase 12 Z1
Phase boundary

The rule catalog becomes the BI team's source of truth.

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.

Decisions

Eight questions — including Q4 promoted from "deferred" to "in scope" by codex.

Q1 · Schema shape

JSON addressed by (propertyKey × pageType × logicalEvent × source).

{
  "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.

Q2 · Dual-read transition

Mirrors Phase 5/6 cutover pattern.

  • Add 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.
  • BI team reviews diff, signs off, then flag flips by default in a v1.2 cutover phase.
Q3 · requiredParams strictness

Three modes per rule, default 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.

Q4 · Raw param capture · promoted to in-scope

Without it, 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.

Q5 · Count rule semantics · revised

Resolved by Q4(b): counts come from 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.

Q6 · Rule precedence · revised

Fully-ordered specificity score; validator rejects ties.

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

Q7 · Tooling · revised

Four CLI tools, not three.

  • 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.
  • NEW npm run rules:coverage — cross-checks rules against data/site-inventory.json. Emits gap: and shadow: warnings.
Q8 · uiEvidence shadowing · new

Reject rules with non-empty 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.

Success criteria

Eight, including the parity-overclaim caveat.

  1. data/event-rules.json covers every (existing-property × existing-pageType × current-9-logical-events) cell currently in data/event-mapping.json.
  2. rules:validate passes; typo ("exactlyOne") fails with line/column. Rules with uiEvidence rejected unless EVENT_RULES_V2_UIEVIDENCE=true.
  3. rules:coverage reports zero gaps against data/site-inventory.json for the current 9 events on every in-scope property.
  4. 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.
  5. With EVENT_RULES_V2=true, a full local run on blesk desktop produces the same passed values as EVENT_RULES_V2=false.
  6. eventPayloads parallel singleton populated for every captured event, drained by the same reset; existing Set<string> storage and serialization unchanged byte-for-byte.
  7. rules:explain accepts --viewport and --uiEvidence flags, prints specificity score in decreasing order.
  8. npm run check + npm run test:logic pass; no new npm deps.
Files

Schema, loader, CLI, dry-run, parity tests.

newdata/event-rules.json
newsrc/lib/eventRules.tsloader + validator + specificity resolver
modsrc/utils/eventUtils.tsdual-read + eventPayloads
modsrc/utils/handlerUtils.tspopulate eventPayloads
newscripts/dry-run-rules.ts
newscripts/rules-cli.tsvalidate / diff / explain / coverage
modpackage.json4 npm scripts
newtests/event-rules.test.js, event-rules-parity.test.js
newdocs/event-rules-reference.mdBI authoring guide
Review

Codex critique & resolution.

!

Raw param capture was schema-only

CODEX BLOCKER · INTEGRATED

Current storage stores names only. Without eventPayloads, requiredParams and paramValues couldn't actually be checked at runtime. Promoted into Phase 9 scope.

uiEvidence shadowing footgun

CODEX HIGH-RISK · INTEGRATED

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.

Specificity under-specified · tooling gaps

CODEX · INTEGRATED

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

Dual-read parity can overclaim safety

CODEX · INTEGRATED

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.

← Previous Phase 8 · Inventory Next → Phase 10 · UI evidence + triage