Repository navigation
fix(reflection): refuse stale approvals at apply time - #23
Conversation
An accepted reflection candidate could be applied after another session had resumed or edited the target task: reflect_decide stored only the decision, and reflect_apply mutated the task without comparing it to the state the reviewer saw. Bind each candidate to the task row and ordered task_field_versions captured when the candidate is created. reflect_apply re-reads the decision, idempotency marker and current task state under SQLite's writer lock; any mismatch leaves the task untouched and records a durable apply_error that requires a new run and a new review. - freeze the full task row and field versions in reviewed_state_json - compare under the writer lock (BEGIN IMMEDIATE for the MCP tool, savepoint plus no-op UPDATE for direct DAO callers) - mutation, ledger, field versions and apply snapshot share a savepoint - idempotent migration adds nullable reviewed_state_json/apply_error_json - legacy candidates without a snapshot fail closed (missing_review_state) Reported in a Medium response to "Reviewable Memory Consolidation for Local AI Agents". Refs: ClickUp 869fghj8b Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hdie6u9nXibq7PSu6xv9RT
list_candidates decoded the full frozen state (the whole task row plus every field version) into each listed candidate. On a copy of a real 2,834-task database the default reflect_review grew from ~15.5k to ~499k characters, an operator-sized page to ~2.5M characters, and one candidate whose note had a 296k-character description to ~308k characters, beyond MCP client output caps. Listings now carry only reviewed_state_sha256 of the stored snapshot; get_candidate, which the apply path uses, still returns the full state. Measured on the same copy: default page 17.8k characters, operator page 88.7k characters, largest row 944 characters. Refs: ClickUp 869fghj8b Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hdie6u9nXibq7PSu6xv9RT
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
🔵 Needs a closer look
It changes transaction/locking and compare-and-mutate semantics on a critical data-mutation path, which warrants human verification despite the thorough test suite.
1 open finding
What changed in this PR
This PR fixes a correctness bug in the Phase‑1 reflection pipeline where an accepted reflection candidate could be applied even after another SQLite session had changed the target task, silently clobbering concurrent work. The fix freezes the full target‑task state at candidate creation, then re‑validates it under SQLite's writer lock at apply time and fails closed (with a durable, human‑visible refusal) on any mismatch, legacy, or missing snapshot — requiring a fresh run and explicit re‑approval.
Changes:
- Freeze the
tasksrow + orderedtask_field_versionsintoreviewed_state_jsonon candidate creation;decidenever refreshes it, and listings carry only a sha256 digest to stay within MCP output caps. - Guard apply behind a savepoint + no-op
UPDATEwriter lock (andBEGIN IMMEDIATEfor the MCP tool), re-reading decision/idempotency/freshness atomically; mismatches recordapply_error_jsonand skip withstale_review/missing_review_state. - Idempotent migration adds nullable
reviewed_state_jsonandapply_error_json; comprehensive new freshness/concurrency tests added.
| File | Description |
|---|---|
reflection_dao.py |
Adds candidate_write_transaction, task_review_state, record_apply_refusal, snapshot freezing in add_candidate, and apply-error gating in decide_candidate. |
reflection_apply.py |
Replaces after-the-fact snapshotting with _apply_candidate that re-validates frozen state under the writer lock and applies atomically. |
intel_server.py |
Marks reflect_apply as write=True (BEGIN IMMEDIATE) and documents the freshness/fail-closed contract. |
schema.py |
Adds reviewed_state_json / apply_error_json columns plus idempotent guarded migrations. |
tests/test_reflection_freshness.py |
New tests covering staleness, ABA, legacy/malformed state, atomic rollback, concurrency, and the MCP-tool path. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| def candidate_decision_counts( | ||
| conn: sqlite3.Connection, run_id: str | ||
| ) -> dict[str, int]: | ||
| def record_apply_refusal(conn, candidate_id: str, reason: str, applied_by: str) -> None: |
…20261014 style(reflection): annotate conn in record_apply_refusal (Copilot finding on #23). Refs: ClickUp 869fghj8b
Every other DAO function in reflection_dao declares conn: sqlite3.Connection. Copilot review finding on PR RMANOV#23. Refs: ClickUp 869fghj8b Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hdie6u9nXibq7PSu6xv9RT

Problem
An accepted reflection candidate could be applied after another session had changed the target task.
reflect_decidestored only the decision, author and time;reflect_applythen calledapply_task_mutationwithout the reviewed values or versions. Before/after snapshots recorded the mutation after the fact but did not stop it.Reproduced on
main(b758842) with a temporary SQLite database and separate connections:stale_overdue_taskscandidate;reflect_decide(..., "accept");status=in_progressand new notes;reflect_applywith the old approval.Result on
main:applied=1, no refusal, the resumed task becomesarchived, and an apply snapshot is written.Fix
tasksrow and its orderedtask_field_versionsare frozen inreviewed_state_json.reflect_reviewshows that snapshot;acceptnever refreshes it.reflect_applytakes SQLite's writer lock (BEGIN IMMEDIATEfor the MCP tool; savepoint plus a no-opUPDATEfor direct DAO/autocommit callers). Under the lock it re-reads the decision, idempotency marker, run state, refusal marker and the current task state.applied=0,skipped.reason=stale_review, and a durableapply_error_json(reason, actor, time,requires_new_review=true). The human decision is kept, the candidate cannot be re-accepted, and a new run plus a new explicit approval are required.reviewed_state_jsonandapply_error_json. Legacy candidates without a snapshot fail closed withmissing_review_state; nothing is re-approved automatically.Changed:
reflection_dao.py,reflection_apply.py,intel_server.py,schema.py. Added:tests/test_reflection_freshness.py. No existing test is modified.Verification
Run on this PR's commits:
76787c7(fix) and4c9800b(compact review listings).4c9800b: full suite, ruff, manifest gate and registry smoke pass.76787c7: full suite 2093 passed, 30 skipped.mainwith only the new test file (Windows, Python 3.12.13):test_reopened_task_survives_old_approvalfails (in_progress → archived) andtest_unchanged_approval_applies_oncepasses.tests/test_reflection_freshness.py: 24 passed.76787c7: 2095 passed, 9 failed. Each failure also fails onmain, is flaky, or is Windows-specific.tests/test_reflection*.py168 passed.stale_review.4c9800bfix came from this check. With76787c7, the defaultreflect_reviewgrew from 15.5k to 499k characters, over MCP client output caps. With4c9800bit is 17.8k characters, and the largest row is 944 characters.The commits went through the owner's
ruff formatpre-commit hook.76787c7is AST- and comment-identical to the reviewed patch (sha256987ff013…b3e1):reflection_apply.pyandreflection_dao.pydiffer only in line wrapping, and the other three files are byte-identical.The original patch package, run on 13 Oct 2026 on Linux with Python 3.12.14, reported the following. These are the package's numbers, not re-run here:
Not run yet, deferred: adversarial locking probes on WAL, mutation testing of the guards, and a consumer audit.
Limits
database is lockedon upgrade; it makes no mutation and must retry. The MCP path starts withBEGIN IMMEDIATE.reflect_reviewlistsreviewed_state_sha256instead of the full frozen state;get_candidate(used by apply) still returns the full state.🤖 Generated with Claude Code
https://claude.ai/code/session_01Hdie6u9nXibq7PSu6xv9RT