Skip to content

feat: invalidate stale downstream outputs after fork changes #33

Open

Description

@utsab345

Problem

When a fork changes an upstream tool or model output, the downstream recorded responses in the retained portion of the forked trace may no longer correspond to the modified execution path. Reusing those responses without clearly identifying their validity can make a fork misleading.

Concretely, in the current implementation:

  • ForkEngine keeps every event with step <= fork_point and can override a tool_result or llm_response anywhere in that prefix (see src/stepfork/fork/engine.py and src/stepfork/fork/overrides.py). Events recorded after the overridden step but at or before the fork point are copied unchanged into the forked trace, even though their outputs were produced by the original upstream state.
  • ReplaySession builds its call sequence from whatever trace it is given and substitutes a recorded output whenever the next call matches by order, identity, and input fingerprint (see src/stepfork/replay/session.py). It has no concept of a parent trace, a fork point, or an applied override, and it never checks whether an upstream event was modified before reusing a downstream recorded output.
  • The only classification StepFork tracks today is substituted vs executed per call (MatchedCall.action / ExecutedCall.action). There is no stale or invalid state, and no marker on events or in the manifest records that a recorded output was produced under an upstream state that a fork has since changed.
  • docs/fork.md states this explicitly: fork currently "does not detect divergence" and "divergence detection requires replay integration (planned)".

In practice this means a fork that changes an early output can still silently reuse downstream recorded outputs (LLM responses, tool results, and the final output comparison) that were captured under the original upstream values. The replay may even pass, giving the false impression that the modified execution path was actually observed. When inputs do happen to change, StepFork raises a replay mismatch, but that is a symptom of the agent behaving differently, not a deliberate classification of which recorded outputs are still trustworthy.

Scope

  • Investigate how StepFork currently identifies divergence between an original trace and a fork. Establish what signals already exist (fork provenance in ForkInfo, override application, replay matching) and what is missing.
  • Define how downstream recorded outputs should be classified when an upstream change may invalidate them. At minimum the distinction between these three cases must become explicit wherever the implementation supports surfacing it:
    1. Reused recorded output - substituted from the recording, produced under the same upstream state that the current trace reflects.
    2. Potentially stale output - substituted from the recording, but an upstream tool or LLM output was changed (e.g. by a fork override) after it was recorded, so it is not evidence for the modified execution path.
    3. Freshly executed output - actually run during replay (live mode / live LLM in hybrid mode).
  • Document the expected behavior when fresh execution is unavailable or disabled (frozen replay, no allow_live_tools), so users know what a substituted stale output does and does not prove.
  • Preserve existing trace compatibility where practical. Any new metadata should be additive and optional; existing traces must continue to load, validate, and replay unchanged.
  • Treat automatic re-execution and dependency-aware invalidation (computing exactly which downstream events an upstream change affects) as open design questions to investigate, not as requirements. Do not assume or claim that StepFork already performs either.

