Skip to content

fix(add-cash): the drift gate compares frozen source facts, so the card-beat cash answer is recordable (closes #665) - #684

Merged
atomchung merged 1 commit into
mainfrom
claude/665-add-cash-drift-gate
Jul 31, 2026
Merged

fix(add-cash): the drift gate compares frozen source facts, so the card-beat cash answer is recordable (closes #665)#684
atomchung merged 1 commit into
mainfrom
claude/665-add-cash-drift-gate

Conversation

@atomchung

@atomchung atomchung commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Closes #665.

What was actually wrong

The issue's structural read — _cash_recompute_drift compares state_snapshot and question_queue whole — is real, but it is not what refused the session. I measured it: across five mock books, anchoring cash moves engine_state.cash, engine_card's cash/acct_perf/honesty_ledger, and card_plan.required_honesty_keys. Nothing in state_snapshot. Nothing in question_queue, not even the question ids.

What moved those surfaces was the recompute's own second market-data resolution. MarketDataBundle.covers refuses 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-cash re-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 ^VIX moving between two calls seconds apart):

add-cash exit: 2
error: adding the cash anchor changed more than the anchor; these no longer match the
session you are amending: engine_state, engine_card, state_snapshot
provider calls: 2          # the cache never served the second pass

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.

File Why
skills/fomo-kernel/engine/market_data.py New posture frame_frozen/FROZEN_ENV beside network_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 code frozen_frame_gone.
skills/fomo-kernel/engine/review.py cmd_add_cash re-enters _prepare_session with amending_session=True, which sets the frozen posture in _run_engine and makes _verify_and_ingest_frozen_trades refuse a grown transaction file before the append. _cash_recompute_drift now compares CASH_RECOMPUTE_SOURCE_FACTS — the input files, the frozen frame, the recorded book, the diagnosis, the rules offered, the question identities — replacing CASH_RECOMPUTE_STATE_KEYS/CASH_RECOMPUTE_CARD_KEYS and the wholesale state_snapshot/question_queue comparison. The refusal names which verdict it reached; success emits the other.
tests/test_review_v2.py The regression walk on an engine-priced review, plus the two refusal shapes and the replay. Adds a live-like provider harness (usercustomize, following test_consider.py's pattern).
tests/test_market_data.py Section E2 for the frozen posture, and an AST guard that the new posture also has exactly one reader.
tests/test_split_basis.py The add-cash not-driven-route justification named the old gate; it now names the frozen frame and the source-fact comparison.
docs/maintainer-guide.md The cash-anchor row and the #605 market-data row are mirrored surfaces for both halves; the #630 row's one-line reference to the gate.
skills/fomo-kernel/references/data-contract.md The one full statement of the cash beat now states the two outcomes and the pre-write refusal.

_cash_anchor_status's own next_action already 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_review walks prepare → preview (card shown) → add-cash → preview through 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 amended price_snapshot is byte-identical to the frozen one, cash_anchor.status == "anchored", acct_perf.acct_twr/cash_drag populated, 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 naming the 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 cp backup, restored from the backup (never git 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

import check: IMPORT OK True
RED   test_the_card_beat_cash_answer_is_recordable_on_an_engine_priced_review ::
a declared balance answered at the card beat must be recordable — this is the session
the command exists for: {"error": "the facts moved: ... These no longer match the session
you are amending: engine_state, engine_card. ...

What 4a does and does not prove — corrected after review. The drift list in that
refusal names engine_state, engine_card and nothing else, and re-running the mutation
with only those two restored (dropping state_snapshot/question_queue from it) gives
a byte-identical refusal. So 4a proves one thing: the gate must tolerate the anchor's
own downstream effects.
Comparing the cash-bearing artifacts whole re-refuses the very
session the command exists for.

It does not prove anything about the wholesale state_snapshot/question_queue
comparison. A reviewer ran the discriminating mutation — keep the positive source facts
and append the two wholesale entries back on top — and the suite stayed green. I
reproduced that on this branch:

=== mutation coordinator_append_two ===
import check: IMPORT OK True
GREEN test_the_card_beat_cash_answer_is_recordable_on_an_engine_priced_review
GREEN test_a_frame_no_longer_on_record_refuses_instead_of_fetching_a_fresh_one
GREEN test_replaying_the_same_cash_anchor_changes_nothing

That is consistent with the measurement at the top of this description: once the frame is
frozen, nothing in state_snapshot or question_queue moves under --cash, so comparing
them wholesale is inert. Dropping those two comparisons is structural hardening required
by the disposition, not the load-bearing fix
— it removes the standing hazard that the
gate becomes hostage to the first cash-derived value that ever lands inside a derived
object. The load-bearing fix is the frozen frame, which is what (c) below pins.

Known gap this exposes: no test would catch a reintroduction of the wholesale
state_snapshot/question_queue comparison, precisely because it is inert today. Pinning
it would take a fixture in which the anchor does reach a derived object, and none exists.
Flagged rather than papered over; happy to add one if you want it in this PR.

(b) drop the source-fact guard → the drift cases redden

RED   test_a_frame_no_longer_on_record_refuses_instead_of_fetching_a_fresh_one
RED   test_add_cash_refuses_when_more_than_the_anchor_moved
GREEN test_the_card_beat_cash_answer_is_recordable_on_an_engine_priced_review

(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.

RED   test_the_card_beat_cash_answer_is_recordable_on_an_engine_priced_review ::
a declared balance answered at the card beat must be recordable ...
RED   test_a_frame_no_longer_on_record_refuses_instead_of_fetching_a_fresh_one ::
the recompute went and fetched a replacement frame. That is the defect wearing a repair:
['AMD,ARM,CRWV,MU,NVDA,ORCL,PLTR,QQQ,SOXX,SPY,TSLA,XLY,^VIX']

(d) drop the pre-write refusal → the grown-file case reddens

RED   test_a_source_that_really_moved_still_refuses_and_leaves_nothing_behind

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

PASS: all 49 suites passed.

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

…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>
@atomchung
atomchung marked this pull request as ready for review July 31, 2026 17:12
@atomchung
atomchung merged commit e662164 into main Jul 31, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant