fix(history): pair a frozen save snapshot with its prefix boundary - #8421
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound, honest fixes to two real races, but both land as opt-in/narrowed patches whose complete fix is deferred to #8419 — humans should ratify that sequencing. Watch
Suggestions
[DESIGN-REVIEWED] d544f53 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All claims verified: the 503/rollback machinery and First-Principles-Verdict: CONCERNS The boundary pairing lands on the shared save but is wired to one of its four freeze-and-await callers; regenerate's three stay duplicable, and the declared successor keeps that exposure. What this change shipsIntent: stop a mid-save window trim from duplicating transcript rows, and a mid-save rebind from marking unwritten rows persisted — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] d544f53 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe candidate correctly observes that So (a) has no concrete input and (c) has no observable failure. What remains is a comment-accuracy nitpick on a data-safety path — which fails the survival bar and falls in a category this pipeline does not report. It dies under falsification. No groundable Step 2 finding follows from the same observation, for the same reason: the stale cache is harmless absent an unrealistic three-way file-identity collision. No findings. [OPUS-REVIEWED] d544f53 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ human override acceptedHuman judgment by @bolichen97 overrides the GPT 5.6 finding for This comment is updated in place on each push. The model was not re-run because an authorized human decision supersedes it. False positive or not applicable? A repository writer can comment: |
4ce92ee to
e48f2c5
Compare
|
🤖 Kiro Crew Auto-Pipeline [operator: bolichen97#bb3ad1ca] GPT round 1 ( Design Review (CONCERNS) — per-finding response:
|
|
🤖 Kiro Crew Auto-Pipeline [operator: bolichen97#bb3ad1ca] Design round 2 (
|
e48f2c5 to
41a2a49
Compare
|
🤖 Kiro Crew Auto-Pipeline [operator: bolichen97#bb3ad1ca] GPT round 2 (
One PR, one shape: everything endpoint-local and newly-introduced is fixed here (rounds 1–2: arrival erasure, cap-safe retention); everything shape-level and shared with the merged reference is batched in #8419 so the two implementations move together. |
|
🤖 Kiro Crew Auto-Pipeline [operator: bolichen97#bb3ad1ca] First Principles (round 1) + Design (round 3) on
FP's ships-enumeration (10/10 justified) is appreciated — no contested surface remains in either lane. |
41a2a49 to
a74dda2
Compare
d51533b to
b0ec299
Compare
|
🤖 Kiro Crew Auto-Pipeline [operator: bolichen97#g2-8421] GPT round 7 ( 2. Reverted, not replaced, because the commit cannot know the right value. Your suggested "commit the exact saved window count" requires the count the save wrote, and the commit has no access to it: a trim can land on either side of the stamp and the two are indistinguishable afterwards, and The two boundary counters keep their re-adoption unchanged, and the distinction is exactly the one this finding turns on: the save does not stamp 1. Round 6 removed the Why it is not in this PR: it is a This is where I stop and ask for a maintainer. Seven rounds have produced three real fixes (the original frozen-window/live-boundary duplication, the durable position base, the assignment-only stamping region), two corrections of my own work, and two findings whose stated harm did not survive measurement. The two standing findings both resolve to the witness-record change above. The options are a Also rebased onto current |
|
🤖 Kiro Crew Auto-Pipeline [operator: bolichen97#g2-8421] GPT round 8 ( "Edit-resend on an unlinked cron slot → cron result binds it during the worker save → old transcript is truncated, then the endpoint returns 503 without committing live state." That outcome is this transaction's specified contract, not a defect in it, and it is what this PR introduced to REPLACE something strictly worse. The pre-change endpoint is on the base commit of this branch and can be read directly: it did And the truncated turns are not lost. An explicit-snapshot save takes the archive-safe rewrite path, so Committing live state anyway would be the actual defect. The slot now routes to the cron transcript; publishing the edited branch onto it would put one conversation's edited history on another conversation's slot. Refusing is the only correct disposition once the write has landed on the authorized transcript, which is why the merged reference implementation answers the same condition the same way — The remedy is the same one as rounds 5, 6 and 7, and I have declined it four times for the same reason. "Serialize rebinding with the save, witness update, and live-state commit using shared routing-keyed state" means a rebind gate spanning the event loop and a worker thread. The worker cannot take Where this PR stands, stated plainly for whoever picks it up. Eight lane rounds. Three real defects found and fixed (the original frozen-window / live-boundary duplication — demonstrated writing The one standing finding is a residual whose remedy is a persistence-core refactor I am deliberately not making inside this PR. Two maintainer decisions are needed and neither is mine:
Everything else is green: on head |
The rewind boundary transaction freezes a window snapshot on the event loop and then awaits the save in a worker thread, where _save_slot_to_history read the LIVE _disk_older_count. A trim at the window cap in that gap credits the trimmed rows to the frozen prefix while the snapshot still holds them at its head, so the rewrite emitted them twice -- a silently duplicated transcript, reachable on origin/main today. The endpoint now passes the boundary it observed in the same synchronous stretch as the snapshot. The save refuses on drift (nothing written, the retryable 503 the transaction already contracts for) and the commit re-adopts that boundary, because the commit puts the pre-trim window prefix back on the live window. The durable position base (_disk_older_durable_count) is re-adopted with it: the trim advances the two together, and a row counted as having left the window front while it is back inside it refuses a valid absolute cursor or repeats rows. The post-write witnesses are gated on the routing for the same reason: they describe the file just written but live on the slot, which the loop can rebind mid-write. Stamping then cleared a _pending_rewrite the new transcript still owed and claimed its unsaved rows as persisted, which a later flush believed. Every witness left at its pre-save value is the conservative reading of that witness. Split out of the edit-resend port (#8431 carries the endpoint); both defects live in the shared save and are reachable on rewind without it.
b0ec299 to
d544f53
Compare
|
Scope reduced — this PR is no longer the edit-resend port. Duplicate-implementation decision (item 2 of the round-8 note): #8431 is the vehicle for #7838. The endpoint half has been dropped from this PR rather than left to conflict wholesale. Reverted to What remains is the part #8431 does not touch at all — 4 files, +377/-43:
Both defects are reachable on One follow-up this exposes: The |
|
GPT round 9 ( The finding verbatim, from the lane's extraction step: The race is real and the harm chain is coherent — I traced it rather than re-asserting the earlier disposition. What is new, and what the fence's You will see And it cannot be closed here. The only fix is atomic publication of the five as one routing-keyed record, and I checked the two cheaper repairs before concluding that:
So the position is unchanged from rounds 5-8, and now with the delta measured: full remedy on #8419 ( Override rationale, if a maintainer chooses that path — stated so it can be checked rather than trusted: this change strictly narrows a pre-existing unconditional-stamping race (origin/main has no routing check in that block at all) and introduces no new one; the residual's only remedy is the routing-keyed witness record tracked on #8419, whose partial application would weaken a fail-open security guard. The two verifications are the |
|
/ai-review override gpt d544f53: residual of a wider main defect this PR narrows -- main stamps all five witnesses with no routing check and stats the file inline (chat_persistence.py:3357-3394), while the delete-won guard's evidence gate at :2992 fails OPEN, so a partial migration is unsafe. The full fix is tracked on open issue #8419. |
Human judgment recorded@bolichen97 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (4 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: a frozen messages snapshot was written against a _disk_older_count read after a concurrent cap-trim, so trimmed rows landed twice — once in the frozen prefix, once at the head of the snapshot. The save now takes the paired expected_disk_older_count and refuses on drift, and rewind's commit restores the boundary the file was actually written against. Spec files changed as a ride-along (a minority of the diff on both file count and changed lines), not reviewed as a design decision: docs/system-specs/modules/history.md.
Why no screenshot: backend-only change to the shared transcript-save primitive and the
rewindendpoint's boundary transaction; no UI surface is touched.Problem / Motivation
Two defects in
_save_slot_to_history(src/kiro_crew/dashboard/chat_persistence.py), both reachable through the shippedrewindendpoint (chat_rewind.py:233-514, merged in #8145):A frozen window snapshot is written against a live prefix boundary. A boundary transaction freezes
msgs_snapshoton the event loop and then awaits the save in a worker thread, where the save read the liveslot._disk_older_count. That counter is where the frozen prefix ends. Anappendat the window cap in that gap trims the front of the window and credits the trimmed rows to the counter — so the file written isfrozen_prefix(after the trim) + snapshot(frozen before it), which emits the trimmed rows twice. Demonstrated with the fix removed: a window at its cap plus one arrival landing during the save writes["u0", "u0", "a0", "edited"].Post-write witnesses are stamped onto a slot the loop may have rebound mid-write.
_pending_rewrite,_disk_window_len,_disk_meta_created_at,_disk_meta_observedand_frozen_prefix_cachedescribe the file this save wrote, but they live on the live slot. The save's routing pin is checked before the write, so the bytes always land on the authorized transcript — but a rebind landing after that pin (a cron injection re-linking the slot) leaves the stamping describing the old file on a slot that now writes a new one: it cleared a_pending_rewritethe new transcript still owed and claimed its unsaved rows as persisted, which a later flush believed.Why it matters
The first is a silently duplicated transcript — no error, no warning, and the user sees a turn twice in their own history. The second hands a later flush a false "already persisted" reading of a transcript that was never written, which is how unsaved rows get dropped and an owed archive-safe rewrite gets skipped.
What changed (motivation → approach → change)
_save_slot_to_historytakesexpected_disk_older_count(keyword, defaults toNone= today's behaviour for every other caller). A caller that freezes its ownmessagessnapshot cannot use the existing bounded snapshot retry — the snapshot is already frozen — so it passes the boundary it observed in the same synchronous stretch as the snapshot. Drift refuses the save:False, nothing written, which is the retryable503these transactions already contract for. Ignored without an explicitmessages, where the bounded retry already takes both halves together.slot_history_key(slot)against the key it wrote and skips the stamping on a mismatch, logging it. Every witness left at its pre-save value is that witness's conservative reading — the next save re-reads the prefix, re-takes the archive-safe path, and re-observes the file. TheConversationLogcache invalidation is keyed on the file that was written and stays unconditional. Everything the stamp needs (the post-writestat, the carried-forwardcreated_at) is hoisted above the re-check so the guarded region is assignments only — a save runs in a worker thread, and a syscall inside that region was the realistic point at which the loop got to rebind under a half-applied stamp.rewindpairs its snapshot with its boundary.pre_await_disk_older_countandpre_await_disk_older_durable_countare captured besidemsgs_snapshotwith no await between them, the first travels to the save, and the commit re-adopts both — because the commit puts the pre-trim window prefix back on the live window. The durable position base moves with the disk boundary for the same reason and on the same rows: a row counted as having left the window front while it is back inside it either refuses a valid absolute cursor (since < base) or repeats rows. A trim landing after the worker read the boundary cannot be refused (the correct file is already written), so it is corrected at the commit instead._disk_window_lenis deliberately left possibly SHORT, and the direction is the argument. The save stamps it absolutely, so a trim landing before the stamp has its decrement erased while one landing after it does not, and the commit cannot tell the two apart without the count the save actually wrote — which is notlen(msgs_snapshot)either (a note row authorized elsewhere is filtered out of the write). Over-claiming is the harmful direction: a later trim then credits rows to the frozen prefix the file does not hold, and the next save re-emits window rows — the duplication this PR exists to prevent. Short costs an under-credited prefix, a warning about rows that are in fact on disk, and one whole-file re-read; the foreign-append merge preserves the on-disk window line the memory window has dropped, so no row is lost.docs/system-specs/modules/history.mddocuments all four points in the same commit, including the residual below and its remedy, so that knowledge is in the spec rather than only in this thread.Standing reviewer finding — maintainer decision needed
GPT 5.6 Reviewhas held one finding across rounds 5-8, anchored at the routing gate above (chat_persistence.py, theif slot_history_key(slot) == history_key:region): the route check and the witness assignments are not atomic against the event loop. It is real and it is narrowed as far as this line can be narrowed — the region is now five plain attribute assignments with no yield point, thepath.stat()it used to contain having been hoisted out.What remains needs an atomic multi-field publish, which the reviewer's own remedy names correctly: one routing-keyed record. Concretely, collapsing
_pending_rewrite,_disk_window_len,_disk_meta_created_at,_disk_meta_observedand_frozen_prefix_cacheinto a single_ChatSlotfield carrying thehistory_keyit describes, published by one assignment and validated by consumers against their own routing. That would also give the commit the saved window count_disk_window_lenis missing above, so both halves have the same fix.Why it is not in this PR: it is a
__slots__field-shape change with five consumers to migrate (channel_slots.py,chat_persistence.py×3,chat_handlers.py) plus the delete-won guard, which reads two of those fields independently and fails OPEN when they are unset — so a partial migration weakens a security guard rather than a cache. That is a persistence-core change with its own design and its own tests; it is recorded on #8419 with the consumer list and the rounds 5-8 analysis. Clearing the lane needs either that change landing first, or a maintainer/ai-review overridefor the residual. Not a call this PR makes.Follow-up scope (noted, not expanded here)
save_slot_off_loopdoes not forwardexpected_disk_older_count. Its keyword list is explicit, so a boundary transaction routed through it still reads the live counter in the worker and keeps exposure 1. fix(dashboard): make edit-resend a real conversation boundary #8431's edit-resend saves that way. Threading the parameter through is a small follow-up once both land;history.mdstates the gap.session.md's edit-resend section.Tests
test/test_dashboard_chat_rewind.py(38 pass, against the real_save_slot_to_history/ConversationLogexcept where a boundary is deliberately failed):test_rewind_pairs_the_snapshot_with_the_frozen_prefix_boundary— the wiring: the endpoint hands the save the PRE-await boundary, not the one a worker would have read, and re-adopts both counters at the commit.test_rewind_refuses_when_a_cap_trim_moves_the_frozen_prefix— the save's own refusal, for real: window at cap with the rows on disk, an arrival landing between the native clear and the rewrite →503 rewind_save_failed, no dispatch, transcript byte-identical.test_rewind_leaves_the_witnesses_alone_when_a_rebind_wins_the_write— a rebind insideatomic_write→503 rewind_slot_rebound,_pending_rewritestill owed, window still unpersisted.Mutation-verified, each caught by exactly one of the two real-save tests: neuter the drift refusal (
if False:) →test_rewind_refuses_...fails; make the witness gate unconditional (if True:) →test_rewind_leaves_the_witnesses_alone...fails.Targeted runs: 38 pass in
test_dashboard_chat_rewind.py; 849 pass acrosstest_chat_regenerate_cov80.py,test_forced_save_history_key_pin.py,test_history_composition_contract.py,test_persist_off_loop.py,test_dashboard_chat.py; 2,672 pass + 4 xfailed across all 37 test modules that reference_save_slot_to_historyor any of the three witness counters. Local gates clean on the committed range:check_black_formatting,check_subprocess_encoding,isort,flake8 src/kiro_crew test,mypy --platform linux src/kiro_crew(1,294 files),check_brand_name,check_harness_parity,docs-lint,check_changelog_history.Manual verification
N/A —
rewindis exercised end to end through the aiohttp test client against the real persistence layer, on both sides of the stamp; there is no UI change.Related Issues
Split out of the edit-resend port; #8431 carries the endpoint and closes #7838. Shape source: #8145. Residual hardening: #8419. Context: #7838.
Pattern harvest
Rule candidate: review-prompt
Pattern: "a snapshot handed across an await must travel with every counter that describes where it belongs, not just its own contents — and post-write bookkeeping that lives on a mutable object must be gated on the object still describing what was written." Generalizes beyond this save: any worker-thread write whose bookkeeping lands back on an event-loop-owned object has the same two halves, and the correct end state for both is one routing-keyed record published by a single assignment (#8419).
Checklist
docs/system-specs/modules/history.md, same commit)