ADR-0024: Which chain a firing joins, and operator actions in the log¶
Status: Accepted Date: 2026-09-28 Deciders: Alex Nodeland
Context¶
A causal chain (the correlation_id on every envelope) is a trace session in Langfuse and the unit of chain feedback and chain evaluation (RFC-0002). An event nothing caused starts a chain; an event a run emits continues its run's chain. What was undecided is which chain a firing, and so its run, belongs to, when a rule matched several envelopes from different chains: three errors from three sources, a deploy followed by an alert, or an absence noticed at a tick.
Separately, RFC-0002's operator intervention rate (the share of runs a person retried, skipped or cancelled) needs every intervention in the log, with the actor who made it. Skipping and cancelling appended run_skipped and run_cancelled; retrying by hand appended nothing.
Decision¶
- A firing joins the chain of the latest envelope it matched. Core records the chain on each
Match, soFiring.correlation_idis decided byevaluateand pinned by conformance fixtures, andRun.correlation_idcopies it. An absence firing joins the chain of the last envelope the rule saw, not the tick that passed its deadline. - Run operations and feedback join the run's chain, so they appear in its session. Chain feedback joins the chain it is about.
- Retrying a run by hand appends
run_requeued, attributed to whoever did it.core.retryreturns it, like every other transition.
Amendment (2026-09-29): a chain is joined by its first event's id¶
- A
correlation_ida client gives must name the start of a chain: an event whose envelope'scorrelation_idis its own id. Publishing with the id of a later event of a chain, and feedback on aChainTargetwith one, is rejected withvalidation_failed, and the message names the chain that event belongs to, so the client can correct it. Accepting any existing event id made a new chain named by an event that was already in another, split the session in two, and left chain feedback about a chain that did not exist. An id not in the log is stillnot_found. - The check runs before a publish's ids are looked up, so retrying a publish with such a
correlation_idis rejected too.errorsis empty, as for the other checks that are not Pydantic's.
Options considered¶
Option A: The latest matched envelope's chain (chosen)¶
| Dimension | Assessment |
|---|---|
| Complexity | Low: one field on Match, decided in core |
| Predictability | High: the envelope that completed the match |
Pros: the chain that made the condition hold continues; absence follows the thing that went quiet; decided once, in core, and stored on the run.
Cons: the earlier matched envelopes' chains do not include the run, though the firing's matched seqs still point to them.
Option B: The earliest matched envelope's chain¶
Pros: follows the first cause. Cons: for a sequence (a deploy, then errors) the run joins the deploy's chain, not the alert's; a long-running count keeps joining an old chain.
Option C: A new chain per firing¶
Pros: simple. Cons: breaks the session: a person's report and the agent it triggered would be separate sessions.
Option D: Derive the chain in the host from the envelope at fired_seq¶
Pros: no core change. Cons: wrong for absence (the tick's chain), and every reader of a run would re-read the log.
Trade-off analysis¶
Option A is the only one that is right for all four patterns and keeps the decision in the pure core, where the fixtures specify it. The run carries its chain, so admin events, spans and feedback need no extra reads.
Consequences¶
- Easier: a Langfuse session shows everything one incident led to, including operators' actions and feedback.
- Easier: the intervention rate is a query over
run_requeued,run_skippedandrun_cancelledby actor kind. - Harder:
Matchstates are slightly larger (one string per match held).
Action items¶
-
Match.correlation_id,Firing.correlation_id,Run.correlation_id, andchains.jsonconformance cases. -
run_requeuedandcore.retryreturning it. - The reactor appends
rule_firedin the firing's chain (phase 2c).