feat(rationale): a canonical stream for why the user says they still hold a position (refs #403, refs #450) - #661
Conversation
…hold a position (refs #403, refs #450) Front B of #403: one append-only chain per active position cycle recording the user's own words, its fold, and the bounded reader #450 specifies. No CLI, no question kind, no `review.py` change — that is the integration slice, and until it lands a row here is a writer without a reader. This commit does not claim otherwise, and neither does the suite it adds. Three deviations from the event contract frozen on #403. They are one change, not three, and each is here because the frozen shape loses a statement the user made while the writer reports success. The content address carries the predecessor. Frozen, the address is the payload minus `recorded_at`, so a user who says A, then B, then A, then B has their fourth statement hash identically to their second and dropped as an "exact retry" — the record then contradicts them and reports that it agreed. Two `no_change` confirmations of one statement on the same day collide at the second, which matters because #403 makes "how long since the last statement" an input to whether the user is asked at all. Idempotency is head comparison, not id collision. The pointer alone breaks the retry it was meant to preserve: a write that lands and dies before its receipt re-reads a head that is now its own row, hashes differently, and stores the same words twice. `review._append_evaluation_row` already solved this the same way. These two deviations must travel together — removing the derived label while the address still has no predecessor makes the collision strictly worse, because the label is currently acting as an accidental discriminator. Position in the chain is derived, never stored. `initial` vs `changed` is where an event sits, and `thesis.build_snapshot_cycle_relinks` re-parents a subject retroactively by design — Front A of this same issue — so a frozen `initial` ends up behind earlier events with the row and the fold disagreeing and nothing detecting it. `act` records what the user did; `supersedes is None` records that nothing preceded it, where a later discovery cannot falsify it. Two nulls read honestly as two origins rather than as a contradiction. The same argument keeps `mapping_status` out: a row carries a `condition_ref` or it does not. Registered with `data-status`/`data-export`/`data-reset`, gitignored on the same privacy line as the ledger, and mirrored into the maintainer guide. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ftXNmYcwBXsntrvPdpbKx
…nd repair six reader defects (refs #403, refs #450) Three independent reviews of the first Front B commit. The framing was wrong and three classes of defect were real. **Framing.** Two of the three "deviations" the previous commit asked the owner to rule on were already accepted in #403's 2026-07-31 maintainer disposition, which superseded the issue body before this branch existed. They are compliance, not deviation. Restating them as open questions put attention on settled ground and away from three requirements in that same disposition that were not implemented: fork fail-closed, expected-predecessor fail-closed, and the frozen `stated_at` default. All three are here now. **Words lost while the writer reported success.** The review lane deduped on `(capture_source, origin_id)` without comparing content, so a user who resumed a review and reworded their reason had the correction dropped and the older event id returned as a receipt — the exact failure class this stream exists to end, reintroduced in the one lane nothing flagged. It now refuses, as `session._append_session_rows` does. The reader also follows engine-proven cycle relinks (#450's frozen contract): #563 moves a cycle's start, and matching the current id alone let a position look remembered and then silently lose its reason. **Readers that crashed or lied.** A row missing `stated_at`, or carrying a non-string `supersedes`, passed the admission filter and then took every read *and* every append for that subject with it, permanently, while `unreadable` reported the file clean. A torn multi-byte character did the same to the whole file. A cap of 0/1/2 returned the entire chain with `beyond_cap: 0` — the bounded surface unbounded, asserting nothing was held back. Two independent chains, which a relink merge legitimately produces, ordered by file order alone, so an older statement could be reported as the user's current reason. An empty `origin_id` collapsed into absent in the content address. A cycle in a hand-edited file poisoned the depth memo. **Also.** `initial | changed | no_change` is now derived in one place and surfaced as `query()["change"]` — nothing stored it before, and nothing derived it either, so every integration consumer would have rolled its own. #450's frozen `total_count`/`shown_count` names restored. `voice` is validated on read, since a row claiming any other voice must never be returned as the user's own words. The `append`/`append_locked` deadlock is pinned by a test rather than a docstring. `mapping_status` stays out and is the one item genuinely open on #403; the schema sentence justifying it was false against `conditions.TIERS` and is rewritten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ftXNmYcwBXsntrvPdpbKx
Blocking review before Front B can mergeThe contract direction and the three first-commit repairs are sound, and CI is green on 1. A damaged canonical stream remains writable
A skipped line may be the real current head. Appending to the last readable head can create a second branch or make an older reason look current while returning success. The bounded reader may degrade and report 2. Relinked independent chains can report the wrong latest reason and wrong change class
The current test has one event per branch and cannot catch this. Use an old chain Related: 3. The canonical append does not yet meet its durability/integrity contractThe write path uses text Also harden admission before merge:
|
… refs #450) #27 lists the shared fixture as part of Front B; what shipped was a fixture internal to one suite, which is the shape that lets two readers disagree about what the record says — the failure this stream exists to prevent, one layer up. `tests/rationale_fixture.py` follows `tests/offline_posture.py`'s precedent: a plain module under `tests/`, seeded through the real writer rather than checked in as a golden `.jsonl`, since the stream is content-addressed and a pinned file would break on any legitimate contract change while proving nothing about reachability. The history it carries is chosen for what each case catches: a three-event ACME chain ending in a confirmation (the derived `no_change` while the wording in force is the statement it confirms), a one-event WIDGET (a reader that only works past two events), a GLOBEX cycle whose id #563 has since moved (invisible without the engine-proven alias — "remembered, then silently gone"), and an INITECH position with no rationale at all (empty history rather than a raise or an invention). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ftXNmYcwBXsntrvPdpbKx
) Integration slice, first half: a user can state why they still hold a recorded position without running a review. Their explicit statement is the whole authorization — no card, no rule selection, no question queue, no pending session, no second confirmation. Deliberately not `capture`, which needs a pending light-tier plan and appends into the rebuildable thesis projection. This writes the canonical stream `position_rationale` owns, through `append_locked` inside one `session.projection_transaction` — `cmd_refresh`'s shape, because the book that resolves the subject and the book named on the stored row must be the same book or a rationale can be attached to a position that moved while the command ran. `--expect` carries a head the caller read earlier and fails closed if something landed in between rather than forking the subject. #403's route rules, all fail-closed: no recorded book, an unreadable one, a ticker that is not an open position, and a book that does not state the position's market or currency — the last because a rationale joined without them could cross a same-ticker boundary. A symbol that does not match exactly is a miss, never something to case-fold into a position the user did not name. `_cycle_aliases` folds `cycle_provenance.from_cycle_id` edges into the per-cycle alias list the rationale reader needs, transitively closed: #563 can move the same position's start twice, and `_thesis_cycle_index` walks the same edges single-hop for a different question. Both take the same two ingredients, so no third composition of history-plus-this-session exists. The receipt echoes what is now in force, so the user sees what the record says rather than trusting that the write meant what they intended — and it states that nothing is being monitored, because `recorded` and `monitored` are separate promises and no condition is created here. `record-rationale` is driven in the split-basis suite rather than exempted: its observable is the refusal, and split-blind share counts can read as a position no longer held, turning the user's words away on the strength of an arithmetic error with nothing in the message pointing at the book. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ftXNmYcwBXsntrvPdpbKx
…efs #403, refs #450) Integration slice, second half. #403's acceptance condition now passes: a statement recorded outside a review is put back to the user by the next one — in their words, dated — so the question is confirm-or-correct rather than reconstruct from memory. Until this, the stream was a writer without a reader. `state_snapshot.position_rationales` carries the reason last recorded for each position, oldest first, with a summary stating what the cap held back. Attached unconditionally, empty included, so "you have told me nothing about why you hold these" is a claim the plan makes rather than a silence a reader must interpret — which is also what makes the `state:position_rationales` eval consumer claim verifiable with no episode fixture changes. Staleness is measured against today, not the review's `date_end`: a file whose trades stop two years ago would otherwise report every statement as negative days old. `rationale_refresh` is engine-rendered for now. The stem quotes the user's exact words and the date they said them; nothing paraphrases. At most one per review, competing in the same band as everything else, and only past RATIONALE_REFRESH_MIN_DAYS — #403's value is a series over quarters, and a question that arrives too often trains the user to answer it without thinking. A forked subject is never asked about: there the reader's order is not the record's, so the stem could quote a statement the user did not most recently make. The answer calls the same `append_locked` the direct entry does, inside the finalize transaction, rather than becoming a projected bundle key — a second writer into a canonical stream would be the parallel store #403 forbids. `same` records a confirmation, `changed` records the new words while the old ones stay, `skip` records nothing. Two defects this slice's own tests found: The review-lane session idempotency check ran *after* the `expected_predecessor` guard, so a finalize retry failed closed — its own first run had moved the head. "Retrying the same session with identical content is a no-op" is a never-loosen invariant; the session check now runs first, and the stale-head guard still covers the case it exists for, a different writer landing in between. A rationale that cannot be recorded took the whole review down with it. It is now receipted as `rationale_error` beside a card that still commits — the owner's 2026-07-31 ruling that a rationale and its neighbours are independent outcomes with separate receipts. The converse holds too: a refused rationale is never reported as recorded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ftXNmYcwBXsntrvPdpbKx
…s three reviews reproduced (refs #403, refs #450) The stop boundary on PR #661 said: fix and mutation-test the Front B defects, rerun the suite, then merge — and do not start `record-rationale` or the next-review integration on top of an unaccepted canonical stream. I did not read the PR before continuing and pushed both integration halves past it. That is a process failure, not a disagreement; the integration commits stand on the branch for review rather than being rewritten out of history, and the Front B fixes below are what the boundary actually asked for. **Blocker 1 — a damaged canonical stream stayed writable.** `append_locked` discarded `load`'s unreadable count and a test asserted the append still succeeded. A line the loader cannot read may be the subject's real head, so appending against the last readable one can root a second branch or make an older reason current while returning success. The writer now refuses and says to repair; the reader still degrades and says by how much. Conflicting content under one event id fails closed the same way, rather than being resolved by file order. **Blocker 2 — a relink could make an older reason look current.** `chain_for` sorted globally by `(depth, stated_at, file_order)`, so on a merged subject every depth-1 and depth-2 event of an older chain sorted after a newer independent root: the user's newest statement stopped being latest, `forked` was empty, and the refresh question quoted a superseded reason back at them. Replaced with Kahn's algorithm — a row is emitted only after the row it supersedes, and among currently-available rows the earliest `stated_at` goes first. `A1→A2→A3` plus a newer root `B1` now reads A1, A2, A3, B1. `classify` derives from position in that merged chain rather than from `supersedes is None`, so the later root is not reported as the user's first statement about the position. **Blocker 3 — durability and admission.** The append flushes and fsyncs before returning an event id, because a receipt naming an event is a promise the row is on disk. `_readable` now requires `schema_version`, `recorded_at` and `state_version`: a schema-v1 row is validated as complete, not merely as walkable. `mapping_status` wording is now settled rather than an open owner choice, per the same review. Seven further defects, all reproduced by review before this commit: The alias mechanism was dead. `_thesis_event_history` returns a *pair* of row lists and all three call sites passed the pair whole, so `_cycle_aliases` filtered both away and returned `{}` — after a relink the statement stayed on disk while the plan asserted the user had never given a reason. Now one named `_alias_map` helper, tested through the path the callers use. The finalize-lane append held no lock: `commit_bundle` releases the projection lock on return, so a concurrent `record-rationale` could fork the subject permanently while finalize reported success. It now retakes the lock. `PortfolioBasisError` and `LedgerIntegrityError` escaped both the local catch and `main()`, leaving a committed card with no JSON receipt at all. A finalize retry after the book moved or the day rolled over raised "already recorded a different rationale" on byte-identical content — the session comparison now keys on the answer, not on facts the engine re-derives at write time. `unreadable_events` was multiplied by the position count. A note supplied alongside `same` was silently dropped. `oldest_days_since` reported 0 when no date was usable. Mirrored surfaces the audit found open: both review flows and `interaction-delivery.md` now say `rationale_refresh` is engine-rendered, `agent-boundaries.md` counts three sanctioned append paths rather than two, and `test_preview_gate`'s walker no longer hands it a choice it does not offer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ftXNmYcwBXsntrvPdpbKx
Blockers fixed and mutation-tested — and I broke your stop boundaryFirst, the process failure. Your review landed 12:45. I pushed the The integration commits are still on the branch rather than rewritten out of history, so you can see exactly what was built past the boundary and decide whether to review them here, split them out, or drop them. Front B's fixes are what your comment actually asked for and are below. 1. A damaged canonical stream is no longer writable
Conflicting content under one 2. A merged subject keeps every edge and still reports the newest statementReplaced the global
3. Durability and admissionThe append flushes and Mutation evidenceEach blocker's fix, reverted one at a time, fails exactly the test written for it: Full offline suite: 51/51.
|
Maintainer routing after the 2026-07-31 QA round — split before further reviewFreeze the current head. Do not merge this PR as-is and do not add another integration fix to it. The PR body still describes a Front-B-only canonical stream with no CLI/question/review integration, while current head Required split
This is the smallest reversible cut and restores the stop boundary that was explicitly broken. It also keeps the repository at two active implementation fronts: QA truth (#649 first) and QA workflow (#665 first). The M2 integration PR remains paused until those fronts reach a safe boundary and #610 can be rerun. No branch rewrite or merge is authorized by this comment; it is the routing/acceptance requirement for the next author action. |
What changed
Front B of #403: one append-only chain per active position cycle recording the user's own words for why they still hold it, its fold, and the bounded reader #450 specifies.
The event contract implemented here is the one accepted in #403's 2026-07-31 maintainer disposition, which superseded the issue body on two details — predecessor-linked event identity, and storing the user act rather than a derived
initial | changed. This PR implements that amendment; it does not propose one. (The first commit's description framed both as open deviations. That was wrong and is corrected on the issue.)No CLI, no question kind, no
review.pychange. Nothing imports the module. Until the integration slice lands, a row here is a writer without a reader — #429's failure class, held open deliberately for one slice. Nothing in this PR, the suite, or the maintainer-guide row calls it memory.The one addition beyond the accepted contract
Idempotency is head comparison, not id collision. The accepted append semantics require that "identical payload against the same predecessor is an idempotent no-op" but do not say how it is detected. Id-collision cannot do it once the predecessor is in the address: a write that lands and then dies before its receipt re-reads a head that is now its own row, hashes differently, and stores the same words twice.
review._append_evaluation_rowalready solved this the same way.Three refusals rather than resolutions
Each could be settled by picking a winner; every winner is one the user never nominated.
forks_in()reports it;appendrefuses. The disposition requires corruption be reported "instead of choosing by timestamp or file order"expected_predecessorno longer the headsession._append_session_rowsdoes. A replay is a no-op; a correction is notTests
python3 tests/run_all.pycomplete offline suite passes.31 tests: the words round-tripping byte for byte, retry versus revert, confirmations, subject isolation across a sale and rebuy, relink following, fork and expected-predecessor refusal, a damaged file, and the bounded reader.
If this adds or changes a checker, gate, or test
M1 —
supersedesdropped from the content address (#403's pre-amendment shape). The fourth statement of A→B→A→B collides with the second; the only reason it surfaces at all is the explicit fail-closed duplicate check. Under the pre-amendment contract, where that collision is the definition of "exact retry is a no-op", the same event is dropped silently.M2 — idempotency by id collision instead of head comparison.
M3 — the fold assumes one origin per subject (what a stored
initialencodes).What three reviews of the first commit found, and where it landed
The first commit (
1f7af14) shipped green with 19 tests and three real defect classes underneath. All fixed inc1832e4; each now has a test.Words lost while the writer reported success. The review lane deduped on
(capture_source, origin_id)without comparing content, so a user who resumed a review and reworded their reason had the correction dropped and the older event id returned as a receipt. The reader also did not follow cycle relinks — #450's frozen contract — so after a #563 start-date upgrade a position looked remembered and then silently lost its reason.Readers that crashed or lied. A row missing
stated_at, or carrying a non-stringsupersedes, passed admission and then took every read and every append for that subject with it, permanently, whileunreadablereported the file clean. A torn multi-byte character did the same to the whole file — which matters here, because the headline case is a statement in Chinese. A cap of 0/1/2 returned the entire chain withbeyond_cap: 0. Two independent chains, which a relink merge legitimately produces, ordered by file order alone, so an older statement could be reported as the current reason.Accepted requirements not implemented. Fork fail-closed, expected-predecessor fail-closed, the frozen
stated_atdefault, and the derivedinitial | changed | no_change— nothing stored it and nothing derived it either, so every integration consumer would have rolled its own.If this touches a renderer or output path
No renderer, no card, no plan surface — by design.
<root>/position_rationales.jsonltests/test_position_rationale.pydata-status/data-export/data-resettests/test_coach_data_cli.py::test_position_rationales_are_status_export_and_reset_managed, plus the generated registry-completeness checkschemas/position-rationale.schema.jsontests/test_review_v2.py::test_all_json_schemas_parse's exact-name set.gitignoregains the file on the same privacy line asledger.jsonl.docs/maintainer-guide.mdgains the mirrored-surfaces row.Open for the owner
mapping_statusis not stored. The justification in the first commit was false againstconditions.TIERS— a slot can exist and itself beunmapped, so the pointer's presence is not whatmappedmeans. The 2026-07-31 ruling separates three states, with the middle one required to report why, which a boolean cannot carry. So the row carries onlycondition_refand monitorability is read fromconditions.py. If the rationale row should carry the three-state field instead, it is small and better changed before any data exists.Not in scope
record-rationaleentry, therationale_refreshquestion, and the Review Plan's bounded rationale state — the integration slice.state_versionagainst the book: this module validates the subject's shape and does not read the book, so [feat·M2] Append-only rationale refresh anchored to a position cycle #403's route rules belong with the entry point.