Domain: forward-chaining business-rules engine

Issue:
Support escalated two automation incidents from the same customer, both involving rule cascades — automations reacting to facts that other automations modify within the same run.
First incident: a notification automation ran with values that were already out of date. A higher-priority rule had updated the ticket's severity moments earlier in the same run, and the notification that followed quoted the old severity. The firing trace makes it worse: the notification's justification points at the pre-update version of the fact, which is exactly what our audit tooling relies on never happening.
Second incident: a reminder automation ran even though the condition it watches had just stopped being true. An earlier rule in the same run closed the ticket; the reminder fired anyway, off the ticket's earlier state.
We also have a third report we believe is the same family: an escalation automation guarded by an "unless an open alert already exists" absence guard fired anyway — another rule had raised exactly such an alert earlier in the same run.
What we expect is what the engine already does everywhere else: every firing sees the world as it stands at the moment it fires, and fires only if its condition — guards included — holds then. Changing facts between runs already does the right thing in every case we can construct, and cascades where rules only assert or retract facts that other rules match positively seem fine too. Whatever this is, it is specific to what rules change while a run is in progress.
One boundary on the fix: the incremental design is intentional. Within a run the engine re-examines only what an action touched, and the full sweep happens once, when the run starts — match work is metered and capacity dashboards depend on it keeping that shape.

Repository files:
content: # Present so pytest adds the repository root to sys.path, making `import src`
# resolve from the test suite.

path: conftest.py

content: # Pure standard library unless the environment image pre-bakes wheels.

path: requirements.lock

content: """ruled: a small forward-chaining rules engine.

Facts go into working memory; rules watch for them; actions fire and
may assert, modify, or retract more facts — cascades run to
quiescence with deterministic ordering, refraction, and a firing
trace suitable for audits.
"""

from .conditions import Absent, Condition, Pattern, Var
from .engine import Engine, Firing
from .errors import (
    CascadeLimitError,
    DuplicateRuleError,
    ReentrantRunError,
    RulesError,
    UnknownFactError,
)
from .facts import Fact, WorkingMemory
from .rules import Rule, RuleBook, rule
from .trace import FiringRecord

__all__ = [
    "Absent",
    "CascadeLimitError",
    "Condition",
    "DuplicateRuleError",
    "Engine",
    "Fact",
    "Firing",
    "FiringRecord",
    "Pattern",
    "ReentrantRunError",
    "Rule",
    "RuleBook",
    "RulesError",
    "UnknownFactError",
    "Var",
    "WorkingMemory",
    "rule",
]

path: src/__init__.py

content: """The agenda: activations awaiting their turn to fire.

An *activation* is one match, frozen: the rule, the facts that
satisfied it (as immutable snapshots), the variable bindings, and the
justification the match rests on. Firing order is salience first
(higher wins), then the newest justifying stamp (fresher knowledge
first), then rule registration order.

``rebuild`` constructs the agenda from a full match sweep when a
drain begins; ``reconcile`` folds one action's mutations in between
firings (see ``matcher`` on why the incremental split matters
operationally).
"""

from dataclasses import dataclass
from typing import Dict, FrozenSet, List, Optional, Set, Tuple

from .facts import Fact, WorkingMemory
from .matcher import Match
from .metrics import Metrics
from .rules import RuleBook

Justification = Tuple[Tuple[str, int], ...]


def fact_ids(justification: Justification) -> FrozenSet[str]:
    """The facts a justification rests on."""
    return frozenset(fid for fid, _ in justification)


@dataclass(frozen=True)
class Activation:
    rule_name: str
    salience: int
    registration: int
    facts: Dict[str, Fact]
    bindings: Dict[str, object]
    justification: Justification
    newest_stamp: int

    def sort_key(self):
        return (
            -self.salience,
            -self.newest_stamp,
            self.registration,
            self.justification,
            tuple(sorted(self.facts)),
        )


class Agenda:
    def __init__(self, metrics: Metrics) -> None:
        self._metrics = metrics
        self._queued: List[Activation] = []
        self._fired: Set[Tuple[str, Justification]] = set()

    # -- construction -----------------------------------------------------

    def _activation(self, rules: RuleBook, match: Match) -> Activation:
        return Activation(
            rule_name=match.rule.name,
            salience=match.rule.salience,
            registration=rules.registration_index(match.rule.name),
            facts=dict(match.facts),
            bindings=dict(match.bindings),
            justification=match.justification(),
            newest_stamp=match.newest_stamp(),
        )

    def rebuild(self, rules: RuleBook, matches: List[Match]) -> None:
        """Fresh agenda from a full match sweep (drain entry)."""
        self._metrics.drain_sweeps += 1
        self._queued = [
            self._activation(rules, m)
            for m in matches
            if (m.rule.name, m.justification()) not in self._fired
        ]

    def reconcile(
        self,
        rules: RuleBook,
        memory: WorkingMemory,
        delta_matches: List[Match],
        retracted: Set[str],
    ) -> None:
        """Fold one action's mutations into the agenda.

        Keeps queued work that still applies, drops work whose facts
        went away, and queues matches the mutations created — without
        double-queueing anything already on the agenda or already
        fired.
        """
        self._metrics.delta_reconciles += 1
        survivors = []
        for act in self._queued:
            if all(memory.contains(fid) for fid in fact_ids(act.justification)):
                survivors.append(act)
            else:
                self._metrics.withdrawals += 1
        queued_or_fired = {
            (act.rule_name, fact_ids(act.justification)) for act in survivors
        }
        queued_or_fired.update(
            (name, fact_ids(just)) for name, just in self._fired
        )
        for match in delta_matches:
            key = (match.rule.name, fact_ids(match.justification()))
            if key in queued_or_fired:
                continue
            queued_or_fired.add(key)
            survivors.append(self._activation(rules, match))
        self._queued = survivors

    # -- consumption ------------------------------------------------------

    def pop(self) -> Optional[Activation]:
        if not self._queued:
            return None
        self._queued.sort(key=Activation.sort_key)
        act = self._queued.pop(0)
        self._fired.add((act.rule_name, act.justification))
        return act

    def __len__(self) -> int:
        return len(self._queued)

    def peek_order(self) -> List[str]:
        """Rule names in current firing order (diagnostics surface)."""
        return [a.rule_name for a in sorted(self._queued, key=Activation.sort_key)]

path: src/agenda.py

content: """Rule conditions: patterns over working memory.

A ``Pattern`` matches facts of one type against attribute tests. A
test is either a literal (attribute equals value), a callable
predicate over the attribute value, or a ``Var`` — a named binding
variable. The first pattern that mentions a variable binds it; later
patterns mentioning the same variable only match facts whose
attribute equals the bound value (an equi-join).

An ``Absent`` term guards a condition: the condition holds only while
NO fact satisfies the absent term's tests (under the match's variable
bindings). Absence guards are how rules say "unless": escalate unless
an open alert already exists, remind unless the customer opted out.

A rule's condition is a conjunction of one or more aliased patterns
plus any absence guards. A *match* is one assignment of distinct
facts to aliases satisfying all tests, joins, and guards; the facts
assigned to aliases are the match's justification (absence binds no
fact — it is a claim about the rest of the world).
"""

from dataclasses import dataclass
from typing import Dict, List, Optional

from .facts import Fact


@dataclass(frozen=True)
class Var:
    name: str


class Pattern:
    def __init__(self, alias: str, fact_type: str, **tests) -> None:
        self.alias = alias
        self.fact_type = fact_type
        self.tests = tests

    def try_match(self, fact: Fact, bindings: Dict[str, object]) -> Optional[Dict[str, object]]:
        """Bindings extended by this fact, or None if it does not match."""
        if fact.type != self.fact_type:
            return None
        out = dict(bindings)
        for attr, test in self.tests.items():
            value = fact.value(attr)
            if isinstance(test, Var):
                if test.name in out:
                    if out[test.name] != value:
                        return None
                else:
                    out[test.name] = value
            elif callable(test):
                if not test(value):
                    return None
            else:
                if value != test:
                    return None
        return out

    def __repr__(self) -> str:  # pragma: no cover - diagnostics only
        return f"Pattern({self.alias}: {self.fact_type})"


class Absent:
    """No fact of ``fact_type`` satisfying ``tests`` may exist."""

    def __init__(self, fact_type: str, **tests) -> None:
        self.fact_type = fact_type
        self.tests = tests

    def blocked_by(self, fact: Fact, bindings: Dict[str, object]) -> bool:
        """Does this fact violate the guard, given the match's bindings?"""
        if fact.type != self.fact_type:
            return False
        for attr, test in self.tests.items():
            value = fact.value(attr)
            if isinstance(test, Var):
                if test.name not in bindings or bindings[test.name] != value:
                    return False
            elif callable(test):
                if not test(value):
                    return False
            else:
                if value != test:
                    return False
        return True

    def __repr__(self) -> str:  # pragma: no cover - diagnostics only
        return f"Absent({self.fact_type})"


