Skip to content

fix(feedback): atomic posterior write, one transaction, and a lock floor (#1168) - #1183

Merged
github-actions[bot] merged 6 commits into
mainfrom
fix/issue-1168-feedback-atomicity
Jul 30, 2026
Merged

fix(feedback): atomic posterior write, one transaction, and a lock floor (#1168)#1183
github-actions[bot] merged 6 commits into
mainfrom
fix/issue-1168-feedback-atomicity

Conversation

@robotrocketscience

@robotrocketscience robotrocketscience commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Closes #1168.

Lost updates

apply_feedback was an unsynchronised read-modify-write — get_belief, arithmetic in Python, then a whole-row update_belief writing the stale snapshot back — across two autocommit transactions with busy_timeout as the only concurrency control.

Reproduced by the new race test against the pre-fix code (8 threads × 15 events on one belief):

beta: expected 121.0, got 16.0     # 87% of evidence discarded
feedback_history rows: 120         # the log recorded every event

The posterior that drives L1 rerank was permanently inconsistent with its own append-only log, with no error signal and no reconciliation path.

Fix: a new atomic MemoryStore.bump_posterior issuing SET alpha = alpha + ?, beta = beta + ?, sharing one transaction with the audit-row insert via a new transaction(immediate=True) mode (BEGIN IMMEDIATE, so the write lock is taken up front and contention resolves as a wait rather than as a lost upgrade).

Lock clobber

Because update_belief writes every column from an in-memory snapshot, a feedback write landing on a pre-lock snapshot could revert lock_level / locked_at / origin committed by a concurrent aelf lock.

bump_posterior writes only the two posterior columns, so the window cannot exist. test_feedback_write_path_never_writes_lock_columns proves this structurally rather than probabilistically — it traces the SQL the path emits and asserts no beliefs UPDATE assigns any other column. That test also fails on the pre-fix code.

Lock floor

apply_feedback never read lock_level. scoring.decay() had the floor, but decay() is dead. So:

  • aelf feedback <locked-id> harmful moved a user lock.
  • Worse: with sentiment_from_prose, L0 locks are injected on every prompt and so sit in every turn's pending set — ten "no"/"that's wrong" turns could drag a lock from μ=0.947 to μ=0.367.

docs/user/LIMITATIONS.md:38 and docs/user/PRIVACY.md:78 both promised this could not happen.

Passive feedback now records the event and holds the posterior. Per your call on the scoping question:

Surface Behaviour on a locked belief
CLI aelf feedback audited, warns and points at aelf unlock, exit 0
MCP aelf_feedback audited, returns kind: feedback.locked_not_applied
sentiment / retrieval exposure / valence propagation audited, no move
aelf confirm / aelf_confirm unchanged — α += 1.0

confirm is exempt because COMMANDS.md:30 defines it as explicit user affirmation "distinct from ... implicit retrieval feedback". It passes respect_lock=False; that is an explicit parameter, not a sniff of the user-supplied --source string.

Two existing tests asserted feedback moving a locked posterior (test_cli.py::test_feedback_used_increments_alpha, test_mcp_server.py::test_end_to_end_lock_search_feedback_demote). Both are rewritten to the documented behaviour, and a positive test for the floor is added alongside.

The three bypassing paths

Path Disposition
deferred_feedback.sweep_deferred_feedback Fixed. Keeps its own per-row BEGIN IMMEDIATE and queue bookkeeping, but now honours the lock floor and the #655 federation-ownership check, draining ineligible rows rather than letting them build a backlog. New skipped_locked / skipped_foreign counters.
clamp_ghosts.clamp_ghost_alpha Documented. Already asserts lock_level='none' in its own WHERE clause and writes a reversing feedback_history row; its module docstring covers the rest.
consolidation dedup sum Documented. Sums existing evidence when collapsing a duplicate group — a merge, not new evidence, so there is nothing to audit.

PHILOSOPHY.md's "there is one writer of (α, β)" is replaced with the invariants that actually hold across all four writers.

One thing I did NOT do — needs an operator call

The audit's fifth finding argues the deferred sweeper should be gated on the #1086 exposure_updates_posterior flag (or that is_enqueue_on_retrieve_enabled should default to False), on the grounds that it re-instates the junk percolation #1086 removed.

I did not do that, because I don't think it's a bug fix. The implicit lane shipped under #191/#256, before #1086, and it is a materially different mechanism: a much smaller epsilon (0.05 vs 1.0) behind a 30-minute grace window that any explicit correction or contradiction cancels. #1086 turned off the immediate exposure→posterior bump in hook_search; it did not touch this lane. Whether the lane should exist at all — and whether aelf sweep-feedback should become a posterior no-op by default — is a product decision, not something to fold into a concurrency fix. Flagging it rather than deciding it.

The sixth finding (an applied_to_posterior / d_alpha / d_beta column on feedback_history so the posterior becomes a genuine fold over the log) is a schema migration and belongs with the determinism umbrella #1157, whose first AC is making the log total. Not in scope here.

Acceptance criteria

  • bump_posterior() issuing SET alpha = alpha + ? in a single statement
  • Wrap the posterior write and the feedback_history insert in one BEGIN IMMEDIATE
  • Lock-floor check in apply_feedback; locked beliefs get an audit row and a warning, never a posterior move
  • Route or document the three bypassing paths — one fixed, two documented (table above)
  • Concurrency test that does not rely on the 5s suite timeout

Verification

  • Every fix was verified by reverting it and confirming the new tests fail. Pre-fix: both race tests fail on the conservation assertion (16.0 vs 121.0), the SQL-column audit fails on lock_level =, and the sweeper test reports applied=1 on a locked belief.
  • The race tests set busy_timeout explicitly (2000 ms) and carry their own @pytest.mark.timeout(30), so lock contention resolves as a wait and a regression fails on the conservation assertion — never colliding with the suite-wide 5 s default that equals SQLite's busy_timeout (AC5).
  • Full suite: 6087 passed, 69 skipped, 75 xfailed.

Summary by Sourcery

Make posterior feedback updates atomic and transactional while enforcing a lock floor and updating surrounding surfaces and docs to match.

Bug Fixes:

  • Prevent concurrent feedback writers from losing evidence and clobbering lock-related columns by using an atomic posterior increment within a single transaction.
  • Ensure passive feedback cannot change user-locked beliefs, and have CLI and MCP surfaces report audited-but-not-applied feedback on locks.
  • Stop the deferred feedback sweeper from bumping locked or foreign-owned beliefs, draining those queue rows instead.

Enhancements:

  • Introduce a transaction mode that acquires SQLite write locks up front for read-then-write sequences to avoid lost updates.
  • Expose whether a feedback event actually moved the posterior so callers and propagation logic can distinguish audit-only events from applied updates.

Documentation:

  • Align PHILOSOPHY, COMMANDS, LIMITATIONS, PRIVACY, and CHANGELOG docs with the new feedback atomicity guarantees, lock floor behavior, and multiple posterior-write paths.

Tests:

  • Add race-condition tests to prove conservation of alpha/beta under concurrent feedback, independent of global timeouts.
  • Add tests that the implicit sweeper respects the lock floor and ownership rules, and that feedback writes never touch lock/provenance columns.
  • Add coverage for the new atomic posterior bump primitive and immediate transaction semantics, including rollback behavior.

Summary by CodeRabbit

  • Bug Fixes

    • Feedback updates are now safe during concurrent activity, preventing evidence from being lost.
    • Passive feedback no longer changes locked beliefs, while still recording the feedback event.
    • Deferred feedback now skips locked or externally owned beliefs safely.
    • Confirmation remains able to update locked beliefs as an explicit action.
    • CLI and MCP responses now clearly indicate when feedback was recorded but not applied.
  • Documentation

    • Updated command, privacy, limitations, philosophy, and changelog documentation to reflect lock behavior and feedback update rules.

@robotrocketscience robotrocketscience added the author-Kulili PR coordination mutex label Jul 29, 2026
@sourcery-ai

sourcery-ai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Reviewer's Guide

Makes feedback posterior updates atomic and transactional, enforces a lock floor for passive feedback, and aligns all feedback writers and docs with those invariants.

Sequence diagram for deferred feedback sweeper honoring lock floor and ownership

sequenceDiagram
    participant Sweeper as sweep_deferred_feedback
    participant Store as MemoryStore
    participant DB as SQLite

    loop each row in deferred_feedback_queue WHERE status='pending'
        Sweeper->>DB: SELECT * FROM deferred_feedback_queue
        DB-->>Sweeper: row(belief_id, id, ...)

        Sweeper->>Store: get_belief(belief_id)
        alt no belief
            Sweeper->>DB: UPDATE deferred_feedback_queue SET status='cancelled'
            Sweeper->>Sweeper: skipped_no_belief++, cancelled++
        else belief exists
            alt belief.lock_level == LOCK_USER
                Sweeper->>DB: UPDATE deferred_feedback_queue SET status='cancelled'
                Sweeper->>Sweeper: skipped_locked++, cancelled++
            else foreign belief
                Sweeper->>Store: assert_local_ownership(belief_id)
                Store-->>Sweeper: ValueError
                Sweeper->>DB: UPDATE deferred_feedback_queue SET status='cancelled'
                Sweeper->>Sweeper: skipped_foreign++, cancelled++
            else eligible
                Sweeper->>Store: apply_deferred_alpha_beta_update(...)
                Sweeper->>DB: UPDATE deferred_feedback_queue SET status='applied'
                Sweeper->>Sweeper: applied++, epsilon_used+=epsilon
            end
        end
    end
Loading

File-Level Changes

Change Details Files
Make posterior updates atomic via SQL increments inside a single immediate transaction shared with feedback_history inserts, and add a narrow bump_posterior store primitive.
  • Introduce _bayesian_delta to compute (d_alpha, d_beta) increments and use it in apply_feedback for writes.
  • Wrap posterior update and feedback_history insert in store.transaction(immediate=True) and compute prior/posterior from the atomic bump.
  • Add MemoryStore.transaction(immediate=True) to start BEGIN IMMEDIATE at depth 0 and MemoryStore.bump_posterior to atomically SET alpha = alpha + ? and beta = beta + ? without touching other columns.
  • Add tests exercising bump_posterior semantics, column narrowness, and transaction(immediate=True) commit/rollback behavior.
src/aelfrice/feedback.py
src/aelfrice/store.py
tests/test_feedback_atomicity.py
Enforce a lock floor for passive feedback across CLI, MCP, and deferred sweeper paths while exempting explicit confirm, and surface locked-not-applied outcomes to callers.
  • Extend FeedbackResult with posterior_applied and skipped_locked flags and gate propagation on posterior_applied.
  • Add respect_lock flag to apply_feedback, treat LOCK_USER as a floor when respect_lock is True, and keep audit-only behavior with unchanged posterior.
  • Update CLI feedback command to print a lock-specific message when feedback is recorded but not applied, while keeping exit code 0.
  • Adjust MCP tool_feedback to emit kind=feedback.locked_not_applied with an error message when a lock prevents application, and change tool_confirm to pass respect_lock=False.
  • Modify deferred_feedback.sweep_deferred_feedback to cancel and count queued items for locked or foreign beliefs via new skipped_locked and skipped_foreign counters, and add tests ensuring sweeper respects the lock floor.
  • Update CLI and MCP tests to assert the new lock-floor behavior and audit rows, and add tests ensuring sweep continues to bump unlocked beliefs.
src/aelfrice/feedback.py
src/aelfrice/cli.py
src/aelfrice/mcp_server.py
src/aelfrice/deferred_feedback.py
tests/test_cli.py
tests/test_mcp_server.py
tests/test_implicit_feedback.py
Eliminate whole-row feedback writes that could clobber lock/provenance columns and verify structurally that the feedback path never writes lock-related columns.
  • Keep _bayesian_update for read-only projections while ensuring the write path uses delta-based bump_posterior.
  • Rely on bump_posterior instead of update_belief in apply_feedback so only alpha/beta change on feedback writes.
  • Add a SQL trace-based test asserting that feedback write paths never assign lock_level, locked_at, origin, or other non-posterior columns.
  • Document in bump_posterior and tests the prior behavior where feedback could revert concurrent lock/origin writes.
src/aelfrice/feedback.py
src/aelfrice/store.py
tests/test_feedback_atomicity.py
Add concurrency-focused tests to prove evidence conservation under contention and ensure transaction behavior is correct.
  • Introduce threaded race tests that run concurrent positive and negative feedback with explicit busy_timeout and per-test timeout, asserting alpha/beta conservation and feedback_history length.
  • Seed beliefs via ingest_turn and reuse across tests to simulate real-world writes.
  • Verify transaction(immediate=True) commits/rolls back posterior + audit together and behaves safely when nested or when a transaction is already open.
tests/test_feedback_atomicity.py
Align documentation and changelog with the new feedback invariants and lock-floor behavior.
  • Update PHILOSOPHY.md to describe apply_feedback as the primary writer, enumerate other deliberate writers, and state the invariants (lock floor, federated read-only, audited moves, atomic increments).
  • Clarify COMMANDS.md for confirm and feedback, explicitly documenting confirm’s exemption from the lock floor and feedback’s no-op-on-lock semantics with audit-only behavior.
  • Expand LIMITATIONS.md and PRIVACY.md to describe enforced lock durability across passive feedback surfaces and note the pre-fix behavior.
  • Add a v4 changelog entry detailing the concurrency bug, lock-floor enforcement, sweeper behavior, and corrected invariants.
docs/concepts/PHILOSOPHY.md
docs/user/COMMANDS.md
docs/user/LIMITATIONS.md
docs/user/PRIVACY.md
CHANGELOG/v4.md

Assessment against linked issues

Issue Objective Addressed Explanation
#1168 Make posterior updates atomic and transactional to prevent lost-update races and lock clobber (introduce a bump_posterior() SQL increment and wrap the posterior write plus feedback_history insert in a single BEGIN IMMEDIATE transaction).
#1168 Enforce lock-floor semantics so passive feedback on locked beliefs is audited but does not move the posterior, updating apply_feedback, callers (CLI/MCP/sentiment/exposure), and user-facing documentation accordingly.
#1168 Handle the three α-mutating paths that bypass apply_feedback (fix or document them and enforce invariants such as the lock floor and federation ownership) and add explicit concurrency tests that do not rely on the global 5s suite timeout.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@robotrocketscience robotrocketscience added the attn:review Needs review (PR open, awaiting reviewer) label Jul 29, 2026
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@robotrocketscience, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 33 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 704d4ce3-7420-45a8-ad85-fbf06649676e

📥 Commits

Reviewing files that changed from the base of the PR and between 4884475 and 954d85a.

📒 Files selected for processing (14)
  • CHANGELOG/v4.md
  • docs/concepts/PHILOSOPHY.md
  • docs/user/COMMANDS.md
  • docs/user/LIMITATIONS.md
  • docs/user/PRIVACY.md
  • src/aelfrice/cli.py
  • src/aelfrice/deferred_feedback.py
  • src/aelfrice/feedback.py
  • src/aelfrice/mcp_server.py
  • src/aelfrice/store.py
  • tests/test_cli.py
  • tests/test_feedback_atomicity.py
  • tests/test_implicit_feedback.py
  • tests/test_mcp_server.py
📝 Walkthrough

Walkthrough

Feedback posterior updates now use atomic SQLite increments with audit-row consistency. Passive feedback respects user locks, deferred feedback skips locked or foreign beliefs, and CLI/MCP responses distinguish audited-but-unapplied outcomes. Confirm remains exempt from the lock floor.

Changes

Feedback concurrency and lock behavior

Layer / File(s) Summary
Atomic posterior storage
src/aelfrice/store.py, tests/test_feedback_atomicity.py
MemoryStore adds immediate transactions and atomic posterior increments, with tests for commit, rollback, nesting, unknown beliefs, and column preservation.
Atomic feedback update flow
src/aelfrice/feedback.py, tests/test_feedback_atomicity.py
apply_feedback performs lock-aware atomic posterior and audit updates, reports application status, and preserves concurrent evidence.
Deferred feedback ownership checks
src/aelfrice/deferred_feedback.py, tests/test_implicit_feedback.py
The sweeper drains locked or foreign beliefs without applying epsilon and reports separate skip counters.
Command and protocol outcomes
src/aelfrice/cli.py, src/aelfrice/mcp_server.py, tests/test_cli.py, tests/test_mcp_server.py
CLI and MCP feedback expose locked-but-audited results, while confirm bypasses the lock floor.
Behavior documentation
CHANGELOG/v4.md, docs/concepts/PHILOSOPHY.md, docs/user/COMMANDS.md, docs/user/LIMITATIONS.md, docs/user/PRIVACY.md
Documentation describes atomic posterior writes, lock durability, passive feedback behavior, and confirm’s exception.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant apply_feedback
  participant MemoryStore
  participant FeedbackHistory
  Client->>apply_feedback: submit feedback
  apply_feedback->>MemoryStore: begin immediate transaction
  apply_feedback->>MemoryStore: atomically bump posterior or skip for user lock
  apply_feedback->>FeedbackHistory: record feedback event
  MemoryStore-->>apply_feedback: updated posterior
  apply_feedback-->>Client: feedback result and lock status
Loading

Possibly related issues

Suggested labels: bug

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: atomic feedback writes with a lock floor.
Description check ✅ Passed The description is detailed and covers the fix, linked issue, and verification, though it doesn't follow the exact template headings.
Docstring Coverage ✅ Passed Docstring coverage is 81.82% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-1168-feedback-atomicity

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

PR-size soft cap

This PR is over the advisory size threshold:

  • 910 changed lines (limit: 200)
  • 14 changed files (limit: 3)

Bigger PRs collide with more open work, which under the parallel-session workflow tends to produce repeated attn:merge-conflict cycles (see #602). When practical, split into smaller PRs that each touch a focused surface.

This is advisory only — nothing is blocked. If the size is intentional (large refactor, module removal, generated code), apply the size:override label and this comment will be removed on the next push.

Comment thread tests/test_feedback_atomicity.py Fixed
Comment thread tests/test_feedback_atomicity.py Fixed
Comment thread tests/test_feedback_atomicity.py Fixed

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="tests/test_implicit_feedback.py" line_range="426-435" />
<code_context>
+def test_sweep_does_not_bump_a_locked_belief() -> None:
</code_context>
<issue_to_address>
**suggestion (testing):** Add a complementary test case for `skipped_foreign` and cancellation behaviour on federated beliefs in the sweeper.

The sweeper now differentiates `skipped_locked` vs `skipped_foreign` and cancels both to prevent backlogs, but only the lock-floor path is exercised by tests. Please add a test that enqueues a belief with a foreign `belief_id` (e.g., one that fails `assert_local_ownership`), and asserts that it is counted in `skipped_foreign`, its status becomes `cancelled`, it is removed from `enqueued`, and no posterior or `feedback_history` row is created.

Suggested implementation:

```python
def test_sweep_does_not_bump_a_locked_belief() -> None:
    """Hypothesis: a user lock is never bumped by the implicit lane.

    The sweeper writes alpha directly, bypassing apply_feedback, so before
    #1168 the retrieval-driven +epsilon landed on locks — which
    docs/user/LIMITATIONS.md and PRIVACY.md both promise cannot happen.
    Falsifiable by any alpha change, or by the row staying enqueued."""
    locked = _mk("b1", "apple banana")
    locked.lock_level = LOCK_USER
    locked.alpha = 9.0
    locked.beta = 0.5

def test_sweep_cancels_foreign_belief() -> None:
    """Sweeper should cancel federated / foreign beliefs instead of processing them.

    The sweeper distinguishes skipped_locked from skipped_foreign and must cancel
    both to avoid backlogs. A belief whose id fails assert_local_ownership should:
    - be counted in skipped_foreign
    - have its status set to 'cancelled'
    - be removed from the enqueued queue
    - not produce a posterior or feedback_history row.
    """
    store = MemoryStore()

    # Arrange: create a belief that is definitely "foreign" for this installation.
    # The exact shape should match assert_local_ownership, e.g. a non-local prefix.
    foreign_belief = _mk("foreign:remote:123", "apple banana")
    foreign_belief.status = "enqueued"
    foreign_belief.lock_level = None  # not locked; rejected purely for foreign id
    foreign_belief.alpha = 1.0
    foreign_belief.beta = 1.0

    # Persist the foreign belief in the store as enqueued work
    store.put_belief(foreign_belief)

    # Act: run the implicit feedback sweeper over the store
    sweep_result = sweep_implicit_feedback(store)

    # Assert: the sweeper recognised and skipped the foreign belief
    assert sweep_result.skipped_foreign == 1
    assert sweep_result.skipped_locked == 0

    # Assert: the belief itself was cancelled and is no longer enqueued
    updated = store.get_belief(foreign_belief.belief_id)
    assert updated is not None
    assert updated.status == "cancelled"

    enqueued_ids = {b.belief_id for b in store.iter_enqueued_beliefs()}
    assert foreign_belief.belief_id not in enqueued_ids

    # Assert: no posterior row was created for the foreign belief
    posterior = store.get_posterior(foreign_belief.belief_id)
    assert posterior is None

    # Assert: no feedback history was written for the foreign belief
    history = list(store.get_feedback_history(foreign_belief.belief_id))
    assert history == []

```

To integrate this test with your actual code, please align the helper and API calls:

1. Replace `sweep_implicit_feedback(store)` with the real sweeper entry point (e.g. `implicit_feedback_sweeper.store_sweep(store)` or whatever is currently used in `test_sweep_does_not_bump_a_locked_belief`).
2. Ensure `MemoryStore.put_belief`, `iter_enqueued_beliefs`, `get_posterior`, and `get_feedback_history` match the actual store interface. If the queue of enqueued beliefs is exposed differently (e.g. `store.enqueued`, `store.list_enqueued()`), adapt the assertions accordingly.
3. Adjust the foreign `belief_id` format `"foreign:remote:123"` to whatever your `assert_local_ownership` implementation actually rejects (e.g. a different hostname, shard id, or prefix). The important part is that this id fails the ownership assertion so the sweeper takes the `skipped_foreign` path.
4. If statuses are represented as enums or constants rather than strings, replace `'cancelled'` with the appropriate status value.
5. Mirror how the existing sweeper tests fetch the sweep result and inspect counters (e.g. if they access `metrics.skipped_foreign` or `result["skipped_foreign"]`, match that convention here).
</issue_to_address>

### Comment 2
<location path="tests/test_mcp_server.py" line_range="492-496" />
<code_context>
     assert any(h["id"] == bid for h in hits["hits"])

+    # #1168 lock floor: passive feedback on a lock is audited, not applied.
     fb = tool_feedback(store, belief_id=bid, signal="used")
-    assert fb["new_alpha"] > fb["prior_alpha"]
+    assert fb["kind"] == "feedback.locked_not_applied"
+    assert fb["new_alpha"] == fb["prior_alpha"]
+    assert len(store.list_feedback_events(belief_id=bid)) == 1

     dem = tool_demote(store, belief_id=bid)
</code_context>
<issue_to_address>
**suggestion (testing):** Consider asserting the `error` payload for the locked-not-applied MCP feedback response.

To better exercise the MCP contract, please also assert that the `error` field is present and matches the expected explanatory string (or at least a meaningful message), since downstream clients may branch on both `kind` and `error` for lock-floor handling.

```suggestion
    # #1168 lock floor: passive feedback on a lock is audited, not applied.
    fb = tool_feedback(store, belief_id=bid, signal="used")
    assert fb["kind"] == "feedback.locked_not_applied"
    # Ensure the MCP contract surfaces an explanatory error message for locked-not-applied feedback.
    assert "error" in fb
    assert isinstance(fb["error"], str)
    assert fb["error"].strip() != ""
    assert fb["new_alpha"] == fb["prior_alpha"]
    assert len(store.list_feedback_events(belief_id=bid)) == 1
```
</issue_to_address>

### Comment 3
<location path="CHANGELOG/v4.md" line_range="20" />
<code_context>
+- **Feedback lost most of its evidence under concurrency, and moved locks it promised not to ([#1168](https://github.com/robotrocketscience/aelfrice/issues/1168)).** `apply_feedback` was an unsynchronised read-modify-write — `get_belief`, arithmetic in Python, then a whole-row `update_belief` writing the stale snapshot back — across two autocommit transactions. Concurrent hook processes each added their delta to the same alpha they had all read, so the last writer won: 8 threads × 15 events land 16 of 121 units of evidence on the pre-fix code while `feedback_history` records all 120, leaving the posterior that drives L1 rerank permanently inconsistent with its own append-only log. The write is now a new atomic `MemoryStore.bump_posterior` (`SET alpha = alpha + ?`) sharing one `BEGIN IMMEDIATE` transaction with its audit row, via a new `transaction(immediate=True)` mode. Dropping the whole-row write also closes a second defect: a feedback event could previously revert `lock_level` / `origin` written by a concurrent `aelf lock`. **Lock floor:** `apply_feedback` never read `lock_level`, so `aelf feedback <locked-id> harmful` moved a user lock — and sentiment-derived turn valence hit *every* lock on *every* prompt, since L0 locks are always injected and so sit in every turn's pending set. `LIMITATIONS.md` and `PRIVACY.md` both promised this could not happen. Passive feedback (CLI `feedback`, MCP `aelf_feedback`, sentiment, retrieval exposure, valence propagation) now records the event and holds the posterior; the CLI says so and points at `aelf unlock`, and MCP returns a distinct `feedback.locked_not_applied` kind so the no-op cannot read as success. `aelf confirm` / `aelf_confirm` are exempt — `COMMANDS.md` defines them as explicit affirmation rather than passive feedback. The deferred sweeper (`aelf sweep-feedback`), which writes alpha directly, now honours the same lock floor plus the [#655](https://github.com/robotrocketscience/aelfrice/issues/655) federation-ownership check, draining ineligible queue rows rather than letting them build a backlog; new `skipped_locked` / `skipped_foreign` counters report it. `PHILOSOPHY.md`'s "one writer of (α, β)" claim is corrected to name the three deliberate direct-write paths and state the invariants that actually hold across them.
</code_context>
<issue_to_address>
**issue (typo):** Example arithmetic in the concurrency description appears inconsistent.

The example currently mixes 120 and 121 units of evidence even though 8×15 = 120. Please either adjust the numbers or add a brief note explaining why 121 appears so the arithmetic is clear to readers.

```suggestion
- **Feedback lost most of its evidence under concurrency, and moved locks it promised not to ([#1168](https://github.com/robotrocketscience/aelfrice/issues/1168)).** `apply_feedback` was an unsynchronised read-modify-write — `get_belief`, arithmetic in Python, then a whole-row `update_belief` writing the stale snapshot back — across two autocommit transactions. Concurrent hook processes each added their delta to the same alpha they had all read, so the last writer won: 8 threads × 15 events land 16 of 120 units of evidence on the pre-fix code while `feedback_history` records all 120, leaving the posterior that drives L1 rerank permanently inconsistent with its own append-only log. The write is now a new atomic `MemoryStore.bump_posterior` (`SET alpha = alpha + ?`) sharing one `BEGIN IMMEDIATE` transaction with its audit row, via a new `transaction(immediate=True)` mode. Dropping the whole-row write also closes a second defect: a feedback event could previously revert `lock_level` / `origin` written by a concurrent `aelf lock`. **Lock floor:** `apply_feedback` never read `lock_level`, so `aelf feedback <locked-id> harmful` moved a user lock — and sentiment-derived turn valence hit *every* lock on *every* prompt, since L0 locks are always injected and so sit in every turn's pending set. `LIMITATIONS.md` and `PRIVACY.md` both promised this could not happen. Passive feedback (CLI `feedback`, MCP `aelf_feedback`, sentiment, retrieval exposure, valence propagation) now records the event and holds the posterior; the CLI says so and points at `aelf unlock`, and MCP returns a distinct `feedback.locked_not_applied` kind so the no-op cannot read as success. `aelf confirm` / `aelf_confirm` are exempt — `COMMANDS.md` defines them as explicit affirmation rather than passive feedback. The deferred sweeper (`aelf sweep-feedback`), which writes alpha directly, now honours the same lock floor plus the [#655](https://github.com/robotrocketscience/aelfrice/issues/655) federation-ownership check, draining ineligible queue rows rather than letting them build a backlog; new `skipped_locked` / `skipped_foreign` counters report it. `PHILOSOPHY.md`'s "one writer of (α, β)" claim is corrected to name the three deliberate direct-write paths and state the invariants that actually hold across them.
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread tests/test_implicit_feedback.py
Comment thread tests/test_mcp_server.py
Comment thread CHANGELOG/v4.md

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/aelfrice/feedback.py (1)

179-238: 🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

TOCTOU: lock-floor decision is made before the write lock is acquired.

store.get_belief(belief_id) (line 181) and the locked/posterior_applied derivation (lines 200-201) happen before with store.transaction(immediate=True): (line 211) takes the write lock. bump_posterior correctly re-derives prior_alpha/prior_beta from its own atomic result, so the increment is safe — but the decision of whether to increment at all is not: if a concurrent aelf lock commits between line 181 and line 211, this call still computes locked=False from the stale read and proceeds to move the posterior of a now-locked belief, defeating the exact "movement of locked beliefs" invariant this PR is meant to close.

Move the belief read and lock/posterior decision inside the transaction, after BEGIN IMMEDIATE has taken the write lock, so the decision and the write observe the same committed state.

🔒 Proposed fix: decide inside the write-locked transaction
     store.assert_local_ownership(belief_id)
 
-    b: Belief | None = store.get_belief(belief_id)
-    if b is None:
-        raise ValueError(f"belief not found: {belief_id}")
-
-    # Lock floor (`#1168`). ...
-    locked: bool = respect_lock and b.lock_level == LOCK_USER
-    posterior_applied: bool = update_posterior and not locked
-
-    prior_alpha: float = b.alpha
-    prior_beta: float = b.beta
     timestamp: str = now if now is not None else _utc_now_iso()
 
-    # One transaction for the posterior write and its audit row (`#1168`): ...
+    # One transaction for the read-decide-write sequence (`#1168`): the lock
+    # floor decision must observe the same committed state as the write,
+    # or a lock committed between an earlier read and BEGIN IMMEDIATE is
+    # missed and the posterior still moves.
     with store.transaction(immediate=True):
+        b: Belief | None = store.get_belief(belief_id)
+        if b is None:
+            raise ValueError(f"belief not found: {belief_id}")
+        locked: bool = respect_lock and b.lock_level == LOCK_USER
+        posterior_applied: bool = update_posterior and not locked
+        prior_alpha: float = b.alpha
+        prior_beta: float = b.beta
+
         if posterior_applied:
             d_alpha, d_beta = _bayesian_delta(valence)
             bumped = store.bump_posterior(belief_id, d_alpha, d_beta)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/aelfrice/feedback.py` around lines 179 - 238, Move the get_belief lookup
and the locked/posterior_applied calculation into the
store.transaction(immediate=True) block, after the write lock is acquired. Keep
the not-found validation and snapshot initialization inside that transaction so
the lock-floor decision and bump_posterior operation use the same committed
belief state; preserve the existing audit-only behavior for locked beliefs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/concepts/PHILOSOPHY.md`:
- Around line 110-112: Update the opening sentence in the philosophy section to
scope the single-path claim specifically to feedback-driven runtime belief
mutation, rather than all runtime belief mutation. Keep the existing reference
to apply_feedback and the following explanation of additional write paths
unchanged.

In `@src/aelfrice/cli.py`:
- Around line 3514-3532: Update the feed event construction and _feed_log_event
call associated with the result.skipped_locked branch so locked feedback is
represented as not applied, using a distinct locked/not-applied event kind or
posterior_applied=False. Preserve feedback.applied only for the else branch
where the posterior actually changes, keeping the CLI output consistent with the
emitted event.

In `@src/aelfrice/deferred_feedback.py`:
- Around line 347-371: The deferred-feedback processing flow must acquire BEGIN
IMMEDIATE before reloading the belief and evaluating LOCK_USER or
local-ownership eligibility. Move the lock and ownership checks inside that
transaction, and commit cancellation updates there; keep the posterior write in
the same transaction so eligibility and UPDATE beliefs are atomic.

In `@src/aelfrice/mcp_server.py`:
- Around line 749-758: Update the aelf_feedback docstring’s documented return
contract to include the feedback.locked_not_applied response kind and its error
field, alongside the existing feedback.applied, feedback.bad_signal, and
feedback.unknown_belief kinds. Keep the implementation in tool_feedback
unchanged.

In `@tests/test_mcp_server.py`:
- Around line 492-496: Extend the assertions in the lock-floor regression test
around tool_feedback so the returned beta value is also equal to its prior_beta
value. Keep the existing alpha and feedback-event assertions unchanged, ensuring
both posterior parameters are verified as unchanged.

---

Outside diff comments:
In `@src/aelfrice/feedback.py`:
- Around line 179-238: Move the get_belief lookup and the
locked/posterior_applied calculation into the store.transaction(immediate=True)
block, after the write lock is acquired. Keep the not-found validation and
snapshot initialization inside that transaction so the lock-floor decision and
bump_posterior operation use the same committed belief state; preserve the
existing audit-only behavior for locked beliefs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f182d60f-295e-454d-8b77-ca0d97e0d671

📥 Commits

Reviewing files that changed from the base of the PR and between 450475b and 4884475.

📒 Files selected for processing (14)
  • CHANGELOG/v4.md
  • docs/concepts/PHILOSOPHY.md
  • docs/user/COMMANDS.md
  • docs/user/LIMITATIONS.md
  • docs/user/PRIVACY.md
  • src/aelfrice/cli.py
  • src/aelfrice/deferred_feedback.py
  • src/aelfrice/feedback.py
  • src/aelfrice/mcp_server.py
  • src/aelfrice/store.py
  • tests/test_cli.py
  • tests/test_feedback_atomicity.py
  • tests/test_implicit_feedback.py
  • tests/test_mcp_server.py

Comment thread docs/concepts/PHILOSOPHY.md Outdated
Comment thread src/aelfrice/cli.py
Comment thread src/aelfrice/deferred_feedback.py Outdated
Comment thread src/aelfrice/mcp_server.py
Comment thread tests/test_mcp_server.py
robotrocketscience added a commit that referenced this pull request Jul 29, 2026
Bot review on PR #1183 surfaced two real defects in the previous commits.

The sweeper's lock and ownership checks ran before its `BEGIN IMMEDIATE`, so
a lock landing between the check and the direct `UPDATE beliefs SET alpha`
would move a belief the checks had just rejected — the same check-then-act
class this issue is about. The transaction now opens first and the
eligibility reads happen inside it. A statement-order test pins the
structure and fails on the old ordering.

The CLI printed "recorded but did not move it" while `_feed_log_event` still
logged `feedback.applied`, so `aelf feed` reported a posterior move that
never happened. It now emits `feedback.locked_not_applied` and carries
`posterior_applied`.

Also: document the new `feedback.locked_not_applied` kind in the
`aelf_feedback` return contract; PHILOSOPHY.md's first sentence still read
as an absolute single-writer claim two lines above the correction; correct
the changelog's 120-vs-121 arithmetic (120 events, beta 1.0 -> 121.0).

Test coverage from the same review: `skipped_foreign` and its cancellation,
the MCP `error` payload, and the beta half of the lock-floor invariant.
CodeQL: drop an import left unused by an earlier rewrite, narrow a worker
`except BaseException` to `Exception`, and restructure a `pytest.fail` that
made the following lines unreachable.
@robotrocketscience

Copy link
Copy Markdown
Owner Author

Addressed the review in f6dce5d5 (pushed). Two of the findings were real defects in my own commits:

Sweeper TOCTOU (deferred_feedback.py) — fixed. Correct and worth flagging loudly: the lock and ownership checks ran before BEGIN IMMEDIATE, so a lock committed in that window would have let +epsilon land on a belief the checks had just rejected — the same check-then-act class this issue is about, reintroduced by my own fix. The transaction now opens first and all eligibility reads happen inside it. test_sweep_eligibility_checks_share_the_row_transaction pins the statement order and fails on the old arrangement.

Feed event mislabelled (cli.py) — fixed. The CLI printed "recorded but did not move it" while _feed_log_event still logged feedback.applied, so aelf feed reported a posterior move that never happened. Now emits feedback.locked_not_applied and carries posterior_applied.

Also taken:

  • aelf_feedback return contract documents the new feedback.locked_not_applied kind.
  • PHILOSOPHY.md's first sentence still read as an absolute single-writer claim two lines above the correction — reworded to "feedback-driven belief mutation".
  • Changelog arithmetic: 8x15 = 120 events; 121.0 was the absolute post-value (beta starts at 1.0). Reworded to state both.
  • Tests: skipped_foreign plus its cancellation and absence of any audit row; the MCP error payload; the beta half of the lock-floor invariant.

CodeQL: dropped an import left unused by an earlier rewrite, narrowed a worker except BaseException to Exception, and restructured a pytest.fail that made the following lines unreachable.

Full suite: 6105 passed, 69 skipped, 75 xfailed.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Operator decision, 2026-07-29: the deferred lane keeps moving posteriors. This PR ships as-is on that point.

The audit's fifth finding argued sweep_deferred_feedback should be gated on the #1086 exposure_updates_posterior flag (or is_enqueue_on_retrieve_enabled flipped to default-False), on the grounds that it re-instates the junk percolation #1086 removed. Ratified reading: it does not. The lane shipped under #191/#256 before #1086 and is a materially different mechanism — ε=0.05 rather than 1.0, behind a 30-minute grace window that any explicit correction or contradiction cancels. #1086 turned off the immediate exposure→posterior bump in hook_search; it never touched this lane.

What the lane was genuinely missing — and what this PR adds — is the lock floor and the #655 federation-ownership check. Both now applied, with ineligible queue rows drained rather than left to accumulate.

Not to be re-litigated without new evidence.

robotrocketscience added a commit that referenced this pull request Jul 29, 2026
Bot review on PR #1183 surfaced two real defects in the previous commits.

The sweeper's lock and ownership checks ran before its `BEGIN IMMEDIATE`, so
a lock landing between the check and the direct `UPDATE beliefs SET alpha`
would move a belief the checks had just rejected — the same check-then-act
class this issue is about. The transaction now opens first and the
eligibility reads happen inside it. A statement-order test pins the
structure and fails on the old ordering.

The CLI printed "recorded but did not move it" while `_feed_log_event` still
logged `feedback.applied`, so `aelf feed` reported a posterior move that
never happened. It now emits `feedback.locked_not_applied` and carries
`posterior_applied`.

Also: document the new `feedback.locked_not_applied` kind in the
`aelf_feedback` return contract; PHILOSOPHY.md's first sentence still read
as an absolute single-writer claim two lines above the correction; correct
the changelog's 120-vs-121 arithmetic (120 events, beta 1.0 -> 121.0).

Test coverage from the same review: `skipped_foreign` and its cancellation,
the MCP `error` payload, and the beta half of the lock-floor invariant.
CodeQL: drop an import left unused by an earlier rewrite, narrow a worker
`except BaseException` to `Exception`, and restructure a `pytest.fail` that
made the following lines unreachable.
@robotrocketscience
robotrocketscience force-pushed the fix/issue-1168-feedback-atomicity branch from f6dce5d to 80e1315 Compare July 29, 2026 23:46
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Setr:2026-07-30T17:33:02Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Approve on the merits. The diagnosis is right, the fix is right, and the lock-floor scoping is the right shape. Outstanding items are mechanical plus one genuine coverage gap in new code — details at the end.

Verified

  • bump_posterior is sound: SET alpha = alpha + ? is evaluated by SQLite against the committed row, and the follow-up SELECT inside the transaction reads the uncommitted value from the same connection, so the returned (alpha, beta) is the authoritative post-write pair. Recomputing prior_alpha = new_alpha - d_alpha rather than trusting the earlier snapshot is the right call and easy to get wrong.
  • LOCK_LEVELS is exactly {none, user}, so keying the floor on LOCK_USER covers the whole space — no third level slips underneath.
  • respect_lock as an explicit parameter rather than sniffing the caller-supplied --source string is the correct boundary. A user-controlled string deciding whether a user lock holds would be a bad seam.
  • The confirm exemption is well grounded — COMMANDS.md:30 really does define it as explicit affirmation "distinct from ... implicit retrieval feedback", so it's a documented distinction rather than a convenient one.
  • Rewriting the two tests that asserted feedback moving a locked posterior is right. They encoded the bug; the docs encoded the intent; the docs win.
  • The three bypassing paths are dispositioned honestly, including the two that are documented rather than changed. clamp_ghost_alpha already asserting lock_level='none' in its own WHERE clause is the kind of thing that's easy to assume and worth having checked.

The race tests are a conjunction guard — neither half is individually pinned

Worth knowing before someone refactors this. The PR ships two independent mechanisms, and the tests only fail when both are removed:

bump_posterior -> snapshot read-modify-write   (keeping BEGIN IMMEDIATE)  -> 10 passed
transaction(immediate=True) -> transaction()   (keeping atomic SET)       -> 10 passed
both removed                                                             -> 2 failed

That is not a defect — each mechanism alone genuinely does prevent the lost update, so the redundancy is real defence rather than an illusion. And the PR's claim that "the race test fails against pre-fix code" is accurate, because pre-fix had neither.

But it means a partial revert ships silently, and the PR argues the two mechanisms guard different things: BEGIN IMMEDIATE for the read-then-write upgrade race, the atomic SET for the lock-clobber window. If those are separate guarantees, each wants a test that fails on its own. The lock-clobber half is closest — test_feedback_write_path_never_writes_lock_columns traces the emitted SQL, but it passes a SET alpha = ?, beta = ? snapshot write too, since that also touches only two columns. Asserting the form (alpha = alpha +) rather than just the column set would distinguish it.

Small thing to add, and it's the difference between "the tests confirm this works" and "the tests would notice if it stopped".

Agreed on the deferred-sweeper gating question

Declining to gate the implicit lane on exposure_updates_posterior is defensible, and the reasoning is the right kind: it distinguishes the mechanism (ε=0.05 behind a 30-minute grace window that any explicit correction cancels) from what #1086 actually removed, rather than pattern-matching on "this also moves posteriors". Flagging it for an operator call rather than deciding unilaterally is correct — it's a behaviour-scope question, not a bug.

That said, it deserves its own issue rather than living in a PR body, since the PR will close and the question won't.


Outstanding before this can be labelled

  1. Rebasemain is now 9606628c.
  2. skipped_foreign has no test (Sourcery). This is the one real gap: the sweeper gained two counters and only skipped_locked is exercised. New code, new counter, no coverage — and the foreign path also has to cancel the row rather than leave it to build the backlog the PR set out to prevent, which is a second assertion nothing currently makes.
  3. MCP error payload assertion (Sourcery) — reasonable. Clients branch on the contract, so kind alone under-specifies it.
  4. CodeQL unreachable-code at test_feedback_atomicity.py:291 — looks like a false positive from the raise inside the nested with block, but it's an unresolved thread and the merge-train will bounce on it regardless, so it needs disposing of either way.

I'd take 1, 3 and 4 as chores; 2 is worth doing properly. Re-ping me and I'll re-review promptly.

@robotrocketscience robotrocketscience added attn:unblock Needs answer from another session and removed attn:review Needs review (PR open, awaiting reviewer) labels Jul 30, 2026
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Setr:2026-07-30T17:36:25Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Setr:2026-07-30T18:13:36Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Rebased onto 4f5f866d (post-#1184) and dispositioned all three threads. FF on main, 6 signed commits, discretion clean, 178 tests green across the five affected modules.

The rebase had a real semantic conflict worth flagging. #1184 landed src_confidence on the propagate_valence call; this PR changes that call's guard from update_posterior to posterior_applied for the lock floor. Both are wanted and neither side's version has both, so I merged them rather than taking a side:

if posterior_applied and propagate and _propagation_enabled():
    prior_denom = prior_alpha + prior_beta
    deltas = store.propagate_valence(
        belief_id, valence,
        src_confidence=(prior_alpha / prior_denom) if prior_denom > 0 else 0.0,
    )

The composition is what you'd want: a locked belief holds its posterior and does not propagate, so the held signal can't reach its neighbours by another route. And in that branch prior_alpha/prior_beta are the authoritative pre-event pair recomputed from what the atomic write landed on, which is exactly what src_confidence is documented to need. 119 feedback + valence tests pass together.

Sourcery, skipped_foreign coverage — already exists. test_sweep_does_not_bump_a_foreign_belief (test_implicit_feedback.py:518) asserts applied == 0, skipped_foreign == 1, cancelled == 1, alpha unchanged, the queue drained to zero enqueued, and no feedback_history row — every assertion the thread asks for. I wrote the suggested test, confirmed it was load-bearing (dropping the ownership check fails it and the existing one), then deleted it as a duplicate.

Sourcery, MCP error payload — already exists, test_mcp_server.py:497-499, with a comment saying clients may branch on error as well as kind. No change needed.

CodeQL unreachable code — a false positive, now cleared structurally. CodeQL does not model pytest.raises as catching, so a bare raise inside the transaction block reads as the end of the function and every assertion after it looked unreachable. Raising through a helper keeps the test behaviourally identical.

Two of the three review items were requests for coverage that already shipped. Not a complaint about the bots — but worth recording, since acting on either would have added a redundant test that looks like new safety and isn't.

robotrocketscience added a commit that referenced this pull request Jul 30, 2026
Bot review on PR #1183 surfaced two real defects in the previous commits.

The sweeper's lock and ownership checks ran before its `BEGIN IMMEDIATE`, so
a lock landing between the check and the direct `UPDATE beliefs SET alpha`
would move a belief the checks had just rejected — the same check-then-act
class this issue is about. The transaction now opens first and the
eligibility reads happen inside it. A statement-order test pins the
structure and fails on the old ordering.

The CLI printed "recorded but did not move it" while `_feed_log_event` still
logged `feedback.applied`, so `aelf feed` reported a posterior move that
never happened. It now emits `feedback.locked_not_applied` and carries
`posterior_applied`.

Also: document the new `feedback.locked_not_applied` kind in the
`aelf_feedback` return contract; PHILOSOPHY.md's first sentence still read
as an absolute single-writer claim two lines above the correction; correct
the changelog's 120-vs-121 arithmetic (120 events, beta 1.0 -> 121.0).

Test coverage from the same review: `skipped_foreign` and its cancellation,
the MCP `error` payload, and the beta half of the lock-floor invariant.
CodeQL: drop an import left unused by an earlier rewrite, narrow a worker
`except BaseException` to `Exception`, and restructure a `pytest.fail` that
made the following lines unreachable.
@robotrocketscience
robotrocketscience force-pushed the fix/issue-1168-feedback-atomicity branch from 80e1315 to 8fb0bd1 Compare July 30, 2026 18:18
@robotrocketscience robotrocketscience added ready-to-merge Trigger merge-train: FF main to this PR's head and removed attn:unblock Needs answer from another session labels Jul 30, 2026
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Setr:2026-07-30T18:25:36Z]

bump_posterior issues `SET alpha = alpha + ?, beta = beta + ?` so the
increment is evaluated by SQLite against the committed row. update_belief
cannot do this: it is a whole-row write of an in-memory snapshot, which makes
every posterior move an unsynchronised read-modify-write and lets a stale
snapshot clobber columns the caller never meant to touch.

transaction(immediate=True) issues BEGIN IMMEDIATE so a block that reads and
then writes something derived from that read takes the write lock up front
and waits on busy_timeout instead of losing the upgrade race to SQLITE_BUSY.
Ignored when nested or when a transaction is already open, so it is safe to
pass unconditionally.

Primitives only; no caller changes here.
… floor

apply_feedback was an unsynchronised read-modify-write landing in a full-row
update_belief, across two autocommit transactions. Two defects, one fix each.

Lost updates: concurrent hook processes each added their delta to the same
alpha they had all read, so the last writer won. 8 threads x 15 events land
16 of 121 units of evidence on the pre-fix code while feedback_history
records all 120 — the projection and its own append-only log disagreed with
no error signal. The write is now store.bump_posterior (atomic SQL
increment), and it shares one BEGIN IMMEDIATE transaction with the audit
row so the two can no longer diverge on a crash. Because the whole-row
write is gone, a feedback event can no longer revert lock or provenance
columns committed by a concurrent writer.

Lock floor: apply_feedback never read lock_level, so `aelf feedback
<locked-id> harmful` moved a user lock — and, worse, sentiment-derived turn
valence hit every lock on every prompt, since L0 locks are always injected
and so sit in every turn's pending set. docs/user/LIMITATIONS.md and
docs/user/PRIVACY.md both promise passive feedback cannot move a lock. It
now cannot: the event is still audited, the posterior holds, and the CLI
says so and points at `aelf unlock`. MCP returns a distinct
feedback.locked_not_applied kind so a caller cannot read the no-op as a
silent success. `aelf confirm` / `aelf_confirm` pass respect_lock=False —
COMMANDS.md defines them as explicit user affirmation, distinct from
implicit feedback, and they keep their existing behaviour on locks.

Two existing tests asserted feedback moving a locked posterior; both are
rewritten to the documented behaviour. The new race tests set busy_timeout
explicitly and carry their own wall-clock budget, so a regression fails on
the conservation assertion rather than colliding with the suite-wide 5 s
timeout that equals SQLite's default busy_timeout.
sweep_deferred_feedback writes alpha directly rather than going through
apply_feedback. That stays — it owns its own per-row BEGIN IMMEDIATE and its
queue-status bookkeeping — but it was also skipping the two invariants that
endpoint enforces. A retrieval-driven +epsilon landed on user locks, which
LIMITATIONS.md and PRIVACY.md both promise cannot happen, and on federated
beliefs that are read-only through the local DB (#655).

Both cases now drain the queue row instead of applying it, so an ineligible
belief cannot accumulate a backlog that all lands at once if it later
becomes eligible. New skipped_locked / skipped_foreign counters on
SweepResult report it.

Scope note: this does not gate the sweeper on the #1086
exposure-updates-posterior flag. The implicit lane predates #1086 (#191/#256)
and is a materially different mechanism — a much smaller epsilon, behind a
grace window that any explicit correction cancels — not the immediate
exposure-as-endorsement bump #1086 removed. Whether the lane should exist at
all is an operator call, not a bug fix.
PHILOSOPHY.md asserted "one writer of (α, β)". Three paths besides
apply_feedback write it, each deliberately; the claim is replaced with the
invariants that actually hold across all four (lock floor, federation
read-only, an audit row per non-merge move, atomic increment).

LIMITATIONS.md and PRIVACY.md promised passive feedback cannot move a lock
while the code moved it; both now describe the enforced behaviour, name the
signals it covers, and note the `aelf confirm` exemption. COMMANDS.md
documents the no-op-on-lock outcome for `feedback` and the exemption for
`confirm`.
Bot review on PR #1183 surfaced two real defects in the previous commits.

The sweeper's lock and ownership checks ran before its `BEGIN IMMEDIATE`, so
a lock landing between the check and the direct `UPDATE beliefs SET alpha`
would move a belief the checks had just rejected — the same check-then-act
class this issue is about. The transaction now opens first and the
eligibility reads happen inside it. A statement-order test pins the
structure and fails on the old ordering.

The CLI printed "recorded but did not move it" while `_feed_log_event` still
logged `feedback.applied`, so `aelf feed` reported a posterior move that
never happened. It now emits `feedback.locked_not_applied` and carries
`posterior_applied`.

Also: document the new `feedback.locked_not_applied` kind in the
`aelf_feedback` return contract; PHILOSOPHY.md's first sentence still read
as an absolute single-writer claim two lines above the correction; correct
the changelog's 120-vs-121 arithmetic (120 events, beta 1.0 -> 121.0).

Test coverage from the same review: `skipped_foreign` and its cancellation,
the MCP `error` payload, and the beta half of the lock-floor invariant.
CodeQL: drop an import left unused by an earlier rewrite, narrow a worker
`except BaseException` to `Exception`, and restructure a `pytest.fail` that
made the following lines unreachable.
CodeQL reported every assertion after the transaction block as
unreachable: it does not model `pytest.raises` as catching, so a bare
`raise` inside the block is statically the end of the function. Raising
through a helper keeps the test byte-identical in behaviour and clears
the alert.
@robotrocketscience
robotrocketscience force-pushed the fix/issue-1168-feedback-atomicity branch from 8fb0bd1 to 954d85a Compare July 30, 2026 18:33
@github-actions
github-actions Bot merged commit 954d85a into main Jul 30, 2026
25 checks passed
@github-actions github-actions Bot removed the ready-to-merge Trigger merge-train: FF main to this PR's head label Jul 30, 2026
@github-actions

Copy link
Copy Markdown

merge-train: merged 954d85amain via FF push.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author-Kulili PR coordination mutex

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(feedback): lost-update race on (α, β), full-row write reverts concurrent locks, and no lock-floor check

2 participants