ADR-106: Rule-`outcome` model and `winner_takes_all` aggregation
Context
The canonical decision model — the Codex primitives.mdx and world-model.mdx — is rule-evaluation-produces-outcome: a rule carries a static outcome (one of GREEN | GREEN-SKIP | YELLOW | RED) that it emits when its predicate matches; the predicate itself only reports {matched: bool}; the composition root reads metadata.outcome to determine the contributing status. Severity tier (T1/T2/T3) is a separate axis governing sort and suppression, not the outcome generator.
The v0 decision engine (SPEC-533 codegen + SPEC-534 engine, 2026-05-31) never built the outcome field. worlds.rules carried only severity_tier, and codegen + the engine collapsed outcome into a severity→status map: no rule matched → GREEN, a t1 match → RED, a t2/t3 match → YELLOW. A rule could never emit an affirmative GREEN; GREEN meant only “nothing fired.” This was documented in-code as a deliberate v0 narrowing (the engine.py / composition.py v0-boundary docstrings) and deferred by SPEC-534’s scope note as “rule-level outcome contributions + aggregation modes.”
The Phase C dogfood (SPEC-682) deployed a real ruleset and exercised /decide end-to-end — the no-shortcut path the validation program exists to run. It exposed that the stopgap forces unnatural authoring: because a rule cannot emit an outcome, rules must be phrased to “fire on the block/escalate condition” and graded by severity-as-outcome, instead of the natural “this rule says permitted/GREEN” or “this rule says blocked/RED.” A qualifying dependent and an ineligible one both matched their descriptive rules → both YELLOW; an affirmative GREEN could only ever mean “nothing fired.” Meaningful, naturally-authored adjudications need the outcome model.
ADR-100 D1 enumerated the rule’s axes as “four axes total” (two-dimensional provenance, severity, lifecycle) — but the emitted outcome is a fifth axis it did not name, present only in the Codex prose ADR-100 reconciles to. This ADR ratifies that axis and the aggregation model that consumes it, and brings the implementation to the canonical design.
Decision
D1 — A rule carries a static emitted outcome
A rule carries an outcome — the four-state status it contributes when its predicate matches — as static authoring metadata, decoupled from severity:
- The value set is
GREEN | GREEN-SKIP | YELLOW | RED(uppercase, matching the platformDecisionStatuswire values so the value carries verbatim from authoring through codegen to/decide). A rule can emit an affirmativeGREEN(permitted). - It is stored on
worlds.rules.outcome(a new column,NOT NULL DEFAULT 'YELLOW'— the safe middle band, mirroring theseverity_tierdefaultt2), set once at authoring time and carried forward on a revision (like provenance and severity; re-grading flows through the ADR-026 restatement mechanism, not an in-place mutation). - The predicate stays match-only (
{matched: bool, reason?, trace?}). Outcome is metadata the composition root reads — not predicate output. outcomeis independent ofseverity_tier: severity orders which matched rule’s outcome wins; it no longer is the outcome.
D2 — Codegen emits outcome into the module ABI; the engine reads it
The deployed-module ABI (the ADR-080 D4 composition root) gains a _RULE_OUTCOME registry (rule_id → emitted status) alongside the existing _RULE_FUNCTIONS; _RULE_SEVERITY is re-typed to carry the severity tier ('t1'|'t2'|'t3') rather than a status literal. The runtime engine determines each matched rule’s contributing status from _RULE_OUTCOME, not from severity. The per-rule executor reports (outcome, severity_tier) per rule so aggregation runs over the isolated outcomes (one errored predicate cannot mask another rule’s RED, preserving ADR-083 D3).
D3 — Aggregation is winner_takes_all; severity is the sort/suppression axis
The single implemented aggregation mode is winner_takes_all: among the matched rules, the highest-severity rule’s outcome wins. Precisely:
- T1 is the unconditional hard-floor top rank — a matched T1 rule’s outcome wins outright over any lower-tier matched rule, preserving the T1-unsuppressible property across all (current and reserved) aggregation modes. Aggregation across modes otherwise applies to T2/T3.
- Intra-tier tie-break — most-restrictive wins. When two or more matched rules share the winning tier but emit different outcomes, the most-restrictive binds: RED > YELLOW > GREEN-SKIP > GREEN. This is fail-safe (a tie never silently downgrades a block), deterministic, and reuses the engine’s existing status-severity ordering. (The Codex and the prior aggregation description left this case unspecified; this ADR settles it.)
- The ADR-083 D3 error-floor composes on top: any errored / timed-out predicate raises the final status to at least YELLOW, but never downgrades a more-severe matched outcome.
- Nothing matched → GREEN (the
NO_RULE_MATCHdefault — an affirmative proceed).
aggregation_outcome.mode and the World Model Card’s aggregation_mode are winner_takes_all. vote_of_n and weighted_sum_threshold remain reserved-but-shaped (not implemented).
D4 — The decision basis records each matched rule’s outcome
The decision metadata (the ADR-077 D3 audit substrate) records, per matched rule, its emitted outcome and severity_tier (a matched_rule_outcomes structure), serialized into the durable decision_records.decision_metadata JSONB. This makes a decision provable: why a status bound (which rule’s outcome won at which tier), not merely which rules matched. Additive — no migration, no change to the decision.recorded event payload.
D5 — Backward compatibility: a shim reads pre-ADR-106 bundles
Decision modules are content-addressed and immutable; modules deployed before this ADR expose no _RULE_OUTCOME and carry a status literal in _RULE_SEVERITY. The executor reads such a legacy bundle through a shim that derives the emitted outcome from that legacy status and reconstructs the tier (RED→t1, else t2) — preserving the pre-ADR-106 behavior exactly, so no forced re-deploy is required. The migration backfill (t1→RED, else YELLOW) applies the same mapping to existing worlds.rules rows.
D6 — Out of scope (deferred-but-shaped)
GREEN-SKIP is representable as an emitted outcome but skip/no-op suppression semantics are not built (suppression_chain stays empty). The non-default aggregation modes (vote_of_n, weighted_sum_threshold) and a stored per-action aggregation_mode column are not built (the single mode is hardcoded). These layer on without reshaping the ABI or the engine.
Consequences
- A rule is authored in decision terms — “this rule says GREEN / YELLOW / RED” — instead of being phrased to fire on the escalate condition and graded by severity-as-outcome. The Phase C dogfood rules are re-authored to carry explicit outcomes (SPEC-683 AC7), removing the decision-polarity workaround.
- Severity becomes a genuinely independent axis (sort/suppression), consistent with ADR-100 D1’s “each axis independent” intent — now including the emitted-outcome axis that ADR-100’s enumeration omitted (amended there in lockstep).
- The four-state contract is unchanged at the edge; affirmative GREEN now arises from a matched GREEN rule, not only from “nothing fired.”
- The decision basis is richer (per-rule outcome + severity) and the customer deep-dive can surface why a decision bound. Additive to the recorded metadata; the wire event is unchanged.
- Already-deployed modules keep loading via the D5 shim; the shim can retire once all worlds re-deploy (tracked, not in scope).
- The Codex (
primitives.mdx/world-model.mdx) already documented this model as the design; it is reconciled to the implementation here and gains the intra-tier tie-break (D3) it had left unspecified.
References
- ADR-077 D3 — the
{status, work_frame, decision_metadata}decision contract + the rule-level-contribution follow-up this realizes - ADR-080 D4 — the deployed-module manifest + ABI the
_RULE_OUTCOMEregistry extends - ADR-083 D3 — per-predicate isolation + the error-floor that composes with
winner_takes_all - ADR-100 D1 — the rule-axis enumeration amended here to acknowledge the emitted-
outcomeaxis - Codex
primitives.mdx/world-model.mdx— the canonical rule-outcome+ severity + aggregation-mode model this implementation reconciles to