class Condition:
    """A conjunction of patterns and absence guards; the match search
    lives in ``matcher``.

    Facts are tried in id order and a fact may satisfy only one alias
    per match, so a match is a set of distinct facts.
    """

    def __init__(self, patterns: List[Pattern], absences: Optional[List[Absent]] = None) -> None:
        if not patterns:
            raise ValueError("a condition needs at least one positive pattern")
        aliases = [p.alias for p in patterns]
        if len(set(aliases)) != len(aliases):
            raise ValueError("pattern aliases must be distinct")
        self.patterns = patterns
        self.absences = list(absences or [])
        bound = {
            t.name
            for p in patterns
            for t in p.tests.values()
            if isinstance(t, Var)
        }
        for absence in self.absences:
            for t in absence.tests.values():
                if isinstance(t, Var) and t.name not in bound:
                    raise ValueError(
                        f"absence guard uses unbound variable {t.name!r}"
                    )

path: src/conditions.py

content: """The engine: working memory + rules + the run-to-completion drain.

``run()`` drives rule firing to quiescence. Semantics:

* A drain begins with one full match sweep; from then on the engine
  is incremental (see ``matcher``).
* Actions mutate facts through their :class:`Firing` context, and the
  mutations take effect immediately — the very next firing decision
  already sees them. Each firing observes every effect of the firings
  before it.
* Actions may not call ``run()``; the drain in progress finishes on
  its own (``ReentrantRunError`` otherwise).
* A drain that exceeds ``max_firings`` raises ``CascadeLimitError`` —
  the safety valve for self-sustaining cascades.

Top-level mutations (outside any drain) simply accumulate in working
memory; the next ``run()`` picks them all up in its entry sweep.
"""

from typing import Dict, List, Optional, Set

from . import matcher
from .agenda import Agenda
from .errors import ReentrantRunError
from .facts import Fact, WorkingMemory
from .metrics import Metrics
from .rules import Rule, RuleBook
from .trace import FiringRecord


class Firing:
    """What an action sees: the match it fired for, plus mutation verbs."""

    def __init__(self, engine: "Engine", facts: Dict[str, Fact], bindings: Dict[str, object]) -> None:
        self._engine = engine
        self.facts = facts
        self.bindings = bindings
        self.touched: Set[str] = set()
        self.retracted: Set[str] = set()

    def assert_fact(self, fact_type: str, **attrs) -> str:
        fact = self._engine.memory.assert_fact(fact_type, **attrs)
        self._engine.metrics.facts_asserted += 1
        self.touched.add(fact.id)
        return fact.id

    def modify_fact(self, fact_id: str, **attrs) -> None:
        self._engine.memory.modify_fact(fact_id, **attrs)
        self._engine.metrics.facts_modified += 1
        self.touched.add(fact_id)

    def retract(self, fact_id: str) -> None:
        self._engine.memory.retract(fact_id)
        self._engine.metrics.facts_retracted += 1
        self.touched.discard(fact_id)
        self.retracted.add(fact_id)

    def query(self, fact_type: str, **eq) -> List[Fact]:
        return self._engine.query(fact_type, **eq)

    def run(self, **kwargs):  # pragma: no cover - guard, exercised via engine
        raise ReentrantRunError("actions must not start a drain")


class Engine:
    def __init__(self) -> None:
        self.memory = WorkingMemory()
        self.rules = RuleBook()
        self.metrics = Metrics()
        self.trace: List[FiringRecord] = []
        self._agenda = Agenda(self.metrics)
        self._draining = False
        self._drains = 0

    # -- authoring --------------------------------------------------------

    def add_rule(self, rule: Rule) -> None:
        self.rules.add(rule)

    # -- top-level mutations ----------------------------------------------

    def assert_fact(self, fact_type: str, **attrs) -> str:
        self._forbid_reentry()
        fact = self.memory.assert_fact(fact_type, **attrs)
        self.metrics.facts_asserted += 1
        return fact.id

    def modify_fact(self, fact_id: str, **attrs) -> None:
        self._forbid_reentry()
        self.memory.modify_fact(fact_id, **attrs)
        self.metrics.facts_modified += 1

    def retract(self, fact_id: str) -> None:
        self._forbid_reentry()
        self.memory.retract(fact_id)
        self.metrics.facts_retracted += 1

    def query(self, fact_type: str, **eq) -> List[Fact]:
        return [
            f
            for f in self.memory.by_type(fact_type)
            if all(f.value(k) == v for k, v in eq.items())
        ]

    # -- the drain ----------------------------------------------------------

    def run(self, max_firings: int = 1000) -> int:
        """Fire until quiescent. Returns the number of firings."""
        from .errors import CascadeLimitError

        self._forbid_reentry()
        self._draining = True
        self._drains += 1
        fired = 0
        try:
            self._agenda.rebuild(
                self.rules, matcher.match_all(self.rules, self.memory, self.metrics)
            )
            while True:
                activation = self._agenda.pop()
                if activation is None:
                    break
                if fired >= max_firings:
                    raise CascadeLimitError(
                        f"drain exceeded {max_firings} firings"
                    )
                firing = Firing(self, dict(activation.facts), dict(activation.bindings))
                activation_record = FiringRecord(
                    drain=self._drains,
                    seq=fired,
                    rule=activation.rule_name,
                    facts={
                        alias: dict(fact.attrs)
                        for alias, fact in activation.facts.items()
                    },
                    bindings=dict(activation.bindings),
                    justification=activation.justification,
                )
                rule = next(
                    r for r in self.rules.all() if r.name == activation.rule_name
                )
                rule.action(firing)
                self.trace.append(activation_record)
                self.metrics.firings += 1
                fired += 1
                if firing.touched or firing.retracted:
                    delta = matcher.match_touching(
                        self.rules, self.memory, self.metrics, firing.touched
                    )
                    self._agenda.reconcile(
                        self.rules, self.memory, delta, firing.retracted
                    )
        finally:
            self._draining = False
        return fired

    # -- helpers ------------------------------------------------------------

    def agenda_preview(self) -> List[str]:
        return self._agenda.peek_order()

    def _forbid_reentry(self) -> None:
        if self._draining:
            raise ReentrantRunError(
                "use the Firing context inside actions; the engine finishes "
                "the drain in progress"
            )

path: src/engine.py

content: """Exception types for the rules engine."""


class RulesError(Exception):
    """Base class for engine errors."""


class UnknownFactError(RulesError):
    """A fact id that is not (or no longer) in working memory."""


class DuplicateRuleError(RulesError):
    """A rule name was registered twice."""


class ReentrantRunError(RulesError):
    """run() was called from inside a rule action.

    Actions mutate working memory through their handle; the engine
    itself finishes the drain. Nested drains would make firing order
    unspecifiable.
    """


class CascadeLimitError(RulesError):
    """A drain exceeded its firing limit (runaway rule cascade)."""

path: src/errors.py

content: """Working memory: the fact store.

A fact is a typed bag of attributes. Facts are immutable snapshots —
``modify`` does not edit a fact in place, it produces the next
*version* of the fact under the same id. Identity (``fact.id``) says
"the same real-world thing"; version says "the same knowledge about
it". Everything downstream that needs to know whether the world has
changed since some earlier moment compares versions, not ids.

Every assertion and modification stamps the fact with the store's
monotonically increasing mutation counter (``stamp``). Stamps give
"recency": which knowledge arrived latest. Retraction removes the id
from working memory entirely; re-asserting equal attribute values
later is a brand-new fact with a new id.
"""

from dataclasses import dataclass, field
from typing import Dict, Iterator, List, Optional

from .errors import UnknownFactError


@dataclass(frozen=True)
class Fact:
    id: str
    type: str
    version: int
    stamp: int
    attrs: Dict[str, object] = field(default_factory=dict)

    def value(self, name: str, default=None):
        return self.attrs.get(name, default)