Keep this issue focused on replay correctness after divergence. It is separate from instrumentation coverage reporting (#30) and from retrieval evidence capture (#31).

Acceptance criteria

  • Regression tests cover a fork that changes an early recorded output and then encounters downstream recorded calls during replay, exercising both the case where downstream inputs still match (stale output would be substituted silently today) and the case where they diverge.
  • Potentially stale outputs cannot be mistaken for verified results from the modified execution path. Reuse of a recorded output after an upstream change is either flagged, classified, or refused, rather than indistinguishable from a normal substitution.
  • The distinction between reused, potentially stale, and freshly executed outputs is clearly communicated in the user-facing surface that the design chooses (replay summary, CLI report, or documented API), where the implementation supports it.
  • Behavior and limitations are documented, including what happens when fresh execution is unavailable.
  • Existing traces and the existing substituted/executed semantics keep working; any format extension is additive and justified.
  • Existing tests are not weakened. New tests are offline and deterministic.

Testing

Add deterministic, offline regression tests for the fork-then-replay scenario described above, plus unit coverage for whatever classification or marker the design introduces. Run the focused tests and the full suite (uv run pytest), and confirm ruff check, ruff format --check, and mypy stay clean.

This needs a design decision before implementation. Please comment with a proposed approach (classification at fork time vs. at replay time, and how it is surfaced) before opening a PR.

Contributions are welcome.

Activity

  1. how2how2how2-arch commented on Oct 28, 2026

    @how2how2how2-arch
    Contributor

    Read the code paths this issue names, on main after #37. One correction to the framing first, then an answer to the question you asked.

    The blocker is upstream of replay. ForkEngine.fork() builds a complete ForkInfo, and then it is dropped: fork_trace() keeps only fork_point_step, fork_point_event_id and overrides_applied in ForkResult, and TraceManifest has no field to receive the rest (trace/manifest.py: extra="forbid", schema_version: Literal["0.1"]; storage.build_manifest() fills the manifest from trace fields only). So a saved fork is not merely "unlabelled by replay" — it is indistinguishable from a plain recording. The parent run id, the fork point and the override set are discarded at the moment the bundle is written, and no replay-side change can recover them.

    Two details from that path:

    • overrides_applied is a count, and the classification needs the set. "The output at step 7 was recorded under an upstream state that changed at step 2" requires knowing which steps were overridden; no arithmetic on a count produces that.
    • ForkInfo.parent_trace_path is a filesystem path, which is not portable inside a bundle. What a bundle can carry is a digest of the parent.

    Also worth noting: OverrideRegistry.apply_override() preserves id and step and replaces only output, so an overridden event is identical in identity to the event it replaced. Diffing parent against fork shows the value and nothing about why it differs.

    Proposal: persist at fork time, classify at replay time, as a pure function of the persisted data.

    1. Additive provenance under fork: in the manifest — parent_run_id, parent_trace_sha256, fork_point_step, fork_point_event_id, fork_timestamp, and overrides: [{step, event_id, target, replaced_output_sha256}]. Digest rather than value: the overridden value is already in the event; what the bundle cannot otherwise name is which outputs were replaced, and the digest of the parent's original is what makes the record checkable rather than asserted.
    2. ForkResult keeps overrides_applied: int — nothing that reads it breaks — and gains the tuple.
    3. Replay derives evidence per call from that record alone, with no new I/O:
    executed           the value was produced live (existing action == "executed")
    recorded           substituted, and no override sits at a step < this call's step
    potentially-stale  substituted, and some override sits at a step < this call's step
    unknown            the bundle is a fork but names no override set
    

    fork is None gives recorded, so every bundle written today replays exactly as it does now.

    Two axes, not three states. MatchedCall.action answers how the value was obtained; evidence answers whether the value is evidence for this path. A call can be executed while its inputs were derived from a stale value — those two facts do not fit in one enum, and collapsing them means one of the two can never be reported. Keep action a two-valued Literal and add a defaulted field beside it. (I have been bitten the other way round: one status cell holding two facts makes an unrelated timeout look like a refutation.)

    The error is one-sided, deliberately. With no dataflow, "after the earliest override" over-approximates dependency. Name the field so it says that: a value may depend on a changed upstream value, not was affected by one. The direction is the point — it can over-report stale and never under-report, which is what makes the label shippable before dependency analysis exists rather than a guess that occasionally blesses a stale output.

    The forward-compat question you have to decide, not the PR. extra="forbid" plus Literal["0.1"] means "additive and optional" is still a versioned change: an older StepFork reading a new fork bundle will refuse it instead of ignoring the new key. Either bump to 0.2 with a loader that accepts both, or accept the break explicitly. I would rather the issue state which than have a PR imply one of the two.

    Where to surface it. Silent substitution is survivable on its own. What creates the false impression is the receipt saying substituted and the diff passing — that reads as "the modified path was observed". So evidence belongs on the receipt and diff verdict: if any compared output is potentially-stale, the verdict says so and names the override steps responsible. Refusal does not have to be the default; keep the tool usable and make the claim narrow.

    Tests. I did not run the suite — git transport to github.com is failing from this machine right now, so treat the list below as a proposal, not a green run.

    • One fork that overrides step 1, then replays a substitution at step 0 and another at step 2, asserting recorded and potentially-stale respectively. A fixture with only post-override calls cannot separate those two units, and a guard whose fixtures cannot separate the units it confuses will pass while measuring the wrong scope.
    • Round-trip: fork → save → load → provenance identical; and a bundle with no fork key loads and replays with everything recorded, so backward compatibility is a test rather than a claim.
    • Plant: rewrite the persisted override set to [] in a saved fork and assert the downstream classification flips. That proves the classifier reads the provenance instead of recomputing the answer from the events.
    • The existing changed-args path must still raise ReplayMismatchError; the new axis must not absorb it.

    Not claiming: no re-execution, no dependency-aware invalidation, no "fork detects divergence". docs/fork.md keeps its limitation line as written — the label describes what a value is evidence for, and says nothing about what ran.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requesttestingTests and test coverage

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions