fix(ledger): a position the user never sold keeps its cycle across a new declaration (closes #539, closes #536) - #659
Merged
Conversation
…new declaration (closes #539, closes #536) A declaration says what is held, never since when. So every adopted position whose start the declaration did not restate fell back to that declaration's own `as_of`, `cycle_id` reminted, and the review asked again for the theses the user had already written — against the previous generation of ids. `_carry_forward_provenance` was gated on a `since_basis` stamp, and the appearance question that produces one is only asked for a position that appears *after* the book exists. Every position in a user's first declaration was therefore permanently unstampable, so unifying the two lanes (#536) would have left #539 exactly where it was. What is carried now is the start itself, read off the derived book: an answered estimate stays the user's while the record still traces the position to its anchor, and everything else still held takes the record's own start, stamped `recorded_book`. Reading it from the derived book removes the sold-and-rebought hazard rather than gating around it — a rebought position's derived start *is* its rebuy date — and it fixes the same defect where the engine already knew the answer: a position opened by a real trade had its ledger-proven start overwritten too. `cycle_seq` rides with the start, and that half is not optional. A `cycle_id` is `ticker#since#seq`, so a position flattened and bought back on one day differs from the cycle it replaced in the sequence alone: carrying the date without it hands the live position the sold cycle's identity, with that cycle's thesis, standing conditions and closed status. Verified reachable through an ordinary silent adoption, and #539 widens the door to it, which is why it lands here. One primitive, `book_refresh.carry_recorded_starts`, assembles its own inputs and holds the single `allow_engine_provenance=True` call site. Both lanes reach it: `build_adoption` for the refresh lane, `review._carried_declaration` for the review lane, which calls it before `snapshot_adapter.prepare` so the plan's cycle ids and the ledger anchor come from one stamped envelope. Doing it at finalize would have corrected the ledger after the review had already re-asked. Nothing in the question layer changed: `missing_thesis_positions` is still positions whose cycle has no thesis. A position nobody sold simply finds the one it always had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ftXNmYcwBXsntrvPdpbKx
… on (#539) Owner disposition on #539 accepted identity preservation, the derived-book input and the lower seam, and refused the one-value provenance model this branch shipped first. `since_basis: recorded_book` described how a date was transported into the new declaration, not why it is believed. It collapsed an exact start the ledger watched a cycle open together with a lower bound inferred from the day a declaration first listed the position, and the collapse is not recoverable afterwards: once a book is adopted, `origin` describes the new snapshot writer rather than the original evidence for the start. A renderer asked to state a holding period later would have had nothing left to distinguish "bought on the 14th" from "on the books since at least the 14th". So the basis is an evidence vocabulary now — `trade_event | snapshot_anchor | user_estimate | unknown` — and it travels unchanged rather than being restamped. The branch order is the protection: a position the record still traces to its anchor keeps that anchor's own basis whatever it says, and only one the anchor does not describe is labelled fresh. Recomputing from `origin` instead would have demoted an adopted trade-proven start to a lower bound on the very next declaration, silently — which is why the trades-origin test now adopts twice, and why that second adoption is what the matching mutation fails on. One guard narrowed with it: an equal-to-`as_of` start is skipped only when it would restate the `snapshot_anchor` default. For the other bases the stamp carries something the default does not, so a position bought on the day it was declared keeps its exact label. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ftXNmYcwBXsntrvPdpbKx
atomchung
marked this pull request as ready for review
July 31, 2026 04:54
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.
What changed
A declaration says what is held, never since when, so every adopted position whose start the declaration did not restate fell back to that declaration's own
as_of—cycle_idreminted, and the next review asked again for the theses the user had already written, against the previous generation of ids. This carries the start itself across adoption, through one primitive both lanes reach, and closes #539 and #536 together because they are one defect seen from two lanes.The design review that produced this shape, with receipts, is on #539 and #536; the owner disposition that revised it is here and the revision is
f9d4d55. Findings that changed the plan #27 was carrying:The stamp was never the fact worth carrying.
_carry_forward_provenancewas gated on asince_basisstamp, and the appearance question that produces one is only asked for a position that appears after the book exists (#531). Every position in a user's first declaration is therefore permanently unstampable — so unifying the two lanes, which is what #536's fix shape and #27's Front A both described, would have closed #536 and left #539 exactly where it was. Reproduced onmain@d4c7d2e: an appearance adoption moved the untouchedACME/WIDGETfrom#2026-06-30#1to#2026-07-15#1, and a second declaration through the review lane re-asked both theses and wrote a second generation of thesis rows.What is carried now is the start itself, read off the derived book — which also fixes the same defect in the case where the engine already knew the answer: a position opened by a real trade had its ledger-proven start overwritten by the next declaration too.
since_basisis an evidence vocabulary, not a transport marker. The first cut stamped every carried startrecorded_book; the owner refused it, correctly. That value says how a date was transported and destroys why it is believed, collapsing an exact start the ledger watched open together with a lower bound inferred from the day a declaration first listed a position — irrecoverably, because after an adoptionorigindescribes the new snapshot writer rather than the original evidence. The vocabulary is nowtrade_event | snapshot_anchor | user_estimate | unknown, carried unchanged.The branch order is the protection rather than the vocabulary: a position the record still traces to its anchor keeps that anchor's own basis whatever it says, and only one the anchor does not describe is labelled fresh. Recomputing from
originin both cases passes every single-adoption test and demotes an adopted trade-proven start on the second declaration, silently and permanently — so the trades-origin test adopts twice, and that is the assertion mutation D fails on.cycle_seqis not a separate nicety. Acycle_idisticker#since#seq, so a position flattened and bought back on one day differs from the cycle it replaced in the sequence alone. Carrying the date without the sequence does not merely misname the live position — it hands it the sold cycle's exact identity, and with it that cycle's thesis, standing conditions and closed/falsified status. Verified reachable through an ordinary adoption that asks the user nothing, and #539 widens the door to it (before, the live position landed on the declaration's own date and collided only by coincidence), so the two land together.The seam is one layer below where #536 proposed it. Threading the prior anchor into
snapshot_adapter.preparewould have added a secondallow_engine_provenance=Truecall site, whichtest_only_the_refresh_lane_may_write_engine_assigned_provenancecounts. Insteadbook_refresh.carry_recorded_startsassembles its ownderived/recordedinputs and holds the single privileged call site;build_adoptionreaches it for the refresh lane andreview._carried_declarationfor the review lane, beforesnapshot_adapter.preparederives anything — so the plan's cycle ids and the ledger anchor come from one stamped envelope. Doing it at finalize would have corrected the ledger after the review had already re-asked. No call reordering was needed: provenance keys never entersnapshot_reconciliation's diff.Nothing in the question layer changed.
missing_thesis_positionsis stillpositions whose cycle has no thesis; a position nobody sold simply finds the one it always had. Nothing reads the new provenance distinction either — #532 owns visibility — so what this owes is that the distinction survives, which the repeated-adoption assertions gate.Tests
python3 tests/run_all.pycomplete offline suite passes (paste the tail below).Note for anyone reproducing: the suite needs
skills/fomo-kernel/requirements.txtinstalled. Withoutpandas/yfinancepresent, nine suites fail onmainas well as on this branch, for reasons unrelated to either.New coverage, each against the lane it belongs to:
test_book_refresh.py::test_a_position_nobody_ever_asked_about_keeps_its_cycle_tootest_book_refresh.py::test_a_start_the_ledger_can_prove_is_not_overwritten_by_a_declarationtest_book_refresh.py::test_a_carried_start_never_hands_a_live_position_a_sold_cycles_identitytest_review_v2.py::test_a_second_declaration_does_not_ask_for_theses_it_already_hastest_review_v2.py::test_a_review_lane_adoption_keeps_the_holding_duration_the_user_answeredtest_ledger.py::test_every_dated_basis_reads_the_same_and_says_something_differentIf this adds or changes a checker, gate, or test
A — carry only the stamp (the pre-#539 behavior).
_carried_startreturns unchanged when the recorded row has nosince_basis.B — the review lane stops carrying (the pre-#536 behavior).
snapshot=_carried_declaration(...)→snapshot=None.The survivor is the correct partition rather than a gap: that scenario adopts through the refresh lane, and
test_book_refresh.pyowns that lane, so neither should notice a review-lane-only mutation.C — carry the start but not the sequence.
D — recompute the basis from
origininstead of carrying the recorded one. The demotion the owner's revision exists to prevent; it survives every single-adoption test.If this touches a renderer or output path
No renderer changed. The changed path reaches the snapshot anchor and the cycle ids derived from it, which two surfaces read:
since/since_basis/cycle_seq)test_book_refresh.py— the tests above plustest_a_stamped_position_keeps_the_canonical_current_book_usable, the pre-existing cross-lane oracle for a new anchor-position field, which now pins the full stamp mapmissing_thesis_positionstest_review_v2.py's two new end-to-end tests, driven through the CLI rather than the builderportfolio_basis.query_current_book(whatconsiderreads)_normalized_anchorbuilds a fresh six-key dict, so provenance is dropped before its exact-key gate and two declarations of one book keep onestate_version; asserted by the same cross-lane oracleMirrored surfaces updated:
docs/maintainer-guide.md's snapshot-position-provenance row (which claimed a call site that no longer exists — a drift the mechanical count could not catch, since the literal is still one and still inbook_refresh.py),docs/prd-ledger.md,references/data-contract.md,flows/book-refresh.mdstep 3, andschemas/book-refresh.schema.json's lane description. The flow and the data contract now state the rendering rule per basis, which the single-value model could not express: an exact start may be given as a day, a lower bound may not.Not in scope
add_count/decision_cursordo not survive adoption (ADDER#…#1#add#2→None). Pre-existing onmain, untouched by this change and not fixed by carrying the sequence; it feeds theadd_thesisdedup. Filed as [bug·P2] Adopting a book resets the add count of a position that was never sold #660 rather than widened into this PR.