class WorkingMemory:
    def __init__(self) -> None:
        self._facts: Dict[str, Fact] = {}
        self._next_id = 1
        self._counter = 0

    # -- mutation ---------------------------------------------------------

    def assert_fact(self, fact_type: str, **attrs) -> Fact:
        self._counter += 1
        fact = Fact(
            id=f"f-{self._next_id:06d}",
            type=fact_type,
            version=1,
            stamp=self._counter,
            attrs=dict(attrs),
        )
        self._next_id += 1
        self._facts[fact.id] = fact
        return fact

    def modify_fact(self, fact_id: str, **attrs) -> Fact:
        """Produce the next version of a fact (partial attribute update)."""
        current = self.get(fact_id)
        self._counter += 1
        merged = dict(current.attrs)
        merged.update(attrs)
        fact = Fact(
            id=current.id,
            type=current.type,
            version=current.version + 1,
            stamp=self._counter,
            attrs=merged,
        )
        self._facts[fact.id] = fact
        return fact

    def retract(self, fact_id: str) -> Fact:
        fact = self.get(fact_id)
        self._counter += 1
        del self._facts[fact_id]
        return fact

    # -- queries ----------------------------------------------------------

    def get(self, fact_id: str) -> Fact:
        try:
            return self._facts[fact_id]
        except KeyError:
            raise UnknownFactError(fact_id) from None

    def contains(self, fact_id: str) -> bool:
        return fact_id in self._facts

    def current_version(self, fact_id: str) -> Optional[int]:
        fact = self._facts.get(fact_id)
        return fact.version if fact else None

    def by_type(self, fact_type: str) -> List[Fact]:
        return [f for f in self.all() if f.type == fact_type]

    def all(self) -> Iterator[Fact]:
        return iter(sorted(self._facts.values(), key=lambda f: f.id))

    @property
    def mutation_counter(self) -> int:
        return self._counter

path: src/facts.py

content: """Match computation, with metered work.

Matching is deliberately a straightforward recursive search — working
memories here are small and semantics, not throughput, are the
product. What *is* budgeted is how often that search runs: match work
is metered (``metrics.matches_evaluated`` counts pattern
evaluations), and within a drain the engine re-examines only the
facts an action touched. Full sweeps of working memory happen when a
drain begins, not per firing — regressing that is a real cost on the
rule sets customers run.
"""

from typing import Dict, List, Optional, Set, Tuple

from .conditions import Condition
from .facts import Fact, WorkingMemory
from .metrics import Metrics
from .rules import Rule, RuleBook


class Match:
    def __init__(self, rule: Rule, facts: Dict[str, Fact], bindings: Dict[str, object]) -> None:
        self.rule = rule
        self.facts = facts          # alias -> Fact (immutable snapshot)
        self.bindings = bindings    # variable name -> value

    def justification(self) -> Tuple[Tuple[str, int], ...]:
        return tuple(sorted((f.id, f.version) for f in self.facts.values()))

    def newest_stamp(self) -> int:
        return max(f.stamp for f in self.facts.values())


def guard_holds(
    condition, facts: List[Fact], bindings: Dict[str, object], metrics: Metrics
) -> bool:
    """True while no fact violates any of the condition's absence guards."""
    for absence in condition.absences:
        for fact in facts:
            metrics.matches_evaluated += 1
            if absence.blocked_by(fact, bindings):
                return False
    return True


def _matches_for(rule: Rule, facts: List[Fact], metrics: Metrics) -> List[Match]:
    results = []
    condition: Condition = rule.condition

    def extend(index: int, used: Dict[str, Fact], bindings: Dict[str, object]) -> None:
        if index == len(condition.patterns):
            if guard_holds(condition, facts, bindings, metrics):
                results.append(Match(rule, dict(used), dict(bindings)))
            return
        pattern = condition.patterns[index]
        for fact in facts:
            if any(fact.id == f.id for f in used.values()):
                continue
            metrics.matches_evaluated += 1
            extended = pattern.try_match(fact, bindings)
            if extended is None:
                continue
            used[pattern.alias] = fact
            extend(index + 1, used, extended)
            del used[pattern.alias]

    extend(0, {}, {})
    return results


def match_all(rules: RuleBook, memory: WorkingMemory, metrics: Metrics) -> List[Match]:
    """Every match in working memory (drain-entry sweep)."""
    facts = list(memory.all())
    out: List[Match] = []
    for rule in rules.all():
        out.extend(_matches_for(rule, facts, metrics))
    return out


def match_touching(
    rules: RuleBook,
    memory: WorkingMemory,
    metrics: Metrics,
    touched: Set[str],
) -> List[Match]:
    """Matches that involve at least one of the ``touched`` fact ids.

    The incremental path: after an action mutates facts, only matches
    mentioning a touched fact can be new or changed, so only those are
    recomputed.
    """
    facts = list(memory.all())
    out: List[Match] = []
    for rule in rules.all():
        for match in _matches_for(rule, facts, metrics):
            if any(f.id in touched for f in match.facts.values()):
                out.append(match)
    return out

path: src/matcher.py

content: """Operational counters.

These are part of the engine's observable surface: dashboards chart
them per drain, and capacity planning relies on ``matches_evaluated``
tracking the incremental-matching design (deltas within a drain, one
full sweep at drain entry — see ``matcher``).
"""

from dataclasses import dataclass


@dataclass
class Metrics:
    facts_asserted: int = 0
    facts_modified: int = 0
    facts_retracted: int = 0
    firings: int = 0
    withdrawals: int = 0
    matches_evaluated: int = 0
    drain_sweeps: int = 0
    delta_reconciles: int = 0

    def snapshot(self) -> dict:
        return dict(self.__dict__)

path: src/metrics.py

content: """Rule definitions and the registry.

A rule is a named condition plus an action. ``salience`` ranks rules
when several are ready at once (higher fires first); rules registered
earlier win exact ties. Actions receive a :class:`~src.engine.Firing`
context and may assert, modify, or retract facts through it — that is
how cascades happen, and it is ordinary, supported use.
"""

from typing import Callable, Dict, List

from .conditions import Absent, Condition, Pattern
from .errors import DuplicateRuleError


class Rule:
    def __init__(self, name: str, condition: Condition, action: Callable, salience: int = 0) -> None:
        self.name = name
        self.condition = condition
        self.action = action
        self.salience = salience

    def __repr__(self) -> str:  # pragma: no cover - diagnostics only
        return f"Rule({self.name!r}, salience={self.salience})"


def rule(name: str, terms: List, action: Callable, salience: int = 0) -> Rule:
    """Convenience constructor: build a Rule from patterns and guards."""
    patterns = [t for t in terms if isinstance(t, Pattern)]
    absences = [t for t in terms if isinstance(t, Absent)]
    return Rule(name, Condition(patterns, absences), action, salience)


class RuleBook:
    def __init__(self) -> None:
        self._rules: Dict[str, Rule] = {}
        self._order: Dict[str, int] = {}

    def add(self, rule: Rule) -> None:
        if rule.name in self._rules:
            raise DuplicateRuleError(rule.name)
        self._order[rule.name] = len(self._rules)
        self._rules[rule.name] = rule

    def registration_index(self, name: str) -> int:
        return self._order[name]

    def all(self) -> List[Rule]:
        return [self._rules[n] for n in sorted(self._rules, key=self._order.get)]

    def __len__(self) -> int:
        return len(self._rules)

path: src/rules.py

content: """The firing trace.

Support reads this to answer "why did that automation run?" — each
record carries the rule, the drain and firing sequence numbers, the
attribute values the action saw, and the justification: the exact
fact versions that satisfied the condition.

Audit relies on two properties of this record, and the dispute
process treats both as authoritative. A justification describes the
facts as they stood at the moment the action ran — never an earlier
state of them. And a rule owes exactly one firing per justification:
the same rule runs again only for a justification it has not yet
answered for, and a fact's new version is precisely such new
material — which is why between-runs modifications re-fire the rules
that watch them.
"""

from dataclasses import dataclass, field
from typing import Dict, Tuple


@dataclass(frozen=True)
class FiringRecord:
    drain: int
    seq: int
    rule: str
    facts: Dict[str, Dict[str, object]]   # alias -> attribute values
    bindings: Dict[str, object]
    justification: Tuple[Tuple[str, int], ...]

    def fact_values(self, alias: str) -> Dict[str, object]:
        return dict(self.facts[alias])

path: src/trace.py

content: """Cascades: actions asserting and retracting facts mid-drain."""

from src import Engine, Pattern, Var, rule


def test_asserted_fact_triggers_downstream_rule_in_same_drain():
    e = Engine()
    seen = []

    def escalate(f):
        seen.append("watch")
        f.assert_fact("alert", level="page", ticket=f.facts["t"].id)

    e.add_rule(rule("watch", [Pattern("t", "ticket", sev="high")], escalate))
    e.add_rule(
        rule("pager", [Pattern("a", "alert", level="page")], lambda f: seen.append("pager"))
    )
    e.assert_fact("ticket", sev="high")
    assert e.run() == 2
    assert seen == ["watch", "pager"]


