fix(cli): the report predicted a tier the run had already decided - #345
Merged
Conversation
#341 made the run's containment tier win over the probe's, and `check_egress` has honoured that since. The REPORT did not. It called `containment(spec)` before the run started and printed that, then reused it for the `--json` payload. On Linux the two agree by construction, so this was invisible. On Windows the run is the authority — seven steps can fail after the probe passes — so a run that WAS contained printed "not contained" beside a trace lane saying the opposite. That is worse than either being wrong on its own. An auditor holding two artifacts that describe one run differently has no way to tell which is lying, and the artifact that is right is the one nobody printed. So `record` and `replay` return the tier they achieved alongside their outcome, and the report prefers it. The tier leaves by out-parameter because it becomes known part way through: every `?` before that point would otherwise have to name a tier it does not have yet. A run that failed before it started reports the prediction, which is the honest answer for a run that achieved nothing. `achieved_tier` is one function used by both paths. The certification path and the reporting path computing this separately is how they came to disagree in the first place. The corrected line prints only when the run's answer DIFFERS from the prediction, so nothing changes on Linux and no output grows a line for a distinction that platform does not have. Not fixed here, and it is the same defect: the run record at `build_control_record` still stores the predicted tier. It is built where the run is not in scope, which is a larger change than this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XdXrbksFKirm7yW6EDunur
There was a problem hiding this comment.
Pull request overview
Fixes the CLI’s containment reporting for app: agent flows so stdout and --json reflect the tier actually achieved by the run (when the run determines one), rather than only the pre-run probe prediction. This aligns reporting with the certification/verdict path (check_egress) and prevents a single run producing conflicting artifacts.
Changes:
- Change
agent_flow::record/agent_flow::replayto return(Containment, Result<(), String>), carrying the achieved tier alongside the run outcome. - Introduce
achieved_tier(run, spec_tier)as the shared rule for “report what the run achieved, otherwise fall back to the prediction”. - Update CLI command paths to print the predicted tier first, and reprint only if the run’s achieved tier differs;
--jsonalways includes the achieved tier.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| crates/flowproof-cli/src/lib.rs | Updates record/run/suite agent-flow paths to print/report the achieved containment tier (with minimal extra stdout). |
| crates/flowproof-cli/src/agent_flow.rs | Adds achieved-tier computation and changes record/replay APIs to return achieved tier + outcome; adds regression tests. |
Suppressed comments (2)
crates/flowproof-cli/src/lib.rs:721
- Same as the single-run path:
predictedis computed here for printing, butagent_flow::replayalso computescontainment(spec)internally, duplicating host probes and making thetier != predictedcomparison depend on two independently constructed values. Consider plumbing the precomputedpredictedintoreplayso the suite path and replay logic stay strictly consistent.
let predicted = agent_flow::containment(spec);
if !json {
println!("{}", predicted.report_line());
}
let (tier, outcome) = agent_flow::replay(spec, trace_path);
if !json && tier != predicted {
crates/flowproof-cli/src/lib.rs:1303
- This path computes
predictedviacontainment(&spec)and then callsagent_flow::replay, which recomputescontainment(spec)internally. That duplicates the Windows host probe and makestier != predictedcompare values built by different probe calls. Consider passing the already-computedpredictedintoreplayto avoid redundant probing and guarantee the comparison reflects only run-vs-probe differences.
let predicted = agent_flow::containment(&spec);
if !json {
println!("{}", predicted.report_line());
}
let (tier, outcome) = agent_flow::replay(&spec, &trace_path);
if !json && tier != predicted {
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+843
to
+845
| pub fn achieved_tier(run: &AgentRun, spec_tier: &Containment) -> Containment { | ||
| run.containment.clone().unwrap_or_else(|| spec_tier.clone()) | ||
| } |
Comment on lines
+301
to
+305
| let predicted = agent_flow::containment(&spec); | ||
| if !json { | ||
| println!("{}", predicted.report_line()); | ||
| } | ||
| let (tier, outcome) = agent_flow::record(&spec, &out); |
AminChirazi
added a commit
that referenced
this pull request
Aug 1, 2026
The last of the three places that read a predicted tier instead of the achieved one. #345 fixed the printed report and the JSON; the run record — the artifact an auditor actually keeps — still called `agent_flow::containment(spec)` for itself. This one costs more than a wrong label. `containment` is read twice in `build_control_record`: once as the record's own tier line, and once to decide whether the blocked lane is evidence at all. So the two tiers disagreeing decides whether the destinations a run refused are carried or discarded — in either direction. An optimistic probe over a run that was not contained would present destinations nothing on this run blocked; a pessimistic one over a run that was contained would throw away the only proof it produced. So the achieved tier is passed in, `None` for a step-engine flow, which has no agent run to ask. The test asserts the PESSIMISTIC direction, and that is a deliberate retreat. The interesting production case is the opposite one — a Windows run that was contained, over a probe predicting otherwise — and it cannot be made falsifiable here. This suite runs on Linux, where the probe already answers Enforced, so a test asserting "the achieved Enforced won" passes identically when the achieved tier is ignored altogether. It was written that way first and it survived the mutation, which is the definition of a test that proves nothing. Turned around it pins the half that can fail here, and it is the safety-critical half: an uncontained run must not inherit an optimistic probe's evidence. Mutation-checked in that direction. The fixture guard on the blocked lane earned its keep again — the first draft wrote the cassette in the wrong shape, the lane failed to parse, and without the guard the emptiness assertion would have passed for the wrong reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XdXrbksFKirm7yW6EDunur
AminChirazi
added a commit
that referenced
this pull request
Aug 1, 2026
Records #345 and #347, which are user-visible and unreleased. The Windows containment work they came out of is deliberately NOT written up here: no Windows job has been confirmed to run its end-to-end test, and an entry claiming a mechanism nobody has watched work is the kind of sentence this file exists not to contain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XdXrbksFKirm7yW6EDunur
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The behaviour half of the defect #344 documented.
#341 made the run's containment tier win over the probe's, and
check_egresshas honoured that since. The report did not. It calledcontainment(spec)before the run started, printed that, and reused it for the--jsonpayload.On Linux the two agree by construction, so this was invisible. On Windows the run is the authority — seven steps can fail after the probe passes — so a run that was contained printed
not containedbeside a trace lane saying the opposite.That is worse than either being wrong alone. An auditor holding two artifacts that describe one run differently has no way to tell which is lying, and the artifact that is right is the one nobody printed.
The shape
recordandreplaynow return(Containment, Result<(), String>)— the tier they achieved, alongside their outcome.The tier leaves by out-parameter into a thin wrapper rather than through the return type, because it becomes known part way through the body: every
?before that point would otherwise have to name a tier it does not have yet. A run that failed before it started reports the prediction, which is the honest answer for a run that achieved nothing. That is also why it is a tuple rather thanResult<Containment, _>— a failed run still has a tier worth reporting.achieved_tieris one function, used by the certification path and the reporting path alike. Those two computing it separately is how they came to disagree in the first place.The corrected line prints only when the run's answer differs from the prediction, so nothing changes on Linux and no output grows a line for a distinction that platform does not have.
--jsonalways carries the achieved tier.Verification
cargo test --workspace --all-features— 728 tests greencargo fmt --check— cleandiff size 107 <= 400, tests773 -> 775, nothing silencedthe_reported_tier_is_the_one_the_run_achievedwas mutation-checked: makingachieved_tierignore the run and return the prediction fails itBoth directions are asserted, and the second is not implied by the first: where the run decided no tier — a
url:service, a flow engaging no egress, a platform with no mechanism — the prediction stands. A run that determined nothing must not read as one that achieved nothing; those are different sentences.Not fixed here, and it is the same defect
The run record still stores the predicted tier.
build_control_recordis called where the run is not in scope, so threading it there is a larger change than this one and would bury the fix above. Flagging it rather than leaving it to be rediscovered.Generated by Claude Code