fix(add-cash): the drift gate compares frozen source facts, so the card-beat cash answer is recordable (closes #665) - #684
Merged
Conversation
…rd-beat cash answer is recordable (closes #665) `prepare` reports `cash_anchor.status: "absent"` with `ask_after: "card_presented"`, the card asks the user for a balance, and the command the plan itself prescribes then refused to record their answer. Reproduced on a book with one unpriced symbol and a provider that moves between passes: exit 2, naming `engine_state, engine_card, state_snapshot, question_queue`. Root cause is the recompute's own second market-data resolution, not the cash anchor. `MarketDataBundle.covers` deliberately refuses to serve a request naming a symbol the stored bundle failed to price, so a transient outage gets its retry — and one unpriced symbol anywhere in the universe therefore made `add-cash` re-price the whole review at a second instant. Every derived number moved with it, and the gate reported the recompute's own movement to the user as facts moving underneath them. Measured across five mock books, a cash anchor moves `engine_state.cash`, `engine_card`'s `cash`/`acct_perf`/ `honesty_ledger` and `card_plan.required_honesty_keys`, and nothing in `state_snapshot` or `question_queue` at all. Two halves, one idea: a pass amending a review the user has already read has no new inputs but the anchor. - The frame is reused, never re-observed. `market_data.frame_frozen` is a posture beside `network_allowed` — one reader, AST-pinned — and `_from_frozen_frame` answers from the exact same-day entry for this request, reaching no provider on any outcome including a missing frame. Offline is checked first, so a degraded run's gap code describes the machine rather than the subcommand. - The gate compares source facts, positively named: the input files, that frozen frame, the recorded book, the diagnosis, the rules offered, and the identities of the questions already answered. `CASH_RECOMPUTE_STATE_KEYS`/ `CASH_RECOMPUTE_CARD_KEYS` and the wholesale `state_snapshot`/ `question_queue` comparison are gone rather than extended. The refusal names which of the two verdicts it reached, and success emits the other (`recompute.outcome: "anchor_propagated"`). A transaction file that grew is refused inside `_verify_and_ingest_frozen_trades` before the append, on the engine's own dedup: a gate that only reads the recomputed plan has already written the rows it is about to refuse. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 31, 2026
atomchung
marked this pull request as ready for review
July 31, 2026 17:12
This was referenced Jul 31, 2026
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.
Closes #665.
What was actually wrong
The issue's structural read —
_cash_recompute_driftcomparesstate_snapshotandquestion_queuewhole — is real, but it is not what refused the session. I measured it: across five mock books, anchoring cash movesengine_state.cash,engine_card'scash/acct_perf/honesty_ledger, andcard_plan.required_honesty_keys. Nothing instate_snapshot. Nothing inquestion_queue, not even the question ids.What moved those surfaces was the recompute's own second market-data resolution.
MarketDataBundle.coversrefuses to serve a request naming a symbol the stored bundle failed to price — deliberately, so a transient outage gets its retry (#605 external review, finding 5). One unpriced symbol anywhere in the universe therefore makes the same-day cache refuse to serve the request that produced it, forever.add-cashre-priced the whole review at a second instant, every derived number moved, and the gate reported the recompute's own movement to the user as facts moving underneath them.Reproduced with an unpriced symbol and a provider that moves between passes (both taken from the real thing —
market_data's own docstring records^VIXmoving between two calls seconds apart):With a deterministic provider and the same unpriced symbol it passes at 2 calls, which isolates the cause: the cache miss is the enabler, the moving provider is the cause.
What changed
Two halves, one idea — a pass amending a review the user has already read has no new inputs but the anchor.
skills/fomo-kernel/engine/market_data.pyframe_frozen/FROZEN_ENVbesidenetwork_allowed, and_from_frozen_frame: exact-request same-day reuse that reaches no provider on any outcome, including a missing frame. Offline is checked first, so a degraded run's gap code describes the machine rather than the subcommand. New declared gap codefrozen_frame_gone.skills/fomo-kernel/engine/review.pycmd_add_cashre-enters_prepare_sessionwithamending_session=True, which sets the frozen posture in_run_engineand makes_verify_and_ingest_frozen_tradesrefuse a grown transaction file before the append._cash_recompute_driftnow comparesCASH_RECOMPUTE_SOURCE_FACTS— the input files, the frozen frame, the recorded book, the diagnosis, the rules offered, the question identities — replacingCASH_RECOMPUTE_STATE_KEYS/CASH_RECOMPUTE_CARD_KEYSand the wholesalestate_snapshot/question_queuecomparison. The refusal names which verdict it reached; success emits the other.tests/test_review_v2.pyusercustomize, followingtest_consider.py's pattern).tests/test_market_data.pytests/test_split_basis.pyadd-cashnot-driven-route justification named the old gate; it now names the frozen frame and the source-fact comparison.docs/maintainer-guide.mdskills/fomo-kernel/references/data-contract.md_cash_anchor_status's ownnext_actionalready promised "it reuses this session's frozen prices" — that sentence is now true rather than aspirational.The four proofs
1. End-to-end on an engine-priced review.
test_the_card_beat_cash_answer_is_recordable_on_an_engine_priced_reviewwalksprepare → preview (card shown) → add-cash → previewthrough the real CLI against a provider that moves between passes. The load-bearing assertion is the request count: the recompute must ask for nothing, because a second resolution that happened to agree would prove nothing. Asserts the amendedprice_snapshotis byte-identical to the frozen one,cash_anchor.status == "anchored",acct_perf.acct_twr/cash_dragpopulated, and that the amended card previews.2. A real change still refuses, with no residue. Two shapes, because they fail at different depths.
test_a_frame_no_longer_on_record_refuses_instead_of_fetching_a_fresh_one— the frame is gone (day boundary / reset root). Refuses namingthe valuation frame the card was priced from, fetches no replacement, ledger byte-identical, no anchored pending session.test_a_source_that_really_moved_still_refuses_and_leaves_nothing_behind— the CSV grew by one row. Ledger byte-identical (the assertion is checked first), refusal names the grown file, no anchored session, no card artifact.3. Idempotent replay.
test_replaying_the_same_cash_anchor_changes_nothing— the anchored session's pending directory is byte-identical after a replay, ledger unchanged, no provider call, no second pending session, stable refusal message.4. Mutation proof. Each applied with a
cpbackup, restored from the backup (nevergit checkout),__pycache__cleared between runs, and each mutated tree import-checked before the run so a crash can never be mistaken for a mutation result.(a) restore whole-object comparison of
engine_state/engine_card→ the success case reddens(b) drop the source-fact guard → the drift cases redden
(the success case correctly stays green — the guard is the only thing removed)
(c) drop the frozen frame → both redden, and the second names the mechanism. This is the mutation that pins #665's actual cause.
(d) drop the pre-write refusal → the grown-file case reddens
An earlier revision of this description presented 4a as evidence for the wholesale
comparison being the guarded thing. It was not; the text above replaces that claim.
Suite
Run with pandas 3.0.3, rich and yfinance importable, so nothing silently skipped; the new provider-backed tests assert at least one real provider call before the recompute, which fails loudly if the fake never ran.
🤖 Generated with Claude Code