def test_three_stage_assert_chain_runs_in_order():
    e = Engine()
    seen = []
    e.add_rule(
        rule("s1", [Pattern("x", "stage1")], lambda f: (seen.append(1), f.assert_fact("stage2")))
    )
    e.add_rule(
        rule("s2", [Pattern("x", "stage2")], lambda f: (seen.append(2), f.assert_fact("stage3")))
    )
    e.add_rule(rule("s3", [Pattern("x", "stage3")], lambda f: seen.append(3)))
    e.assert_fact("stage1")
    assert e.run() == 3
    assert seen == [1, 2, 3]


def test_fresher_knowledge_outranks_older_at_equal_salience():
    # The cascade-created fact is the newest knowledge in the drain, so
    # its activation fires before one justified by an older fact.
    e = Engine()
    order = []
    e.add_rule(
        rule("spawn", [Pattern("k", "kickoff")], lambda f: f.assert_fact("report"), salience=9)
    )
    e.add_rule(rule("on-report", [Pattern("r", "report")], lambda f: order.append("report"), salience=1))
    e.add_rule(rule("on-backlog", [Pattern("b", "backlog")], lambda f: order.append("backlog"), salience=1))
    e.assert_fact("backlog")
    e.assert_fact("kickoff")
    e.run()
    assert order == ["report", "backlog"]


def test_mid_drain_retract_withdraws_pending_work():
    e = Engine()
    late = []

    def zap(f):
        f.retract(f.facts["t"].id)

    e.add_rule(rule("zapper", [Pattern("t", "ticket", status="open")], zap, salience=10))
    e.add_rule(
        rule("late", [Pattern("t", "ticket", status="open")], lambda f: late.append("late"), salience=1)
    )
    e.assert_fact("ticket", status="open")
    assert e.run() == 1
    assert late == []
    assert e.metrics.withdrawals == 1


def test_retract_only_withdraws_matches_involving_the_fact():
    e = Engine()
    fired = []

    def zap_one(f):
        f.retract(f.bindings["victim"])

    e.add_rule(
        rule(
            "zap",
            [Pattern("z", "zap", target=Var("victim"))],
            zap_one,
            salience=10,
        )
    )
    e.add_rule(
        rule("count", [Pattern("t", "ticket")], lambda f: fired.append(f.facts["t"].id), salience=1)
    )
    keep = e.assert_fact("ticket")
    drop = e.assert_fact("ticket")
    e.assert_fact("zap", target=drop)
    e.run()
    assert fired == [keep]


def test_action_queries_see_current_memory():
    e = Engine()
    counts = []

    def census(f):
        counts.append(len(f.query("ticket")))
        f.assert_fact("done")

    e.add_rule(rule("census", [Pattern("k", "kick")], census))
    e.assert_fact("ticket")
    e.assert_fact("ticket")
    e.assert_fact("kick")
    e.run()
    assert counts == [2]


def test_metrics_count_the_drain():
    e = Engine()
    e.add_rule(
        rule("s1", [Pattern("x", "stage1")], lambda f: f.assert_fact("stage2"))
    )
    e.add_rule(rule("s2", [Pattern("x", "stage2")], lambda f: None))
    e.assert_fact("stage1")
    e.run()
    m = e.metrics
    assert m.firings == 2
    assert m.drain_sweeps == 1          # one entry sweep per run()
    assert m.delta_reconciles == 1      # only the mutating firing reconciles
    assert m.facts_asserted == 2
    assert m.matches_evaluated > 0


def test_trace_records_the_story():
    e = Engine()

    def escalate(f):
        f.assert_fact("alert", level="page")

    e.add_rule(rule("watch", [Pattern("t", "ticket", sev="high")], escalate, salience=5))
    e.add_rule(rule("pager", [Pattern("a", "alert", level="page")], lambda f: None, salience=1))
    e.assert_fact("ticket", sev="high")
    e.run()
    assert [r.rule for r in e.trace] == ["watch", "pager"]
    first, second = e.trace
    assert first.seq == 0 and second.seq == 1
    assert first.drain == second.drain == 1
    assert first.fact_values("t")["sev"] == "high"
    assert all(version == 1 for _, version in first.justification)


def test_agenda_preview_orders_pending_work():
    e = Engine()
    e.add_rule(rule("low", [Pattern("t", "ticket")], lambda f: None, salience=1))
    e.add_rule(rule("high", [Pattern("t", "ticket")], lambda f: None, salience=9))
    e.assert_fact("ticket")
    # Preview is a diagnostics surface: build the agenda without firing
    # by asking for order before any run — it reflects the last drain's
    # leftover state, which is empty here.
    assert e.agenda_preview() == []

path: tests/public/test_cascades.py

content: """Firing order, refraction, and drain semantics."""

import pytest

from src import Engine, Pattern, Var, rule
from src.errors import CascadeLimitError, ReentrantRunError


def test_salience_orders_firing():
    e = Engine()
    log = []
    e.add_rule(rule("low", [Pattern("t", "ticket")], lambda f: log.append("low"), salience=1))
    e.add_rule(rule("high", [Pattern("t", "ticket")], lambda f: log.append("high"), salience=9))
    e.add_rule(rule("mid", [Pattern("t", "ticket")], lambda f: log.append("mid"), salience=5))
    e.assert_fact("ticket")
    assert e.run() == 3
    assert log == ["high", "mid", "low"]


def test_equal_salience_falls_back_to_recency_then_registration():
    e = Engine()
    log = []
    e.add_rule(rule("on-a", [Pattern("x", "alpha")], lambda f: log.append("a"), salience=3))
    e.add_rule(rule("on-b", [Pattern("x", "beta")], lambda f: log.append("b"), salience=3))
    e.add_rule(rule("on-a2", [Pattern("x", "alpha")], lambda f: log.append("a2"), salience=3))
    e.assert_fact("alpha")   # older knowledge
    e.assert_fact("beta")    # newer knowledge: fires first at equal salience
    e.run()
    assert log == ["b", "a", "a2"]


def test_refraction_survives_repeated_runs():
    e = Engine()
    count = []
    e.add_rule(rule("once", [Pattern("t", "ticket")], lambda f: count.append(1)))
    e.assert_fact("ticket")
    assert e.run() == 1
    assert e.run() == 0
    assert e.run() == 0
    assert len(count) == 1


def test_top_level_modify_renews_the_justification():
    # Modifying a fact between drains is new knowledge about the same
    # thing: the same rule fires again for the new version.
    e = Engine()
    seen = []
    e.add_rule(
        rule("watch", [Pattern("d", "device")], lambda f: seen.append(f.facts["d"].value("state")))
    )
    fid = e.assert_fact("device", state="off")
    e.run()
    e.modify_fact(fid, state="on")
    e.run()
    assert seen == ["off", "on"]


def test_top_level_modify_without_match_change_is_still_new_knowledge():
    e = Engine()
    seen = []
    e.add_rule(
        rule("audit", [Pattern("d", "device", state="on")], lambda f: seen.append(f.facts["d"].value("fw")))
    )
    fid = e.assert_fact("device", state="on", fw=1)
    e.run()
    e.modify_fact(fid, fw=2)
    e.run()
    assert seen == [1, 2]


def test_new_facts_between_drains_fire_normally():
    e = Engine()
    seen = []
    e.add_rule(rule("watch", [Pattern("t", "ticket")], lambda f: seen.append(f.facts["t"].id)))
    a = e.assert_fact("ticket")
    e.run()
    b = e.assert_fact("ticket")
    e.run()
    assert seen == [a, b]


def test_actions_may_not_start_a_drain():
    e = Engine()

    def naughty(f):
        f.run()

    e.add_rule(rule("naughty", [Pattern("t", "ticket")], naughty))
    e.assert_fact("ticket")
    with pytest.raises(ReentrantRunError):
        e.run()


def test_top_level_mutations_rejected_mid_drain():
    e = Engine()

    def sneaky(f):
        e.assert_fact("side")

    e.add_rule(rule("sneaky", [Pattern("t", "ticket")], sneaky))
    e.assert_fact("ticket")
    with pytest.raises(ReentrantRunError):
        e.run()


def test_cascade_limit_guards_runaway_rules():
    e = Engine()

    def spawn(f):
        f.assert_fact("spark")

    e.add_rule(rule("fire", [Pattern("s", "spark")], spawn))
    e.assert_fact("spark")
    with pytest.raises(CascadeLimitError):
        e.run(max_firings=25)


