From 22521cfbf2f4ebc317ed9a5ff39501d7d25b5075 Mon Sep 17 00:00:00 2001 From: test Date: Sat, 1 Aug 2026 03:00:38 +0800 Subject: [PATCH] fix(qa): the preview receipt is the settled pre-commitment card, not an intermediate render (closes #663) When a first_review/weekly_review resolves an absent cash anchor at the card beat, `add-cash` recomputes the review and a second preview renders before the commitment is written (flows/first-review.md step 6). `verify`'s exactly-once stage=preview pair had no rule distinguishing the pre-cash card that only asked the question from the settled, post-recompute card the user actually used to choose or skip the rule -- so the only way a `provided` trace could satisfy the old "cash_anchor_checked after the card" ordering was to receipt the superseded card as the decision artifact. Maintainer disposition (issue #663, comment 2026-07-31): a review trace has exactly one stage=preview pair, the last pre-commitment card; an earlier preview shown only to unlock a cash anchor is an intermediate interaction, not the accepted artifact. The receipt EVENT is deferred until the recompute settles; the presentation itself is not deferred. `verify_rows` now splits the two outcomes that recompute differently: `declined` keeps the existing "after the card" ordering, since nothing recomputes and the card that asked stands as settled. `provided` inverts it -- the recorded stage=preview artifact/card pair must postdate `cash_anchor_checked`, because providing an anchor always triggers `add-cash` and a recompute, so the exactly-once pair can only honestly be the card rendered afterward. A `provided` trace whose pair sits at or before that row now fails, naming the superseded-card problem. Trace id stability across add-cash's engine session replacement was already covered by the pre-existing "all events must use the declared session_id" check (references/ux-receipt.md's "keep the original session id for the whole trace"); this adds a dedicated assertion tying that coverage to #663's own scenario rather than reimplementing it. Docs updated in the same change (mirrored surface): references/ux-receipt.md (primary contract), docs/qa-runbook.md gate 3, references/data-contract.md, and qa/SKILL.md's replayed `weekly_review` qa-trace walkthrough, which was recording the pre-cash card and is now the reference example for the correct deferred ordering. references/card-delivery.md and references/interaction-delivery.md were checked and need no change: both describe presentation mechanics that stay true regardless of which card the receipt records. Full offline suite green: `python3 tests/run_all.py` -> PASS: all 49 suites passed. Co-Authored-By: Claude Fable 5 --- docs/qa-runbook.md | 12 +- qa/SKILL.md | 25 +-- .../fomo-kernel/references/data-contract.md | 2 +- skills/fomo-kernel/references/ux-receipt.md | 9 +- skills/fomo-kernel/tools/ux_receipt.py | 78 +++++++-- tests/test_interaction_trajectory.py | 153 +++++++++++++++--- 6 files changed, 228 insertions(+), 51 deletions(-) diff --git a/docs/qa-runbook.md b/docs/qa-runbook.md index ef38d258..8b5cad30 100644 --- a/docs/qa-runbook.md +++ b/docs/qa-runbook.md @@ -76,10 +76,14 @@ once and they hold for every run that follows in the same conversation. [interaction-delivery.md](../skills/fomo-kernel/references/interaction-delivery.md) (#293). On a review route it also proves the cash anchor was settled by the user rather than by the agent's discretion: `cash_anchor_checked` carries - `found_in_source` before the first surface, or `provided`/`declined` after - the card the question was asked at, and no outcome exists for "not asked" - (#357). When an anchor arrives mid-run, `add-cash` mints a new session id — - keep the original one for the whole trace. + `found_in_source` before the first surface, `declined` after the settled + card the question was asked at, or `provided` *before* the recorded + preview pair — providing an anchor always mints a new session id via + `add-cash` and recomputes the review, so the settled card is the one + rendered afterward, and the exactly-once preview pair this trace may + record is reserved for it, never the pre-cash card that only asked (#663). + Keep the original session id for the whole trace regardless. No outcome + exists for "not asked" (#357). 4. **Verdict and verification** — the session ended with an `owner_verdict` event and `tools/ux_receipt.py verify` passing. Human-graded runs use both `--require-owner-verdict` and `--require-timing-integrity`; only diff --git a/qa/SKILL.md b/qa/SKILL.md index 1881526a..c8175072 100644 --- a/qa/SKILL.md +++ b/qa/SKILL.md @@ -219,7 +219,12 @@ python3 tools/ux_receipt.py event --session-id --event card_presented \ A candidate with no `grounding` omits the field entirely (like `candidate_1`) — **do not invent a sentence to fill it**. That is precisely the half #293 cannot catch and only a human can hold. -**The `weekly_review` route carries one extra opener, and `verify` enforces it** (the trace above is `first_review`; do not copy the opener into it). When `prepare` selects `weekly_review`, show the user the rule agreed last time **before the first question and the first card**. Two rows differ from the trace above — that opener, and the cash anchor. This trace deliberately shows the other cash shape: a source with no balance row, so the plan came back `absent`, the balance was asked for in the same message as the preview card, and the outcome row therefore sits **after** that card (`provided` when the user gave one, `declined` when they did not). Everything else — questions, answers received, both card stages, rule choice — is copied verbatim: +**The `weekly_review` route carries one extra opener, and `verify` enforces it** (the trace above is `first_review`; do not copy the opener into it). When `prepare` selects `weekly_review`, show the user the rule agreed last time **before the first question and the first card**. Two rows differ from the trace above — that opener, and the cash anchor. This trace deliberately shows the other cash shape: a source with no balance row, so the plan came back `absent`, and the balance was asked for in the same message as a preview card. Where the outcome row lands from there depends on the outcome (#663), because only one of the two recomputes anything: + +- `declined` — nothing recomputes, so the card that asked the question is also the settled one, and the row sits **after** it, exactly as before. +- `provided` — always triggers `add-cash` and a recompute, so the card the user actually used to choose or skip the rule is the one rendered *afterward*, never the one that asked. The trace's one `stage=preview` artifact/`card_presented` pair is reserved for that later, settled card, so the row sits **before** it: record `cash_anchor_checked --cash-outcome provided` when the user answers, run `add-cash` and rerun `preview` on the session it returns — keeping THIS session id for the whole trace, since a receipt records one conversation, not one engine session — and only then record the deferred `artifact_generated`/`card_presented` pair for the card that recompute produced. The first, pre-cash card was real and the user did see it; it simply gets no artifact/presentation row of its own, because it is an intermediate interaction, not the accepted decision artifact. A pair recorded before this row would receipt that superseded card instead, which `verify` now refuses by name. + +This trace shows `provided`. Everything else — questions, answers received, both card stages, rule choice — is copied verbatim, with the preview pair moved to follow the cash row instead of preceding it: ```bash # qa-trace: weekly_review @@ -234,19 +239,21 @@ python3 tools/ux_receipt.py event --session-id --event memory_presented \ # python3 tools/ux_receipt.py event --session-id --event memory_presented --memory-kind due_revisit python3 tools/ux_receipt.py event --session-id --event question_presented --mode native_options python3 tools/ux_receipt.py event --session-id --event answers_received +# The FIRST preview (holdings-only) renders and is shown here, in one message with the +# rule choice and the cash question — a real, user-visible step. #663: it is an +# intermediate interaction, not the accepted decision artifact, so it gets no +# artifact_generated/card_presented row of its own; only the cash answer is recorded now. +# The user gave a balance, so run `review.py add-cash --session-id --cash ` and +# rerun `preview` on the session it returns before recording the next two rows below. +python3 tools/ux_receipt.py event --session-id --event cash_anchor_checked \ + --cash-outcome provided +# The settled card — the one add-cash's rerun preview rendered — is the pair this trace +# records, and the rule choice is shown alongside it. python3 tools/ux_receipt.py event --session-id --event artifact_generated \ --stage preview --artifact-path python3 tools/ux_receipt.py event --session-id --event card_presented --stage preview --mode widget -# The card, the rule choice and the cash question are ONE message, card on top. These -# three rows are the order the surfaces appeared in it. python3 tools/ux_receipt.py event --session-id --event rule_choice_presented \ --mode native_options --grounding-check-file -# The user answered, so `provided`. If they gave a balance, run -# `review.py add-cash --session-id --cash ` and rerun preview on the session -# it returns — but keep THIS session id for the whole trace: a receipt records one -# conversation, not one engine session. -python3 tools/ux_receipt.py event --session-id --event cash_anchor_checked \ - --cash-outcome provided python3 tools/ux_receipt.py event --session-id --event artifact_generated \ --stage final --artifact-path python3 tools/ux_receipt.py event --session-id --event card_presented --stage final --mode widget diff --git a/skills/fomo-kernel/references/data-contract.md b/skills/fomo-kernel/references/data-contract.md index ecd95e77..bb9cd291 100644 --- a/skills/fomo-kernel/references/data-contract.md +++ b/skills/fomo-kernel/references/data-contract.md @@ -80,7 +80,7 @@ python3 engine/review.py add-cash --session-id \ This re-enters the same review with the anchor added, and it reuses that session's frozen prices rather than resolving new ones — the user answered against the card those numbers rendered, so a second observation instant would be a different review wearing the same session (#665). Two outcomes, and the response names which: the anchor propagated into the account pillar (`recompute.outcome: "anchor_propagated"`), or the facts underneath it moved and the command refuses. What counts as moving is the source facts, not the anchor's own downstream effects: the input files, that frozen frame, the recorded book, the diagnosis, the rules offered, and the questions already answered. A transaction file that grew since the card was rendered is refused before anything is written at all. Answers, narrative, and frozen question surfaces carry over untouched; the returned session id supersedes the one you passed, and `card_plan.required_honesty_keys` gains the account-basis key, which needs one more sentence in `narrative.honesty`. Rerun `preview` on the returned session and show the recomputed card. If they skip, the card keeps its holdings pillar and its unlock invitation exactly as before, and the review finishes normally — skipping is a real answer, not a failure. -On `first_review` and full-tier `weekly_review`, record which of the three happened (`cash_anchor_checked`, `references/ux-receipt.md`): `found_in_source` before the first question or card, `provided` or `declined` after the card the question was attached to. There is no outcome meaning "the agent decided not to ask" — that is what made a run where the user never got the chance look identical to one where they declined (#357, fifth recurrence). +On `first_review` and full-tier `weekly_review`, record which of the three happened (`cash_anchor_checked`, `references/ux-receipt.md`): `found_in_source` before the first question or card; `declined` after the card the question was attached to, since nothing recomputes and that card stands as settled; `provided` *before* the recorded preview pair instead, since providing always recomputes and the settled card — the one this trace's exactly-once preview pair must record — is the one rendered after that recompute, not the one that asked (#663). There is no outcome meaning "the agent decided not to ask" — that is what made a run where the user never got the chance look identical to one where they declined (#357, fifth recurrence). One anchor is enough: the engine reconstructs the account's historical cash balance by rolling that single point backward through each trade's cash footprint, so the user never has to supply a day-by-day record. Two things follow from that, worth knowing because neither is visible in the data: diff --git a/skills/fomo-kernel/references/ux-receipt.md b/skills/fomo-kernel/references/ux-receipt.md index a7f9daf5..70de6192 100644 --- a/skills/fomo-kernel/references/ux-receipt.md +++ b/skills/fomo-kernel/references/ux-receipt.md @@ -48,7 +48,10 @@ python3 tools/ux_receipt.py event --session-id \ --event memory_presented --memory-kind prior_commitment # cash anchor (first_review, full-tier weekly). found_in_source goes before the -# first question or card; provided/declined go after the card they were asked at +# first question or card; declined goes after the (settled) card it was asked +# at; provided goes *before* the settled preview pair below -- providing an +# anchor always recomputes the review, so that pair must record the card +# shown after the recompute, never the one that asked (#663) python3 tools/ux_receipt.py event --session-id \ --event cash_anchor_checked --cash-outcome found_in_source @@ -66,9 +69,9 @@ python3 tools/ux_receipt.py event --session-id --event card_presented --sta `--cash-outcome` takes exactly one of `found_in_source` (the statement carried a balance row, so nothing was asked), `provided` (the user was asked at the card beat and gave one), or `declined` (the user was asked and did not). Every value states what the *user's* data or answer decided; there is no value for "the agent decided not to ask". The retired `skipped` was exactly that, and #357's fifth recurrence recorded it correctly and in order while the user was never offered the question — the gate passed and the experience was identical to forgetting. Now a run that never asked can record nothing, and `verify` refuses a trace with no `cash_anchor_checked` on a review whose declared plan says the check was owed, so "nobody was asked" and "they declined" are different traces. -Position follows the outcome, and `verify` enforces it both ways. `found_in_source` is read before `prepare` runs, so it must precede the first question or card — retrospective evidence rather than a self-report, the same anti-backfill rule as the weekly opener. `provided` and `declined` record a question asked in the same message as the preview card (`data-contract.md`), so they must come *after* the first `card_presented`; a `declined` recorded earlier is refused, because at that point there was no card the question could have been attached to. +Position follows the outcome, and `verify` enforces it both ways. `found_in_source` is read before `prepare` runs, so it must precede the first question or card — retrospective evidence rather than a self-report, the same anti-backfill rule as the weekly opener. `provided` and `declined` both record a question asked in the same message as a preview card (`data-contract.md`), but from there the two invert (#663), because only one of them recomputes anything. `declined` triggers no recompute, so the card that asked the question is also the settled one, and the row must come *after* the first `card_presented`; a `declined` recorded earlier is refused, because at that point there was no card the question could have been attached to. -When the user provides an anchor, `review.py add-cash` recomputes the review and returns a new session id. **Keep the original session id for the whole trace.** A receipt records one conversation with a user, not one engine session — the same reason a refresh trace is keyed by `refresh_id` and a `consider` trace by `evaluation_id`. +`provided` always triggers `review.py add-cash` and a recompute, so the card the user actually used to choose or skip the rule is never the one that prompted the question — it is the one `add-cash`'s rerun `preview` renders afterward, on the new session id `add-cash` returns. **Keep the original session id for the whole trace anyway** — a receipt records one conversation with a user, not one engine session, the same reason a refresh trace is keyed by `refresh_id` and a `consider` trace by `evaluation_id` — but the trace's one `stage=preview` artifact/`card_presented` pair (`verify` allows exactly one, on every route that renders a card) is reserved for that later, settled card. Do not call `artifact_generated`/`card_presented` for the first, pre-cash card at all: the user still sees it in real time and answers the cash question against it — that presentation is not deferred — but it is an intermediate interaction, not the accepted decision artifact, so only its *receipt event* is deferred, until the recompute settles and the settled card is ready to show. Concretely: record `cash_anchor_checked --cash-outcome provided` when the user answers (before `add-cash` runs), then record the deferred `artifact_generated`/`card_presented` pair once `preview` has rendered the settled card on the session `add-cash` returned — in that order, so the row comes *before* the pair rather than after it, the inverse of `declined`. A `provided` trace whose recorded pair sits at or before this row has kept the superseded, pre-cash card as its decision artifact instead, and `verify` refuses it, naming the row. If a future route needs to audit every intermediate render rather than just the settled one, that is a distinct, repeatable event kind to add — not a reason to overload this exactly-once pair. `snapshot_review` states cash inline in its own envelope and `test_drive` persists no anchor, so neither carries this requirement at all — `ROUTE_CONTRACTS` says so structurally, independent of anything a trace declares. `first_review` and `weekly_review` do carry it structurally, but a *specific* review can still have no cash-anchor opportunity: a light-tier week's plan carries `input.cash_anchor.status == "not_applicable"` (reason `light_tier`) the same positive way a snapshot's or a test drive's does (`data-contract.md`). Transcribe that status with `--cash-anchor-status not_applicable` at `start` (above, #677), and `verify` requires **zero** `cash_anchor_checked` events instead of one for that trace — recording one anyway, of any outcome, fails as a named contradiction: the plan this trace declares it walked said no check was owed, so an event claiming one happened cannot also be true. A `declined` written to make an honest gap verify is exactly the fabrication this tool exists to catch, not a way around it. Omit the flag and `verify` keeps the original unconditional reading, so a receipt written before it existed still verifies exactly as it did. diff --git a/skills/fomo-kernel/tools/ux_receipt.py b/skills/fomo-kernel/tools/ux_receipt.py index e00a2cc6..c78f99e0 100644 --- a/skills/fomo-kernel/tools/ux_receipt.py +++ b/skills/fomo-kernel/tools/ux_receipt.py @@ -199,10 +199,17 @@ def _default_state_root() -> str: # same hole one level down. # cash_anchor the #357 check, on the routes that read a trade history. Its # required position depends on the outcome, not on the route: -# `found_in_source` before the first surface, `provided`/ -# `declined` after the first card. A declared positions snapshot -# states `cash` inline in its own envelope, and `test_drive` -# persists no accounting anchor at all, so neither owes one. +# `found_in_source` before the first surface; `declined` after +# the settled preview card, since nothing recomputes and that +# card is the one the question was attached to; `provided` +# *before* the settled preview card (#663) — providing an +# anchor always triggers `add-cash` and a recompute, so the one +# `stage=preview` pair this trace may record (see `cards` +# above) must be the card rendered after that recompute, never +# the pre-cash card that only prompted the question. A declared +# positions snapshot states `cash` inline in its own envelope, +# and `test_drive` persists no accounting anchor at all, so +# neither owes one. # On a route where this is True, whether *this instance* owes # the event is narrowed further by the trace's own declared # `cash_anchor_status` (#677): `not_applicable` — the plan's @@ -1390,13 +1397,34 @@ def verify_rows(rows: list[dict], require_owner_verdict: bool = False, # the row is retrospective evidence and must precede the first surface — the # anti-backfill rule this check has always carried. # - # `provided`/`declined` record a question the user was asked *at the card - # beat*, which is where the owner ruled it belongs: the anchor costs the - # account pillar and nothing else, so asking before the card spends a turn - # on something the user cannot yet see the value of. Requiring the row after - # the first card is what makes that ordering evidence rather than intent — - # and a `declined` recorded before any card is refused, because at that - # moment there was no card the question could have been attached to. + # `declined` records a question the user was asked *at the card beat*, + # which is where the owner ruled it belongs: the anchor costs the account + # pillar and nothing else, so asking before the card spends a turn on + # something the user cannot yet see the value of. Requiring the row after + # the (settled) card is what makes that ordering evidence rather than + # intent — and a `declined` recorded before any card is refused, because + # at that moment there was no card the question could have been attached + # to. Nothing recomputes on a decline, so the card that asked is also the + # card the user decided against: the ordinary "card, then check" shape. + # + # `provided` inverts that shape (#663). Providing an anchor always + # triggers `add-cash` and a recompute (`flows/first-review.md` step 6: + # "if they answered, run add-cash, rerun preview on the session it + # returns, and show that card before the commitment is written"), so the + # card the user actually used to choose or skip the rule is never the one + # that prompted the question — it is the one rendered afterward, from the + # recomputed session. The exactly-once `stage=preview` pair the `cards` + # check above enforces therefore has exactly one honest position on a + # `provided` trace: after this event, not before it. The user did see the + # earlier, pre-cash card — that presentation is not deferred — but it is + # an intermediate interaction, not the accepted decision artifact, so + # this trace never records that first card's own artifact/presentation + # pair at all; the receipt event is deferred to the settled card instead. + # A `provided` trace whose recorded preview pair sits at or before this + # row has kept the superseded pre-cash card as its decision artifact, + # which is the gap #663 reported: recording the honest, single preview + # pair for the *first* card was the only way the old "after the card" + # rule for this outcome could be satisfied at all. # # #677: whether *this instance* owes the event at all is read from the # trace's own declared `cash_anchor_status`, not assumed from the route. @@ -1427,11 +1455,29 @@ def verify_rows(rows: list[dict], require_owner_verdict: bool = False, if index >= first_surface: errors.append( "cash_anchor_checked was recorded after the first question or card") - elif index < min(_positions(rows, "card_presented") or [len(rows)]): - errors.append( - f"cash_anchor_checked recorded {outcome!r} before any card was presented; " - "the balance is asked for at the card beat, so a row placed earlier " - "cannot be evidence the user was shown what answering would buy") + elif outcome == "declined": + if index < min(_positions(rows, "card_presented") or [len(rows)]): + errors.append( + f"cash_anchor_checked recorded {outcome!r} before any card was presented; " + "the balance is asked for at the card beat, so a row placed earlier " + "cannot be evidence the user was shown what answering would buy") + elif outcome == "provided" and preview_card: + # #663: unlike `declined`, a `provided` answer always triggers + # `add-cash` and a recompute, so the exactly-once preview pair + # must postdate this row rather than precede it — see the + # block comment above for why. + preview_artifact = _positions(rows, "artifact_generated", stage="preview") + stale = [position for position in preview_artifact + preview_card + if position < index] + if stale: + errors.append( + "cash_anchor_checked recorded 'provided' but the recorded preview " + "artifact/card precedes it; providing an anchor always triggers " + "add-cash and a recompute, so the one preview pair this trace may " + "record must be the settled card shown after that recompute — " + "recording it before this event keeps the superseded pre-cash card " + "as the decision artifact instead of the card the user actually used " + "to choose or skip the rule") if Counter(row.get("event") for row in rows)["widget_attempt_failed"] and "widget" not in card_modes: errors.append("widget failure was recorded without declared widget capability") diff --git a/tests/test_interaction_trajectory.py b/tests/test_interaction_trajectory.py index 4b0350fc..a29b841e 100644 --- a/tests/test_interaction_trajectory.py +++ b/tests/test_interaction_trajectory.py @@ -838,10 +838,17 @@ def test_weekly_opener_after_first_card_fails(): # row is retrospective evidence: an agent that forgot to look cannot fabricate # it afterwards without also getting the ordering wrong. # -# `provided`/`declined` record a question asked at the card beat, where the -# owner ruled it belongs -- the anchor costs the account pillar and nothing -# else, so asking before the card spends a turn on a value the user cannot yet -# see. Requiring the row after the first card is what makes it evidence. +# `provided` and `declined` both record a question asked at the card beat, +# where the owner ruled it belongs -- the anchor costs the account pillar and +# nothing else, so asking before the card spends a turn on a value the user +# cannot yet see. From there the two outcomes diverge (#663). Nothing +# recomputes on a `declined`, so the card that asked the question is also the +# settled card, and the row belongs after it -- requiring the row after the +# card is what makes it evidence rather than intent. A `provided` answer +# always triggers `add-cash` and a recompute, so the settled card is the one +# rendered *afterward*: the exactly-once preview pair this trace may record is +# that later card, and the row therefore belongs *before* it -- the inverse +# ordering, for the same "evidence rather than intent" reason. # # What no longer exists is the escape both halves used to share. `skipped` meant # "the agent decided not to ask", and #357's fifth recurrence recorded it @@ -887,26 +894,52 @@ def test_cash_anchor_found_in_source_passes_before_the_first_surface(): assert ux_receipt.verify_rows(rows) == [] -def test_an_answered_cash_question_is_recorded_at_the_card_beat(): - """The owner's ruling, mechanically. The balance is asked for in the same - message as the preview card, so both user-decided outcomes belong after - it -- and both are accepted there, because declining is a real answer.""" - for outcome in ("provided", "declined"): - rows = good_markdown_rows() - drop(rows, CASH_ANCHOR) - after(rows, PREVIEW_CARD, row("cash_anchor_checked", cash_outcome=outcome)) - assert ux_receipt.verify_rows(rows) == [], outcome +def test_a_declined_cash_answer_is_recorded_at_the_card_beat(): + """The owner's ruling, mechanically, for the outcome nothing recomputes: + the balance is asked for in the same message as the preview card, and + since a decline leaves that card standing as the settled one, the row + belongs after it -- declining is a real answer and is accepted there.""" + rows = good_markdown_rows() + drop(rows, CASH_ANCHOR) + after(rows, PREVIEW_CARD, row("cash_anchor_checked", cash_outcome="declined")) + assert ux_receipt.verify_rows(rows) == [] -def test_an_answered_cash_question_before_any_card_fails(): +def test_a_declined_cash_answer_before_any_card_fails(): """The half that makes "the user was asked" evidence rather than intent. A `declined` recorded before the card is a claim about a question that had nothing to be attached to -- the shape of #357's fifth recurrence, where a correctly ordered row meant only that the agent had decided for the user.""" - for outcome in ("provided", "declined"): - rows = good_markdown_rows() - at(rows, CASH_ANCHOR)["cash_outcome"] = outcome - assert_has(ux_receipt.verify_rows(rows), "before any card was presented") + rows = good_markdown_rows() + at(rows, CASH_ANCHOR)["cash_outcome"] = "declined" + assert_has(ux_receipt.verify_rows(rows), "before any card was presented") + + +def test_a_provided_cash_answer_may_precede_the_settled_preview_pair(): + """#663: `good_markdown_rows()` already positions its one cash-anchor row + before the (only) preview pair -- exactly the shape a `provided` answer + must take now that the receipt event for the settled, post-recompute card + is deferred until after this row rather than recorded for the pre-cash + card that prompted the question.""" + rows = good_markdown_rows() + at(rows, CASH_ANCHOR)["cash_outcome"] = "provided" + assert ux_receipt.verify_rows(rows) == [] + + +def test_a_provided_cash_answer_after_the_settled_preview_fails(): + """#663's own repro, mechanically: recording `provided` after the single + recorded preview pair means that pair was generated and shown *before* + the user answered -- which can never be true of a provided answer, + because providing one always triggers add-cash and a recompute. The only + way a trace could satisfy the pre-#663 "after the card" rule for this + outcome was to receipt the superseded, pre-cash card as the decision + artifact, which is exactly what this shape does and what must now fail.""" + rows = good_markdown_rows() + drop(rows, CASH_ANCHOR) + after(rows, PREVIEW_CARD, row("cash_anchor_checked", cash_outcome="provided")) + errors = ux_receipt.verify_rows(rows) + assert_has(errors, "cash_anchor_checked recorded 'provided'") + assert_has(errors, "superseded pre-cash card") def test_no_cash_outcome_means_the_agent_chose_not_to_ask(): @@ -921,6 +954,90 @@ def test_no_cash_outcome_means_the_agent_chose_not_to_ask(): "indistinguishable from forgetting from the user's chair (#357)") +# --- #663: the settled card is the one this trace may record ----------------- +# +# The maintainer's disposition names the exact shape: prepare -> first preview +# shown (asks the cash question) -> cash answer -> add-cash -> second preview +# shown (the settled card) -> commitment. The receipt cannot see "first" or +# "second" card content -- it never carries card content at all (#360) -- so +# the only honest signal it has is order: the recorded stage=preview pair must +# sit after cash_anchor_checked(provided), because that is structurally the +# only position a post-recompute card can occupy. These two traces are +# production-shaped end to end (required question, cash answer, one preview +# pair, rule choice, finalize) and differ in exactly that one position. + +def _end_to_end_provided_trace(preview_after_cash): + """A complete first_review trace differing only in whether the recorded + stage=preview pair sits before or after the cash answer.""" + rows = [ + declaration(), + row("question_presented", mode="plain_text"), + row("answers_received"), + ] + preview_pair = [ + row("artifact_generated", stage="preview", artifact_path="/tmp/card-private-preview.md"), + row("card_presented", stage="preview", mode="markdown_inline"), + ] + cash = row("cash_anchor_checked", cash_outcome="provided") + if preview_after_cash: + rows.append(cash) + rows.extend(preview_pair) + else: + rows.extend(preview_pair) + rows.append(cash) + rows.append(row("rule_choice_presented", mode="plain_text", + grounding_expected=False, grounding_verbatim=True)) + rows.append(row("artifact_generated", stage="final", artifact_path="/tmp/card-private.md")) + rows.append(row("card_presented", stage="final", mode="markdown_inline")) + return rows + + +def test_end_to_end_provided_trace_pointing_at_the_settled_card_verifies(): + """Required proof 1: prepare -> first preview shown -> cash answer -> + add-cash -> second preview shown -> commitment, walked per #663's + deferred-event instruction, verifies clean -- its one preview + artifact/card pair is, structurally, the settled post-recompute card.""" + rows = _end_to_end_provided_trace(preview_after_cash=True) + assert ux_receipt.verify_rows(rows) == [] + + +def test_end_to_end_provided_trace_pointing_at_the_superseded_card_fails(): + """Required proof 2: the same trace shape, except the recorded preview + pair is the one generated and shown *before* the cash answer -- the + superseded, pre-cash card kept as the decision artifact. Fails, named.""" + rows = _end_to_end_provided_trace(preview_after_cash=False) + errors = ux_receipt.verify_rows(rows) + assert_has(errors, "cash_anchor_checked recorded 'provided'") + assert_has(errors, "superseded pre-cash card") + + +def test_trace_id_stays_the_original_conversation_id_across_add_cash(): + """Required proof 3. `add-cash` returns a new ENGINE session id -- proven + for real in test_review_v2.py:: + test_add_cash_recomputes_the_same_review_without_re_asking_anything + (`out["session_id"] != plan["session_id"]`) -- but the RECEIPT's own + session id is the conversation id chosen once at `start` + (`references/ux-receipt.md`: "keep the original session id for the whole + trace") and is unrelated to it. The pre-existing declared-session_id + invariant (`test_session_id_must_be_consistent`) already enforces this for + every row, engine session replacement included, so #663 adds no separate + mechanism -- this asserts that coverage against #663's own scenario.""" + rows = _end_to_end_provided_trace(preview_after_cash=True) + original_id = rows[0]["session_id"] + assert all(entry["session_id"] == original_id for entry in rows), ( + "a trace spanning add-cash's engine session replacement must still " + "use one conversation session id throughout") + assert ux_receipt.verify_rows(rows) == [] + + # The mechanism: substituting the new ENGINE session id into even one row + # -- what would happen if an agent mistakenly keyed a post-add-cash event + # to add-cash's returned id instead of the original conversation id -- is + # refused by that same pre-existing check. + drifted = _end_to_end_provided_trace(preview_after_cash=True) + at(drifted, PREVIEW_ARTIFACT)["session_id"] = "engine-session-b-returned-by-add-cash" + assert_has(ux_receipt.verify_rows(drifted), "declared session_id") + + def test_cash_anchor_checked_not_required_outside_trade_history_routes(): # snapshot_review's own envelope states `cash` inline (or omits it) and # test_drive never persists an accounting anchor at all (references/