fix(dashboard): pin expected_history_key at the remaining tags/folders forced saves (#7519) - #7714
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Extends the proven #7346 refuse-if-moved pin to the remaining sites with sound serialization and CAS rollback; scope deferrals are explicit and reasonable. Suggestions
[DESIGN-REVIEWED] 94c0bf9 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All claims verified. The mechanism pre-exists in First-Principles-Verdict: CONCERNS Every item is a declared, derived fix for #7519 — but it threads the pin through 8 call sites while the cause-level fix (force-implies-pin inside the save) stays deferred with 3 counted siblings. What this change shipsIntent: stop a sidebar action (tag/file/pin/mode) from durably writing to a different conversation when the slot rebinds mid-request — a FIX, extending #7346's existing pin.
WatchPoint pattern with counted, declared siblings: grepping [FIRST-PRINCIPLES-REVIEWED] 94c0bf9 |
GPT 5.6 Review — ✅ human override acceptedHuman judgment by @chenmingwei23 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: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe single candidate is self-rated "low" confidence and explicitly unconfirmed. Verifying its (c) — observable wrong outcome: The No further grounded findings surfaced in Step 2 — the lock acquisition, CAS rollback guards ( No findings. [OPUS-REVIEWED] 94c0bf9 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
bfe38a6 to
85ec27e
Compare
|
GPT 5.6 round 1 disposition -- FIXED in 85ec27e. Finding: value-only rollback guards can erase an EQUAL concurrent commit (same-value write + mid-save rebind: the newer save succeeds, the refused request's compare-and-set cannot distinguish its own surviving write from the equal one, and restores stale state). Fix, per the prescribed remedy: the whole re-check/mutate/persist/rollback span is now serialized -- PATCH folder/pin/mode under a new per-transcript _slot_meta_txn_lock (same shape and rationale as chat_handlers._autocompact_txn_lock, WeakValueDictionary keyed by transcript so alias slots serialize too), and POST drop under the module's existing tags_write_lock that every other slot.tags writer (PUT tags, tag-delete strip, auto-tag) already holds. Under the lock exactly one request is inside the span per transcript, so a rollback can only undo its own write. The compare-and-set guards remain as defense in depth for the non-endpoint writers that do not take these locks (the folder-delete unfile loop, which never rolls back -- it dirty-marks). Verified: 11 pin tests + 300 related tests green; isort/flake8/black gate/loop-bound-locks gate/mypy clean. |
|
GPT 5.6 round 2 disposition -- all three FIXED in 1de9745.
Verified: 12 pin tests + 300 related tests green; isort/flake8/black gate/mypy clean. |
85ec27e to
1de9745
Compare
|
GPT 5.6 round 3 disposition -- FIXED in f084270. Finding: the txn lock was keyed by the transcript key, which is exactly the identity a rebind CHANGES -- so PATCH A waiting in its save and same-value PATCH B arriving after a rebind would acquire different locks and the spans interleave anyway (A's rollback could erase B's acknowledged live change before B persists). Fix, per the prescribed remedy: _slot_meta_txn_lock is now keyed by the STATE (one shared metadata lock, WeakKeyDictionary[state, LoopBoundLock]) -- the same rebind-stable identity chat_tags._TAGS_WRITE_LOCKS already uses for every tags writer, so the folder/pin/mode spans serialize regardless of routing changes. These are rare human-driven sidebar operations, so a single per-state lock does not contend. LoopBoundLock replaces the raw asyncio.Lock for loop-rebind safety (issue 4800 convention). Verified: 122 tests across the pin file + folder/mode suites green; isort/flake8/black gate/loop-bound-locks gate/mypy clean. |
1de9745 to
f084270
Compare
|
GPT 5.6 round 4 disposition -- FIXED FORWARD in a051ee8 (the prescribed revert would reopen the cross-transcript write hole this PR exists to close). Finding (real): a slot left dirty can be picked up by the UNPINNED periodic flush while the endpoint's pinned save awaits; the flush persists the provisional in-memory value to the slot's current transcript, then the endpoint's save refuses and rolls back -- leaving the durable record holding a value the caller was told did not apply. Fix: every endpoint refusal path now marks the slot dirty after its rollback, so the next periodic flush re-persists the rolled-back live state to wherever the slot routes and the durable record reconverges within one flush interval. This is the same reconvergence mechanism the cleanup loops (tag-delete strip, folder-delete unfile, restore rollback) already use, now uniform across all 8 sites. The provisional-value window is inherent to the slot model's in-memory-first design (the flush has always written live fields unpinned); the invariant this PR holds is that the REQUEST's own durable write never lands on an unauthorized transcript, and the flush-side divergence now self-heals. Verified: 12 pin tests + 313 related tests green; black gate/flake8/mypy clean. |
f084270 to
a051ee8
Compare
|
CI note: the Backend Tests (Windows) (3) red on a051ee8 is MAIN-OWNED, not this PR's. The failing assertion is test_security_posture.py TestGateSideLogRedactorSpelling (slack/gateway.py: 7 gate-side log sites vs census 6). This PR touches zero lines in slack/gateway.py or the census; reproduced the identical failure against pure origin/main (a492b65) in a detached worktree: 1 failed. Expect the same red on any sibling shard that carries this test until main lands the census fix; this PR will rebase onto settled main to cut a fresh merge ref once that happens. All five AI review lanes are green on this head (GPT converged after 4 fix rounds). |
…s forced saves (#7519) save_slot_off_loop resolves its target transcript from live routing at write time, so a linked_session_key rebind during the persist await could redirect a durable write to a transcript the caller never authorized against. PR #7346 added the expected_history_key refuse-if-moved pin and wired it at the autocompact endpoint only. Thread the same pin through the remaining tags/folders forced-save sites: - chat_tags: the tag-delete slot strip, PUT slot tags, and the drag-drop status reassign. - chat_folders: the folder-delete unfile loop and its restore rollback, PATCH slot folder, PATCH slot pin, and PATCH slot mode. The five request endpoints capture the authorized key BEFORE their first await and re-check it (plus slot object identity) after the last await before mutating, mirroring the reauthorize-then-capture shape of the autocompact precedent. Each site handles the refusal per its own convention: the direct mutation endpoints roll back and return 409 session_gone, the drag-drop endpoint answers in its own ok:false rejection shape, and the best-effort cleanup loops mark the slot dirty for the periodic flush and keep going. Rollbacks are compare-and-set (a concurrent writer's acknowledged commit is never erased) and restore the prior _folder_changed latch rather than clearing it. Same-class force=True sites outside this issue's tags/folders scope (slot-recreate in chat_handlers, chat_auto_tag, crew_chat) are left for a follow-up. Closes #7519
|
Rebased onto main 213f805 (the gate-side census fix has landed on main -- verified by running the census test against pure origin/main in a detached worktree: 1 passed). New head 94c0bf9 is the same single commit, byte-identical diff (4 files); scoped gates re-ran green locally (black gate / flake8 / mypy / 104 tests). This cuts a fresh merge ref past the main-owned red; all review lanes re-roll on the new head. |
a051ee8 to
94c0bf9
Compare
|
/ai-review override gpt 94c0bf9: Zero-delta vs base 213f805: the mutate-then-await shape and the unpinned periodic flush exist verbatim on main (identical shape in the merged autocompact endpoint), so this finding is a pre-existing hole belonging to another issue; this PR strictly narrows that window (pin + reauth + state lock + dirty reconvergence) on a diff byte-identical to a051ee8, which this lane passed. Residual class filed as issue 7772. |
|
GPT 5.6 round 5 (post-rebase re-roll on the byte-identical diff) disposition:
|
Human judgment recorded@chenmingwei23 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. |
|
First Principles CONCERNS disposition -- ACCEPTED AND DEFERRED (matching the review's own framing). The cause-level candidate (force-implies-pin inside save_slot_off_loop, retiring the kwarg threading at all 11 sites) is now recorded on issue #7772 together with the design question that gates it: whether any forced caller must legitimately follow a rebind. This PR deliberately stays at the call-site pattern #7346 established because answering that question changes the save's contract for ALL callers -- an architecture decision for the follow-up, not a mechanical extension of this diff. The three counted unpinned siblings are item 1 of the same issue. |
|
Review-ready on head 94c0bf9: PR Readiness = success (readiness: passed label applied), all 68 deduped check-run lanes green including CodeQL, and all five AI review lanes verdict-clean -- Opus/Design/UX pass, First Principles CONCERNS dispositioned (accepted-and-deferred to issue #7772), GPT via the recorded human override on this head after 4 fixed rounds + 1 zero-delta round. Round ledger: r1 rollback-erases-equal-commit (FIXED: state txn lock + tags lock serialization), r2 SEL-on-denials + stale busy guard + drop resurrects deleted tag (all FIXED), r3 lock keyed by rebindable transcript key (FIXED: state-keyed LoopBoundLock), r4 provisional value escapes via unpinned flush (FIXED FORWARD: dirty reconvergence; prescribed revert rejected as reopening the hole), r5 same finding re-raised on a byte-identical diff the lane had passed (zero-delta vs base, overridden; residual + siblings + pin bool-coercion filed as issue #7772). Branch untouched since the override (push voids it). |
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: fix with a clear root cause -- the remaining forced-save call sites in chat_folders.py/chat_tags.py did not pin expected_history_key, so a concurrent write could clobber history; the fix pins it at each of them (#7519).
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
Problem / Motivation
save_slot_off_loop/_save_slot_to_historyresolve their target transcript from live routing (linked_session_key) at write time. A rebind during the persist await -- a cron completion or workflow injection rebinds already-live slots with norunninggate -- can therefore redirect a durable write to a transcript the caller never authorized against. PR #7346 added theexpected_history_keyrefuse-if-moved pin (the save returnsFalse, writing nothing, when the live key has moved off the pinned one) but wired it only at the autocompact endpoint. The remaining forced-save sites in the tags/folders handlers shared the same rebind window and were still unpinned.Why it matters
A user action aimed at one conversation (tagging it, filing it, pinning it, switching its mode, deleting a tag or folder) could durably mutate a different conversation's transcript metadata if the slot rebinds mid-request. That is a cross-session write-integrity hole: the caller's authorization covered one transcript and the bytes landed on another, with no error and no audit trail.
What changed (motivation -> approach -> change)
Thread the pin through the 8 remaining forced-save sites, following the autocompact precedent:
chat_tags.py: the tag-delete slot strip loop,PUT /slots/{slot}/tags, and the drag-drop status reassign (POST /slots/{slot}/drop).chat_folders.py: the folder-delete unfile loop and its_restore_unfiledrollback,PATCH /slots/{slot}/folder,PATCH /slots/{slot}/pin, andPATCH /slots/{slot}/mode.The five request endpoints capture the authorized key before their first await and re-check it (plus slot object identity, mirroring
_reauthorize_after_await) after the last await before mutating, so a rebind during body parsing, lock waits, or the mode endpoint's busy probes is refused before anything mutates. The persist window itself is covered by the save's pin.Refusal handling matches each caller's existing convention rather than one uniform policy:
409 session_gone-- the autocompact disposition.ok: false+ reason, SELrejected), so the card stays put.Hardenings from review: each endpoint's re-check/mutate/persist/rollback span is serialized -- folder/pin/mode under a new per-transcript
_slot_meta_txn_lock(the autocompact txn-lock shape), and the drag-drop reassign under the module's existingtags_write_lockthat every otherslot.tagswriter already holds -- so a rollback can only ever undo its own write (value-based rollback cannot tell "my write survived" from "someone else wrote the same value"). Rollbacks additionally stay compare-and-set as defense for the non-endpoint writers that do not take these locks (the folder-delete unfile loop), and the folder endpoint restores the prior_folder_changedbreadcrumb latch instead of clearing it (a pending re-injection from an earlier successful move survives a later refusal).Reviewed and deliberately NOT changed:
_unhide_folderwrite is not compensated on the new 409 path: re-hiding could erase a concurrent legitimate unhide, and a visible empty folder is benign and user-correctable.force=Truesites outside this issue's tags/folders scope (slot-recreate inchat_handlers.py,chat_auto_tag.py,crew_chat.py) are left for a follow-up issue.Tests
test/test_forced_save_history_key_pin.py(11 tests): two real-save tests drive endpoints through the real_save_slot_to_historywith routing rebound mid-persist (409, rollback, nothing written to either transcript -- mutation-sensitive: an unpinned save reddens the foreign-meta assertion); per-site disposition tests for all 8 sites assert the refusal handling and that the pin kwarg equals the pre-request key; hardening tests cover the preserved breadcrumb latch, the concurrent-writer-wins rollback guard, and a real lock-window rebind refused by the post-await re-check before any mutation.None(now falsy at the new refusal branches) returnTrue, restoring their documented fidelity.isort,flake8, the baselined black gate, and fullmypy(1,243 files) all clean.Manual verification
Not applicable -- backend-only; behavior on the success path is byte-identical at every site (verified in review), and the refusal paths are only reachable through the mid-request rebind race, which the real-save tests exercise deterministically.
Screenshots / video
Not applicable (no UI change).
Related Issues
Closes #7519
Pattern harvest
Rule candidate: a mutate/save/rollback span with awaits inside must be serialized per resource (txn lock), with compare-and-set rollback as the fallback where a writer cannot take the lock: value-based restore cannot tell "my write survived" from "someone else committed after me", so an unconditional restore erases a concurrent writer's acknowledged commit. The same class produced
_autocompact_txn_lockand the guarded rollback in_restore_unfiled; candidate for the recurring-defect list if it recurs once more.Checklist
chat_persistence.pydocstrings from feat(chat): per-session auto-compact threshold slider in the context popover #7346)Contribution License Agreement
By submitting this pull request, I confirm my contribution is made under the terms of the project's license.