def test_quiescence_returns_zero():
    e = Engine()
    e.add_rule(rule("never", [Pattern("t", "nothing")], lambda f: None))
    assert e.run() == 0

path: tests/public/test_firing.py

content: """Pattern matching, joins, and working-memory basics."""

import pytest

from src import Engine, Pattern, Var, rule
from src.errors import DuplicateRuleError, UnknownFactError


def test_literal_and_predicate_tests():
    e = Engine()
    hits = []
    e.add_rule(
        rule(
            "big-orders",
            [Pattern("o", "order", region="eu", total=lambda v: v >= 100)],
            lambda f: hits.append(f.facts["o"].value("total")),
        )
    )
    e.assert_fact("order", region="eu", total=250)
    e.assert_fact("order", region="us", total=900)
    e.assert_fact("order", region="eu", total=40)
    e.run()
    assert hits == [250]


def test_variable_join_across_patterns():
    e = Engine()
    pairs = []
    e.add_rule(
        rule(
            "same-customer",
            [
                Pattern("o", "order", customer=Var("c")),
                Pattern("p", "profile", customer=Var("c"), tier="vip"),
            ],
            lambda f: pairs.append(
                (f.facts["o"].value("customer"), f.bindings["c"])
            ),
        )
    )
    e.assert_fact("order", customer="acme")
    e.assert_fact("order", customer="zeta")
    e.assert_fact("profile", customer="acme", tier="vip")
    e.assert_fact("profile", customer="zeta", tier="basic")
    e.run()
    assert pairs == [("acme", "acme")]


def test_one_fact_cannot_fill_two_aliases():
    e = Engine()
    hits = []
    e.add_rule(
        rule(
            "pair",
            [Pattern("a", "node"), Pattern("b", "node")],
            lambda f: hits.append((f.facts["a"].id, f.facts["b"].id)),
        )
    )
    e.assert_fact("node")
    e.run()
    assert hits == []


def test_facts_are_versioned_snapshots():
    e = Engine()
    fid = e.assert_fact("device", state="off")
    before = e.query("device")[0]
    e.modify_fact(fid, state="on")
    after = e.query("device")[0]
    assert before.version == 1 and before.value("state") == "off"
    assert after.version == 2 and after.value("state") == "on"
    assert before.id == after.id
    assert after.stamp > before.stamp


def test_retract_and_unknown_fact():
    e = Engine()
    fid = e.assert_fact("device", state="off")
    e.retract(fid)
    assert e.query("device") == []
    with pytest.raises(UnknownFactError):
        e.modify_fact(fid, state="on")


def test_reasserting_equal_values_is_a_new_fact():
    e = Engine()
    first = e.assert_fact("device", serial="a1")
    e.retract(first)
    second = e.assert_fact("device", serial="a1")
    assert first != second


def test_duplicate_rule_name_rejected():
    e = Engine()
    e.add_rule(rule("r", [Pattern("t", "x")], lambda f: None))
    with pytest.raises(DuplicateRuleError):
        e.add_rule(rule("r", [Pattern("t", "x")], lambda f: None))


def test_condition_requires_patterns_and_distinct_aliases():
    from src import Condition

    with pytest.raises(ValueError):
        Condition([])
    with pytest.raises(ValueError):
        Condition([Pattern("a", "x"), Pattern("a", "y")])


def test_absence_guard_blocks_and_allows():
    from src import Absent, Engine, Pattern, rule as mk

    e = Engine()
    hits = []
    e.add_rule(
        mk(
            "escalate",
            [Pattern("t", "ticket", sev="high"), Absent("alert", status="open")],
            lambda f: hits.append(f.facts["t"].id),
        )
    )
    blocked = e.assert_fact("ticket", sev="high")
    e.assert_fact("alert", status="open")
    e.run()
    assert hits == []
    e.retract(e.query("alert")[0].id)
    fresh = e.assert_fact("ticket", sev="high")
    e.run()
    # The entry sweep sees the guard clear; both tickets escalate now.
    assert sorted(hits) == sorted([blocked, fresh])


def test_absence_guard_joins_on_bound_variables():
    from src import Absent, Engine, Pattern, Var, rule as mk

    e = Engine()
    hits = []
    e.add_rule(
        mk(
            "remind",
            [
                Pattern("o", "order", customer=Var("c")),
                Absent("optout", customer=Var("c")),
            ],
            lambda f: hits.append(f.facts["o"].value("customer")),
        )
    )
    e.assert_fact("order", customer="acme")
    e.assert_fact("order", customer="zeta")
    e.assert_fact("optout", customer="zeta")
    e.run()
    assert hits == ["acme"]


def test_blocker_asserted_between_runs_stops_new_work():
    from src import Absent, Engine, Pattern, rule as mk

    e = Engine()
    hits = []
    e.add_rule(
        mk(
            "notify",
            [Pattern("t", "ticket"), Absent("mute")],
            lambda f: hits.append(f.facts["t"].id),
        )
    )
    first = e.assert_fact("ticket")
    e.run()
    e.assert_fact("mute")
    e.assert_fact("ticket")
    e.run()
    assert hits == [first]


def test_absence_guard_requires_bound_variables():
    import pytest
    from src import Absent, Condition, Pattern, Var

    with pytest.raises(ValueError):
        Condition([Pattern("t", "ticket")], [Absent("optout", customer=Var("c"))])
    Condition(
        [Pattern("t", "ticket", customer=Var("c"))],
        [Absent("optout", customer=Var("c"))],
    )
    with pytest.raises(ValueError):
        Condition([], [Absent("optout")])

path: tests/public/test_matching.py

Reference patch:
--- a/src/agenda.py
+++ b/src/agenda.py
@@ -16,7 +16,7 @@ from dataclasses import dataclass
 from typing import Dict, FrozenSet, List, Optional, Set, Tuple
 
 from .facts import Fact, WorkingMemory
-from .matcher import Match
+from .matcher import Match, guard_holds
 from .metrics import Metrics
 from .rules import RuleBook
 
