What invariants prevent delayed feedback from being misinterpreted as independent events?
**Date of Record:** 2026 October 27 **Scope:** Applies when an agentic session takes actions whose effects manifest after a delay, and during the delay, other events occur that may be causally related to the original action but are observed as independent, requiring the session to attribute effects correctly despite temporal separation. Does not apply to sessions with immediate feedback or actions whose effects are strictly instantaneous.
Core Distinction
The separation between **temporal independence** (events occur at separate times) and **causal independence** (events are not causally related). Relying on temporal separation creates the **Delayed Causality Blindness**: the session's action at time T1 produces an effect at time T3, but the session observes an event at T2 (between action and effect) as independent, and when the effect appears at T3, attributes it to the T2 event rather than its own T1 action.
What Was Observed
In delayed-feedback agent architectures, failing to preserve causal attribution across temporal separation produces three primary failure modes:
- **Misattribution to Coincidence:** The session deploys a config change at T1, observes a user complaint at T2 (unrelated), and observes service degradation at T3 (caused by T1). The session attributes the degradation to the user complaint or to the config change based on temporal proximity, not causality.
- **Confounder Blindness:** The session's action at T1 causes effect E at T3, but a confounder F also occurred at T1 (external event). The session attributes E to F because F is more salient, missing its own contribution.
- **Compound Attribution Failure:** Multiple actions at T1, T2, T3 each cause delayed effects that overlap. The session cannot attribute the aggregate effect to specific actions because the temporal windows overlap.
Recorded Elements
- **`action-effect-delay-receipt`:** For each action, the expected delay before its effects manifest, so observers know when to look for consequences.
- **`intervening-events-log-signature`:** A log of events that occurred during the action's delay window, with cryptographic timestamps, so intervening events can be correlated with the eventual effect.
- **`causal-attribution-receipt`:** For each observed effect, the attribution (session action, external cause, or coincidence) with the evidence supporting the attribution.
- **`confounder-detection-signature`:** For periods with concurrent events, the identified potential confounders, so attributions account for alternative causes.
Condition of Validity
* **Applies when:** The session's actions have delayed effects, intervening events occur, and attribution matters for downstream decisions. * **Does not apply when:** Effects are strictly immediate, or attribution is not required.
Decision Boundary
- **Do the action's effects manifest after a delay?** If no, standard attribution applies. If yes, proceed.
- **Are intervening events tracked (`intervening-events-log-signature`)?** If yes, the events during the delay are known. If no, attribution is confounded by unrecorded events.
- **Does the attribution account for confounders?** If yes, the attribution is principled. If no, attribution may be spurious.
- **Conversely, when can an effect be attributed to an action without confounder analysis?** Only when the effect's timing is strictly within the action's causal window AND no intervening events occurred that could plausibly cause the effect.
Operational Effect
- **Delayed Attribution Correctness:** Effects are attributed to their actual causes, not to temporally proximate events.
- **Confounder Awareness:** Alternative causes are explicitly considered.
- **Compound Attribution:** Overlapping effects are attributed correctly.
from dataclasses import dataclass
from enum import Enum, auto
import hashlib
import json
class DelayedAttributionVerdict(Enum):
IMMEDIATE_EFFECT = auto()
ATTRIBUTION_VERIFIED = auto()
CONFOUNDER_DETECTED = auto()
ATTRIBUTION_AMBIGUOUS = auto()
@dataclass(frozen=True)
class DelayedAction:
action_id: str
action_epoch: float
expected_effect_epoch: float
effect_window_sec: float
@dataclass(frozen=True)
class ObservedEffect:
effect_id: str
observed_epoch: float
effect_description: str
candidate_causes: tuple[str, ...] # action_ids or "EXTERNAL"
@dataclass(frozen=True)
class AttributionState:
action: DelayedAction
effect: ObservedEffect
intervening_events_documented: bool
confounders_identified: bool
@dataclass(frozen=True)
class AttributionPolicy:
require_confounder_analysis_for_medium_plus: bool
is_medium_plus_stakes: bool
@dataclass(frozen=True)
class AttributionManifest:
verdict: DelayedAttributionVerdict
attributed_to: str | None
directive: str
manifest_digest: str
class DelayedFeedbackGuard:
@staticmethod
def _hash(data: str) -> str:
return hashlib.sha256(data.encode("utf-8")).hexdigest()
@classmethod
def evaluate_attribution(
cls, state: AttributionState, policy: AttributionPolicy
) -> AttributionManifest:
# Gate 1: Immediate effect
if state.effect.observed_epoch <= state.action.action_epoch + state.action.effect_window_sec:
digest = cls._hash(json.dumps({
"action": state.action.action_id,
"effect": state.effect.effect_id
}, sort_keys=True))
return AttributionManifest(
verdict=DelayedAttributionVerdict.IMMEDIATE_EFFECT,
attributed_to=state.action.action_id,
directive=f"PROCEED: Effect {state.effect.effect_id} within immediate window.",
manifest_digest=digest
)
# Gate 2: Confounder requirement
if policy.is_medium_plus_stakes and policy.require_confounder_analysis_for_medium_plus:
if not state.confounders_identified:
return AttributionManifest(
verdict=DelayedAttributionVerdict.ATTRIBUTION_AMBIGUOUS,
attributed_to=None,
directive=f"HALT: Medium-plus stakes effect {state.effect.effect_id} requires confounder analysis.",
manifest_digest=cls._hash(f"NO_CONFOUNDER_ANALYSIS:{state.effect.effect_id}")
)
# Gate 3: Single candidate cause
if len(state.effect.candidate_causes) == 1:
digest = cls._hash(json.dumps({
"action": state.action.action_id,
"effect": state.effect.effect_id,
"cause": state.effect.candidate_causes[0]
}, sort_keys=True))
return AttributionManifest(
verdict=DelayedAttributionVerdict.ATTRIBUTION_VERIFIED,
attributed_to=state.effect.candidate_causes[0],
directive=f"PROCEED: Effect {state.effect.effect_id} attributed to {state.effect.candidate_causes[0]}.",
manifest_digest=digest
)
# Gate 4: Multiple candidates
if state.action.action_id in state.effect.candidate_causes:
return AttributionManifest(
verdict=DelayedAttributionVerdict.CONFOUNDER_DETECTED,
attributed_to=None,
directive=f"HALT: Multiple candidate causes {state.effect.candidate_causes}, cannot attribute unambiguously.",
manifest_digest=cls._hash(f"CONFOUNDED:{state.effect.effect_id}")
)
# Gate 5: Not our effect
digest = cls._hash(json.dumps({
"action": state.action.action_id,
"effect": state.effect.effect_id,
"candidates": state.effect.candidate_causes
}, sort_keys=True))
return AttributionManifest(
verdict=DelayedAttributionVerdict.ATTRIBUTION_AMBIGUOUS,
attributed_to=None,
directive=f"PROCEED: Effect {state.effect.effect_id} not attributable to {state.action.action_id}.",
manifest_digest=digest
)