@@ -91,20 +91,29 @@ class Agenda:
         fired.
         """
         self._metrics.delta_reconciles += 1
+        current = list(memory.all())
+        by_name = {r.name: r for r in rules.all()}
         survivors = []
         for act in self._queued:
-            if all(memory.contains(fid) for fid in fact_ids(act.justification)):
+            fresh = all(
+                memory.current_version(fid) == version
+                for fid, version in act.justification
+            )
+            if fresh and guard_holds(
+                by_name[act.rule_name].condition,
+                current,
+                act.bindings,
+                self._metrics,
+            ):
                 survivors.append(act)
             else:
                 self._metrics.withdrawals += 1
         queued_or_fired = {
-            (act.rule_name, fact_ids(act.justification)) for act in survivors
+            (act.rule_name, act.justification) for act in survivors
         }
-        queued_or_fired.update(
-            (name, fact_ids(just)) for name, just in self._fired
-        )
+        queued_or_fired.update(self._fired)
         for match in delta_matches:
-            key = (match.rule.name, fact_ids(match.justification()))
+            key = (match.rule.name, match.justification())
             if key in queued_or_fired:
                 continue
             queued_or_fired.add(key)
--- a/src/engine.py
+++ b/src/engine.py
@@ -37,23 +37,27 @@ class Firing:
         self.bindings = bindings
         self.touched: Set[str] = set()
         self.retracted: Set[str] = set()
+        self.touched_types: Set[str] = set()
 
     def assert_fact(self, fact_type: str, **attrs) -> str:
         fact = self._engine.memory.assert_fact(fact_type, **attrs)
         self._engine.metrics.facts_asserted += 1
         self.touched.add(fact.id)
+        self.touched_types.add(fact.type)
         return fact.id
 
     def modify_fact(self, fact_id: str, **attrs) -> None:
-        self._engine.memory.modify_fact(fact_id, **attrs)
+        fact = self._engine.memory.modify_fact(fact_id, **attrs)
         self._engine.metrics.facts_modified += 1
         self.touched.add(fact_id)
+        self.touched_types.add(fact.type)
 
     def retract(self, fact_id: str) -> None:
-        self._engine.memory.retract(fact_id)
+        fact = self._engine.memory.retract(fact_id)
         self._engine.metrics.facts_retracted += 1
         self.touched.discard(fact_id)
         self.retracted.add(fact_id)
+        self.touched_types.add(fact.type)
 
     def query(self, fact_type: str, **eq) -> List[Fact]:
         return self._engine.query(fact_type, **eq)
@@ -147,6 +151,16 @@ class Engine:
                     delta = matcher.match_touching(
                         self.rules, self.memory, self.metrics, firing.touched
                     )
+                    for guarded in self.rules.all():
+                        if any(
+                            absent.fact_type in firing.touched_types
+                            for absent in guarded.condition.absences
+                        ):
+                            delta.extend(
+                                matcher.match_rule(
+                                    guarded, self.memory, self.metrics
+                                )
+                            )
                     self._agenda.reconcile(
                         self.rules, self.memory, delta, firing.retracted
                     )
--- a/src/matcher.py
+++ b/src/matcher.py
@@ -77,6 +77,12 @@ def match_all(rules: RuleBook, memory: WorkingMemory, metrics: Metrics) -> List[
     return out
 
 
+def match_rule(rule: Rule, memory: WorkingMemory, metrics: Metrics) -> List[Match]:
+    """Every current match for one rule (guard transitions can change
+    a rule's match set without touching any of its positive facts)."""
+    return _matches_for(rule, list(memory.all()), metrics)
+
+
 def match_touching(
     rules: RuleBook,
     memory: WorkingMemory,


Hidden acceptance tests (diff):
diff --git a/tests/hidden/test_hidden.py b/tests/hidden/test_hidden.py
new file mode 100644
--- /dev/null
+++ b/tests/hidden/test_hidden.py
@@ -0,0 +1,766 @@
+"""Hidden acceptance suite for mid-drain modification semantics.
+
+Grades the agenda/engine contract stated across the module docs and
+the issue: every firing sees facts as they stand when it fires and
+fires only if its condition holds then; a modified fact withdraws work
+justified by the old version and joins as a new justification (per
+version — refraction included); renewed knowledge sorts by its new
+stamp; and the incremental match-work shape (one sweep per drain,
+deltas between firings) is preserved.
+
+The suite carries its own reference simulator — a semantics-only twin
+that recomputes matches from scratch before every firing with
+version-keyed refraction. It exists only here; the agent never sees
+it. Seeded sweeps compare the engine's firing trace against it across
+generated rule graphs.
+"""
+
+import random
+
+import pytest
+
+from src import Absent, Engine, Pattern, Var, rule
+from src import matcher
+from src.facts import WorkingMemory
+from src.metrics import Metrics
+from src.rules import RuleBook
+
+
+# -- reference simulator (hidden-side oracle) -----------------------------
+
+
+class RefFiring:
+    def __init__(self, sim, facts, bindings):
+        self._sim = sim
+        self.facts = facts
+        self.bindings = bindings
+
+    def assert_fact(self, fact_type, **attrs):
+        return self._sim.memory.assert_fact(fact_type, **attrs).id
+
+    def modify_fact(self, fact_id, **attrs):
+        self._sim.memory.modify_fact(fact_id, **attrs)
+
+    def retract(self, fact_id):
+        self._sim.memory.retract(fact_id)
+
+    def query(self, fact_type, **eq):
+        return [
+            f
+            for f in self._sim.memory.by_type(fact_type)
+            if all(f.value(k) == v for k, v in eq.items())
+        ]
+
+
+class ReferenceEngine:
+    """Correct-by-construction twin: full rematch before every firing."""
+
+    def __init__(self):
+        self.memory = WorkingMemory()
+        self.rules = RuleBook()
+        self.fired = set()
+        self.firings = []
+
+    def add_rule(self, r):
+        self.rules.add(r)
+
+    def assert_fact(self, fact_type, **attrs):
+        return self.memory.assert_fact(fact_type, **attrs).id
+
+    def modify_fact(self, fact_id, **attrs):
+        self.memory.modify_fact(fact_id, **attrs)
+
+    def retract(self, fact_id):
+        self.memory.retract(fact_id)
+
+    def _key(self, match):
+        return (
+            -match.rule.salience,
+            -match.newest_stamp(),
+            self.rules.registration_index(match.rule.name),
+            match.justification(),
+            tuple(sorted(match.facts)),
+        )
+
+    def run(self, max_firings=1000):
+        fired_now = 0
+        while True:
+            matches = matcher.match_all(self.rules, self.memory, Metrics())
+            live = [
+                m
+                for m in matches
+                if (m.rule.name, m.justification()) not in self.fired
+            ]
+            if not live:
+                return fired_now
+            if fired_now >= max_firings:
+                raise AssertionError("reference cascade did not quiesce")
+            best = min(live, key=self._key)
+            self.fired.add((best.rule.name, best.justification()))
+            self.firings.append(
+                (
+                    best.rule.name,
+                    {a: dict(f.attrs) for a, f in best.facts.items()},
+                    best.justification(),
+                )
+            )
+            best.rule.action(RefFiring(self, dict(best.facts), dict(best.bindings)))
+            fired_now += 1
+
+
+def engine_story(engine):
+    return [(r.rule, r.facts, r.justification) for r in engine.trace]
+
+
+# -- the cited faces -------------------------------------------------------
+
+
+def test_firing_uses_values_current_at_fire_time():
+    # Issue incident 1: a higher-salience rule updates severity; the
+    # notification that follows must quote the new value, and its trace
+    # justification must point at the post-update version.
+    e = Engine()
+    seen = []
+    e.add_rule(
+        rule(
+            "triage",
+            [Pattern("t", "ticket", sev="minor")],
+            lambda f: f.modify_fact(f.facts["t"].id, sev="critical"),
+            salience=10,
+        )
+    )
+    e.add_rule(
+        rule(
+            "notify",
+            [Pattern("t", "ticket")],
+            lambda f: seen.append(f.facts["t"].value("sev")),
+            salience=1,
+        )
+    )
+    fid = e.assert_fact("ticket", sev="minor")
+    e.run()
+    assert seen == ["critical"], seen
+    notify = [r for r in e.trace if r.rule == "notify"]
+    assert [r.justification for r in notify] == [((fid, 2),)]
+
+
+def test_withdrawn_condition_never_fires():
+    # Issue incident 2: the reminder's condition stopped holding before
+    # its turn; it must not fire at all.
+    e = Engine()
+    ghost = []
+    e.add_rule(
+        rule(
+            "closer",
+            [Pattern("t", "ticket", status="open")],
+            lambda f: f.modify_fact(f.facts["t"].id, status="closed"),
+            salience=10,
+        )
+    )
+    e.add_rule(
+        rule(
+            "reminder",
+            [Pattern("t", "ticket", status="open")],
+            lambda f: ghost.append("reminded"),
+            salience=1,
+        )
+    )
+    e.assert_fact("ticket", status="open")
+    fired = e.run()
+    assert ghost == [], ghost
+    assert fired == 1
+    assert e.metrics.withdrawals == 1
+
+
+def test_cascade_modification_renews_the_justification():
+    # A rule that already fired for version 1 must fire again when a
+    # later firing in the same drain modifies the fact — new version,
+    # new justification. (Between drains this already works.)
+    e = Engine()
+    seen = []
+    e.add_rule(
+        rule(
+            "scorer",
+            [Pattern("t", "ticket")],
+            lambda f: seen.append(f.facts["t"].value("score")),
+            salience=5,
+        )
+    )
+    e.add_rule(
+        rule(
+            "bumper",
+            [Pattern("t", "ticket", score=1)],
+            lambda f: f.modify_fact(f.facts["t"].id, score=99),
+            salience=1,
+        )
+    )
+    e.assert_fact("ticket", score=1)
+    fired = e.run()
+    assert seen == [1, 99], seen
+    assert fired == 3  # scorer v1, bumper, scorer v2 — and nothing more
+
+
+def test_renewed_justification_sorts_by_its_new_stamp():
+    # Renewal is new knowledge: at equal salience it outranks work
+    # justified by older facts.
+    e = Engine()
+    order = []
+    e.add_rule(
+        rule(
+            "refresh",
+            [Pattern("k", "kickoff")],
+            lambda f: f.modify_fact(f.query("ticket")[0].id, note="fresh"),
+            salience=10,
+        )
+    )
+    e.add_rule(
+        rule(
+            "on-ticket",
+            [Pattern("t", "ticket")],
+            lambda f: order.append(("ticket", f.facts["t"].value("note"))),
+            salience=1,
+        )
+    )
+    e.add_rule(
+        rule(
+            "on-user",
+            [Pattern("u", "user")],
+            lambda f: order.append(("user", None)),
+            salience=1,
+        )
+    )
+    e.assert_fact("ticket", note="stale")   # oldest knowledge
+    e.assert_fact("user")                   # newer
+    e.assert_fact("kickoff")
+    e.run()
+    # The renewed ticket is the newest knowledge in the drain: it fires
+    # before the user fact — and with the fresh note.
+    assert order == [("ticket", "fresh"), ("user", None)], order
+
+
+# -- deeper cascade topologies --------------------------------------------
+
+
+def test_modification_chain_settles_on_final_state():
+    e = Engine()
+    log = []
+    e.add_rule(
+        rule(
+            "stage-a",
+            [Pattern("x", "doc", stage="raw")],
+            lambda f: f.modify_fact(f.facts["x"].id, stage="parsed"),
+            salience=9,
+        )
+    )
+    e.add_rule(
+        rule(
+            "stage-b",
+            [Pattern("x", "doc", stage="parsed")],
+            lambda f: f.modify_fact(f.facts["x"].id, stage="indexed"),
+            salience=9,
+        )
+    )
+    e.add_rule(
+        rule(
+            "observer",
+            [Pattern("x", "doc")],
+            lambda f: log.append(f.facts["x"].value("stage")),
+            salience=1,
+        )
+    )
+    e.assert_fact("doc", stage="raw")
+    e.run()
+    # By the observer's turn the chain has settled: earlier versions
+    # were withdrawn before it ever fired, so it reports the final
+    # stage, exactly once.
+    assert log == ["indexed"], log
+
+
+def test_mutual_modification_pair_settles_before_observers():
+    e = Engine()
+    log = []
+    e.add_rule(
+        rule(
+            "ping",
+            [Pattern("a", "left", handed=False)],
+            lambda f: (
+                f.modify_fact(f.facts["a"].id, handed=True),
+                f.modify_fact(f.query("right")[0].id, poked=True),
+            ),
+            salience=9,
+        )
+    )
+    e.add_rule(
+        rule(
+            "pong",
+            [Pattern("b", "right", poked=True, answered=False)],
+            lambda f: f.modify_fact(f.facts["b"].id, answered=True),
+            salience=9,
+        )
+    )
+    e.add_rule(
+        rule(
+            "observe-left",
+            [Pattern("a", "left")],
+            lambda f: log.append(("left", f.facts["a"].value("handed"))),
+            salience=1,
+        )
+    )
+    e.add_rule(
+        rule(
+            "observe-right",
+            [Pattern("b", "right")],
+            lambda f: log.append(
+                ("right", f.facts["b"].value("poked"), f.facts["b"].value("answered"))
+            ),
+            salience=1,
+        )
+    )
+    e.assert_fact("left", handed=False)
+    e.assert_fact("right", poked=False, answered=False)
+    fired = e.run()
+    # The high-salience exchange settles first; each observer then
+    # fires exactly once, for the final version of its fact.
+    assert log == [("right", True, True), ("left", True)], log
+    assert fired == 4
+
+
+def test_two_writers_one_reader_sees_both_updates():
+    e = Engine()
+    seen = []
+    e.add_rule(
+        rule(
+            "price",
+            [Pattern("q", "quote", priced=False)],
+            lambda f: f.modify_fact(f.facts["q"].id, priced=True, price=120),
+            salience=9,
+        )
+    )
+    e.add_rule(
+        rule(
+            "risk",
+            [Pattern("q", "quote", risked=False)],
+            lambda f: f.modify_fact(f.facts["q"].id, risked=True, risk="low"),
+            salience=8,
+        )
+    )
+    e.add_rule(
+        rule(
+            "send",
+            [Pattern("q", "quote")],
+            lambda f: seen.append(
+                (f.facts["q"].value("price"), f.facts["q"].value("risk"))
+            ),
+            salience=1,
+        )
+    )
+    e.assert_fact("quote", priced=False, risked=False)
+    e.run()
+    # Both writers ran before the reader's turn; the reader fires once,
+    # for the fully-updated quote.
+    assert seen == [(120, "low")], seen
+
+
+def test_join_justification_renews_per_fact_version():
+    e = Engine()
+    seen = []
+    e.add_rule(
+        rule(
+            "amend",
+            [Pattern("o", "order", amended=False)],
+            lambda f: f.modify_fact(f.facts["o"].id, amended=True, qty=7),
+            salience=9,
+        )
+    )
+    e.add_rule(
+        rule(
+            "pair",
+            [
+                Pattern("o", "order", customer=Var("c")),
+                Pattern("p", "profile", customer=Var("c")),
+            ],
+            lambda f: seen.append(
+                (f.facts["o"].value("qty"), dict(f.facts["o"].attrs)["amended"])
+            ),
+            salience=1,
+        )
+    )
+    e.assert_fact("order", customer="acme", qty=1, amended=False)
+    e.assert_fact("profile", customer="acme")
+    e.run()
+    # The join must fire exactly once, for the amended order version.
+    assert seen == [(7, True)], seen
+
+
+def test_refraction_is_exact_per_version():
+    # After the renewal fires, the same version must not fire again —
+    # the drain reaches quiescence with exactly the expected firings.
+    e = Engine()
+    seen = []
+    e.add_rule(
+        rule(
+            "watch",
+            [Pattern("t", "ticket")],
+            lambda f: seen.append(f.facts["t"].version),
+            salience=5,
+        )
+    )
+    e.add_rule(
+        rule(
+            "toucher",
+            [Pattern("t", "ticket", touched=False)],
+            lambda f: f.modify_fact(f.facts["t"].id, touched=True),
+            salience=1,
+        )
+    )
+    e.assert_fact("ticket", touched=False)
+    fired_first = e.run()
+    fired_second = e.run()
+    assert seen == [1, 2], seen
+    assert fired_first == 3
+    assert fired_second == 0
+
+
+def test_unguarded_watcher_sees_every_version():
+    # A high-salience watcher with a broad pattern fires once per
+    # version of the fact as lower-salience rules evolve it: each
+    # modification renews the watcher's justification.
+    e = Engine()
+    seen = []
+    e.add_rule(
+        rule(
+            "watch",
+            [Pattern("c", "counter")],
+            lambda f: seen.append(f.facts["c"].value("n")),
+            salience=9,
+        )
+    )
+    e.add_rule(
+        rule(
+            "bump-a",
+            [Pattern("c", "counter", step_a=False)],
+            lambda f: f.modify_fact(f.facts["c"].id, n=1, step_a=True),
+            salience=1,
+        )
+    )
+    e.add_rule(
+        rule(
+            "bump-b",
+            [Pattern("c", "counter", step_a=True, step_b=False)],
+            lambda f: f.modify_fact(f.facts["c"].id, n=2, step_b=True),
+            salience=1,
+        )
+    )
+    e.assert_fact("counter", n=0, step_a=False, step_b=False)
+    fired = e.run()
+    assert seen == [0, 1, 2], seen
+    assert fired == 5
+
+
+def test_join_renews_after_it_already_fired():
+    e = Engine()
+    seen = []
+    e.add_rule(
+        rule(
+            "pair",
+            [
+                Pattern("o", "order", customer=Var("c")),
+                Pattern("p", "profile", customer=Var("c")),
+            ],
+            lambda f: seen.append(f.facts["o"].value("qty")),
+            salience=9,
+        )
+    )
+    e.add_rule(
+        rule(
+            "amend",
+            [Pattern("o", "order", amended=False)],
+            lambda f: f.modify_fact(f.facts["o"].id, amended=True, qty=7),
+            salience=1,
+        )
+    )
+    e.assert_fact("order", customer="acme", qty=1, amended=False)
+    e.assert_fact("profile", customer="acme")
+    e.run()
+    # The join fired for the original order, then again for the
+    # amended version: a renewed justification is new work even though
+    # the same rule already fired for these fact ids.
+    assert seen == [1, 7], seen
+
+
+def test_guard_violated_mid_drain_withdraws_the_work():
+    # Issue incident 3: the escalation is queued while no alert exists;
+    # a higher-salience rule raises one mid-drain. The guard no longer
+    # holds when escalation's turn comes — it must not fire.
+    e = Engine()
+    fired = []
+    e.add_rule(
+        rule(
+            "raise-alert",
+            [Pattern("t", "ticket", sev="high")],
+            lambda f: f.assert_fact("alert", status="open"),
+            salience=9,
+        )
+    )
+    e.add_rule(
+        rule(
+            "escalate",
+            [Pattern("t", "ticket", sev="high"), Absent("alert", status="open")],
+            lambda f: fired.append("escalated"),
+            salience=1,
+        )
+    )
+    e.assert_fact("ticket", sev="high")
+    e.run()
+    assert fired == [], fired
+
+
+def test_guard_satisfied_mid_drain_activates_the_rule():
+    # The mirror case: the guard is blocked at entry; a higher-salience
+    # rule retracts the blocker mid-drain. The guarded rule's condition
+    # now holds — it must activate and fire in this drain.
+    e = Engine()
+    fired = []
+    e.add_rule(
+        rule(
+            "resolve",
+            [Pattern("a", "alert", status="open")],
+            lambda f: f.retract(f.facts["a"].id),
+            salience=9,
+        )
+    )
+    e.add_rule(
+        rule(
+            "followup",
+            [Pattern("t", "ticket"), Absent("alert", status="open")],
+            lambda f: fired.append(f.facts["t"].id),
+            salience=1,
+        )
+    )
+    tid = e.assert_fact("ticket")
+    e.assert_fact("alert", status="open")
+    e.run()
+    assert fired == [tid], fired
+
+
+def test_modify_can_flip_a_guard_both_ways():
+    # A guard watches an attribute: modifying the blocking fact can
+    # satisfy the guard (resolve the alert) for one rule and violate a
+    # different guard at the same moment.
+    e = Engine()
+    log = []
+    e.add_rule(
+        rule(
+            "resolve-alert",
+            [Pattern("a", "alert", resolved=False)],
+            lambda f: f.modify_fact(f.facts["a"].id, resolved=True),
+            salience=9,
+        )
+    )
+    e.add_rule(
+        rule(
+            "all-clear",
+            [Pattern("t", "ticket"), Absent("alert", resolved=False)],
+            lambda f: log.append("clear"),
+            salience=1,
+        )
+    )
+    e.add_rule(
+        rule(
+            "still-hot",
+            [Pattern("t", "ticket"), Absent("alert", resolved=True)],
+            lambda f: log.append("hot"),
+            salience=1,
+        )
+    )
+    e.assert_fact("ticket")
+    e.assert_fact("alert", resolved=False)
+    e.run()
+    # After the resolve: "all-clear"'s guard is satisfied (activates),
+    # "still-hot"'s guard is violated (its queued work is withdrawn).
+    assert log == ["clear"], log
+
+
+def test_guard_join_tracks_the_bound_customer():
+    e = Engine()
+    fired = []
+    e.add_rule(
+        rule(
+            "flag-acme",
+            [Pattern("k", "kick")],
+            lambda f: f.assert_fact("optout", customer="acme"),
+            salience=9,
+        )
+    )
+    e.add_rule(
+        rule(
+            "remind",
+            [
+                Pattern("o", "order", customer=Var("c")),
+                Absent("optout", customer=Var("c")),
+            ],
+            lambda f: fired.append(f.facts["o"].value("customer")),
+            salience=1,
+        )
+    )
+    e.assert_fact("order", customer="acme")
+    e.assert_fact("order", customer="zeta")
+    e.assert_fact("kick")
+    e.run()
+    # Only acme's reminder is withdrawn by the mid-drain optout.
+    assert fired == ["zeta"], fired
+
+
+# -- the operational boundary (fused with semantics) -----------------------
+
+
+def test_incremental_match_shape_holds_through_a_modify_cascade():
+    # Semantics and the metered work shape, in one scenario: a chain of
+    # per-fact modifications must land the right final states AND stay
+    # on the incremental path — one entry sweep, delta work per firing.
+    e = Engine()
+    finals = []
+    e.add_rule(
+        rule(
+            "promote",
+            [Pattern("j", "job", state="queued")],
+            lambda f: f.modify_fact(f.facts["j"].id, state="running"),
+            salience=9,
+        )
+    )
+    e.add_rule(
+        rule(
+            "finish",
+            [Pattern("j", "job", state="running")],
+            lambda f: f.modify_fact(f.facts["j"].id, state="done"),
+            salience=8,
+        )
+    )
+    e.add_rule(
+        rule(
+            "report",
+            [Pattern("j", "job")],
+            lambda f: finals.append(
+                (f.facts["j"].id, f.facts["j"].value("state"))
+            ),
+            salience=1,
+        )
+    )
+    ids = [e.assert_fact("job", state="queued") for _ in range(12)]
+    e.run()
+    # Every job settled before the reporter's turn: one report per job,
+    # each for the final state.
+    assert sorted(finals) == sorted((i, "done") for i in ids)
+    assert all(f.value("state") == "done" for f in e.query("job"))
+    m = e.metrics
+    assert m.drain_sweeps == 1, "a drain does one entry sweep, not one per firing"
+    # Bound calibrated on the incremental fix; rebuilding the agenda
+    # per firing costs an order of magnitude more at this size.
+    assert m.matches_evaluated <= 9000, m.matches_evaluated
+
+
+# -- seeded sweeps against the reference simulator --------------------------
+
+
+TYPES = ["ticket", "order", "device", "doc"]
+
+
+def next_type(f):
+    # Deterministic "next type": derived from the matched fact, not from
+    # shared random state, so both engines make the same choice.
+    return TYPES[(f.facts["x"].value("depth") or 0) % len(TYPES)]
+
+
+def build_specs(rng):
+    specs = []
+    for i in range(rng.randrange(5, 9)):
+        template = rng.choice(
+            ["promote", "spawn", "consume", "link", "audit", "watch", "guarded"]
+        )
+        ftype = rng.choice(TYPES)
+        salience = rng.randrange(0, 4)
+        specs.append((template, ftype, salience, i))
+    # Guarantee the collision-bearing shapes this suite exists to
+    # grade: a broad passive watcher, a broad bookkeeper, a modifier,
+    # and an absence-guarded observer.
+    specs.append(("watch", rng.choice(TYPES), rng.randrange(2, 5), 89))
+    specs.append(("audit", rng.choice(TYPES), rng.randrange(0, 4), 90))
+    specs.append(("promote", rng.choice(TYPES), rng.randrange(0, 4), 91))
+    specs.append(("guarded", rng.choice(TYPES), rng.randrange(0, 4), 92))
+    return specs
+
+
+def instantiate(specs, engine):
+    for template, ftype, salience, i in specs:
+        name = f"{template}-{ftype}-{i}"
+        if template == "promote":
+            action = lambda f: f.modify_fact(f.facts["x"].id, stage="cooked")
+            patterns = [Pattern("x", ftype, stage="raw")]
+        elif template == "spawn":
+            action = lambda f: f.assert_fact(
+                next_type(f),
+                stage="raw",
+                depth=(f.facts["x"].value("depth") or 0) + 1,
+                seen=False,
+            )
+            patterns = [
+                Pattern("x", ftype, stage="cooked", depth=lambda d: (d or 0) < 2)
+            ]
+        elif template == "consume":
+            action = lambda f: f.retract(f.facts["x"].id)
+            patterns = [Pattern("x", ftype, stage="spent")]
+        elif template == "link":
+            action = lambda f: f.modify_fact(f.facts["x"].id, seen=True)
+            patterns = [Pattern("x", ftype, stage="cooked", seen=False)]
+        elif template == "audit":  # broad, self-quiescing bookkeeping write
+            action = lambda f: f.modify_fact(f.facts["x"].id, audited=True)
+            patterns = [Pattern("x", ftype, audited=False)]
+        elif template == "watch":  # broad passive observer — renewal-sensitive
+            action = lambda f: None
+            patterns = [Pattern("x", ftype)]
+        else:  # guarded: passive observer with an absence guard on
+            # spent facts of its type — transition-sensitive both ways
+            action = lambda f: None
+            patterns = [Pattern("x", ftype), Absent(ftype, stage="spent")]
+        engine.add_rule(rule(name, patterns, action, salience=salience))
+
+
+def seed_facts(rng, engine):
+    for _ in range(rng.randrange(4, 9)):
+        engine.assert_fact(
+            rng.choice(TYPES),
+            stage=rng.choice(["raw", "cooked", "spent"]),
+            depth=0,
+            seen=False,
+            audited=False,
+        )
+
+
+@pytest.mark.parametrize("seed", [7, 19, 41, 73])
+def test_seeded_cascades_match_the_reference(seed):
+    rng = random.Random(seed)
+    specs = build_specs(rng)
+    fact_state = rng.getstate()
+
+    live = Engine()
+    instantiate(specs, live)
+    rng.setstate(fact_state)
+    seed_facts(rng, live)
+
+    ref = ReferenceEngine()
+    instantiate(specs, ref)
+    rng.setstate(fact_state)
+    seed_facts(rng, ref)
+
+    live.run()
+    ref.run()
+    assert engine_story(live) == ref.firings
+
+    # Second drain: top-level churn, then compare the cumulative story.
+    for target in list(live.query("ticket"))[:2]:
+        live.modify_fact(target.id, stage="raw")
+    for target in list(ref.memory.by_type("ticket"))[:2]:
+        ref.modify_fact(target.id, stage="raw")
+    live.run()
+    ref.run()
+    assert engine_story(live) == ref.firings