Skip to content

fix(store): a re-asserted retired belief revives only for a person (#1215) - #1216

Merged
github-actions[bot] merged 3 commits into
mainfrom
fix/issue-1215-retired-content-hash
Jul 30, 2026
Merged

fix(store): a re-asserted retired belief revives only for a person (#1215)#1216
github-actions[bot] merged 3 commits into
mainfrom
fix/issue-1215-retired-content-hash

Conversation

@robotrocketscience

@robotrocketscience robotrocketscience commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Closes #1215.

get_belief_by_content_hash had no valid_to filter. #1210 gave get_belief
one; this is the same hole on the lookup every ingest path actually resolves
through.

The symptom, reproduced end to end

$ aelf lock "the deploy target is heroku"
locked: e81e7af92e405a5f

$ aelf retire e81e7af92e405a5f --force
retired: e81e7af92e405a5f (reversible — `aelf restore e81e7af92e405a5f` to undo)

$ aelf lock "the deploy target is heroku"
upgraded existing belief to lock: e81e7af92e405a5f     <-- reports success

After that second lock:

valid_to            : 2026-07-30T20:24:28+00:00        <-- still retired
lock_level          : user                             <-- the lock did land
corroboration_count : 1                                <-- tombstone gained evidence
get_belief (default): None
aelf locked lists   : []
search finds        : []
list_belief_ids     : ['e81e7af92e405a5f']             <-- one row, invisible

The store holds exactly one row and nothing can see it. The most explicit act
a user has for asserting ground truth is a no-op on every retrieval surface,
and the CLI says it worked.

This is the residual case #1164 did not cover. That fix correctly moved the
lock upgrade onto the resolved id rather than the minted lock id — which is
why lock_level='user' now lands at all. It did not consider that the
resolved row may be a tombstone, so the upgrade lands somewhere unreadable.

The plain-ingest case is the same mechanism without the lock: capture
re-observes a sentence the user retired, writes a corroboration row against
the tombstone, and drops the content.

Why this is a policy decision, not a filter

content_hash is NOT NULL UNIQUE (#219). So "insert a fresh row alongside
the tombstone" is not available — the constraint forbids it. That leaves
revive-or-refuse, and the two differ in what they do to a user's curation.

Ratified: tier it by who is asserting.

tier sources outcome
a person cli_remember, mcp_rememberaelf lock, aelf remember, MCP twins revives: valid_to cleared, FTS row restored, back in search
background capture transcript, commit, filesystem, wonder, claude-memory mirror, migration leaves it retired, records nothing

Re-typing a sentence yourself is a deliberate act. A transcript scan finding
it again is not, and under the old behaviour that scan was quietly accruing
evidence on a belief the user had removed.

Revival deliberately does not move the posterior — the belief comes back
exactly where it left. The re-assertion is recorded as a
belief_corroborations row, which is where that signal belongs, plus a
reassert:revive audit row so the transition is not silent in either
direction (retire already writes user_retired_force).

Verified across every source:

transcript_ingest    -> inserted=False still_retired=True  corrob=0 visible=False
filesystem_ingest    -> inserted=False still_retired=True  corrob=0 visible=False
commit_ingest        -> inserted=False still_retired=True  corrob=0 visible=False
cli_remember         -> inserted=False still_retired=False corrob=1 visible=True

The two opt-ins are UNIQUE-constraint guards, not reads

The lookup now excludes retired rows by default. Two callers opt back in, and
both do so for the same reason insert_belief's id-collision guard did in
#1210 — a tombstone still owns its key:

  • insert_or_corroborate, which must see the tombstone in order to apply the
    policy above rather than blindly INSERT into a UNIQUE column.
  • wonder_ingest, whose dedupe key is a synthetic content hash.

The second is load-bearing rather than defensive, and pinned. Reverting it:

sqlite3.IntegrityError: UNIQUE constraint failed: beliefs.content_hash

It is also right on its own terms — a phantom the lifecycle GC'd should not be
regenerated by the next wonder pass.

Tests assert the invariant per tier, with controls

tests/test_reasserted_retired_belief_1215.py, 23 tests. Each tier carries a
negative control on live content, because "capture did not revive" and
"capture did nothing at all" are satisfied by the same assertions on a store
where corroboration is broken outright.

Verified distinguishing rather than assumed — three independent reverts:

revert result
lookup stops filtering valid_to 1 failed
tiered policy block removed (pre-#1215 swallow) 10 failed
wonder_ingest loses its opt-in 1 failed, IntegrityError

Acceptance criteria

  • Re-asserting a retired statement has a defined, documented outcome; it is
    not silently dropped.
  • aelf lock on a retired statement surfaces the belief rather than
    printing success while leaving it invisible.
  • A retired belief does not accrue corroboration rows from a path that
    resolved to it by content hash.
  • Regression test asserting the invariant, with a negative control that a
    re-assertion of live content still corroborates normally.

Verification

  • Full suite: 6449 passed, 69 skipped, 71 xfailed. No existing test needed
    changing — the default flip is contained by the two opt-ins.
  • No schema change.

Noted, not fixed

Found while reviewing #1214, alongside aelf lock on a retired statement
leaving it retired-but-locked. That second one is fixed here as a
consequence
rather than separately: the lock path is cli_remember, so it
now revives and the belief is genuinely locked and visible. No separate
issue needed.

Summary by Sourcery

Define a tiered policy for re-asserting retired beliefs so that explicit user assertions revive them while background capture leaves them retired and invisible.

New Features:

  • Add an audit feedback event source to record when a retired belief is revived by an explicit user assertion.

Bug Fixes:

  • Ensure re-asserting a retired statement via content-hash lookup no longer writes corroboration against a tombstone while keeping the belief invisible.
  • Fix aelf lock on a retired belief so that it genuinely revives and locks the belief instead of reporting success on an unreadable row.
  • Prevent wonder ingest from re-inserting GC’d phantoms and tripping the content_hash UNIQUE constraint.

Enhancements:

  • Make content-hash lookups exclude retired beliefs by default, with explicit opt-ins for UNIQUE-constraint guard callers instead of general reads.

Documentation:

  • Document the re-assertion policy and its tiering in the v4 changelog, including behaviour for user assertions vs background capture.

Tests:

  • Add a dedicated test suite covering re-assertion of retired beliefs across ingest tiers, including negative controls and wonder-ingest behaviour.

`get_belief_by_content_hash` had no `valid_to` filter, so re-asserting a
statement that had been retired resolved to the tombstone: nothing was
inserted, a corroboration row was written against the retired belief, and
nothing became visible. `aelf lock` on a retired statement printed
success while `aelf locked` stayed empty.

The lookup now excludes retired rows by default, matching `get_belief`
(#1210). The two callers that use it as a UNIQUE-constraint guard rather
than to read content opt in — `content_hash` is `NOT NULL UNIQUE` (#219),
so a tombstone still owns its hash.

Having seen the tombstone, `insert_or_corroborate` applies the ratified
policy, tiered by who is asserting: an explicit user assertion revives
the belief at the posterior it was retired at, with an audit row;
background capture leaves it retired and records nothing, so an agent
re-observing text cannot silently undo the user's curation.
Asserts the invariant rather than the call sites: which tier revives, and
that the other tier writes nothing. Each tier carries a negative control
on live content, because "capture did not revive" and "capture did
nothing at all" are satisfied by the same assertions on a store where
corroboration is broken outright.

Also covers the second UNIQUE-constraint guard: a GC'd phantom is not
regenerated by the next wonder pass.
@robotrocketscience robotrocketscience added the author-garsecg PR coordination mutex label Jul 30, 2026

@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.

Sorry @robotrocketscience, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in: 30 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: fa007c9c-4e9f-4380-bf5e-8fda714fe9bb

📥 Commits

Reviewing files that changed from the base of the PR and between 72787d9 and b90cab7.

📒 Files selected for processing (5)
  • CHANGELOG/v4.md
  • src/aelfrice/models.py
  • src/aelfrice/store.py
  • src/aelfrice/wonder/lifecycle.py
  • tests/test_reasserted_retired_belief_1215.py

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.

@robotrocketscience robotrocketscience added the attn:review Needs review (PR open, awaiting reviewer) label Jul 30, 2026
@sourcery-ai

sourcery-ai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements tiered behavior for re-asserting retired beliefs and introduces lifecycle‑aware content-hash lookups, ensuring explicit user actions revive beliefs while background capture respects tombstones and avoids UNIQUE-constraint violations.

Sequence diagram for tiered re-assertion of a retired belief

sequenceDiagram
    actor User
    participant MemoryStore

    User->>MemoryStore: insert_or_corroborate(b, source_type in CORROBORATION_SOURCES_USER_EXPLICIT)
    MemoryStore->>MemoryStore: get_belief_by_content_hash(b.content_hash, include_retired=True)
    alt retired belief found
        MemoryStore->>MemoryStore: restore_belief(existing.id)
        MemoryStore->>MemoryStore: insert_feedback_event(belief_id=existing.id, source=FEEDBACK_SOURCE_REASSERT_REVIVE)
        MemoryStore->>MemoryStore: record_corroboration(existing.id, source_type)
        MemoryStore-->>User: (existing.id, False)
    else no belief or live belief
        MemoryStore->>MemoryStore: insert_belief(b)
        MemoryStore-->>User: (b.id, True)
    end
Loading

Sequence diagram for background capture respecting retired tombstones

sequenceDiagram
    participant BackgroundCapture
    participant MemoryStore

    BackgroundCapture->>MemoryStore: insert_or_corroborate(b, source_type not in CORROBORATION_SOURCES_USER_EXPLICIT)
    MemoryStore->>MemoryStore: get_belief_by_content_hash(b.content_hash, include_retired=True)
    alt retired belief found
        MemoryStore-->>BackgroundCapture: (existing.id, False)
    else no belief or live belief
        MemoryStore->>MemoryStore: insert_belief(b)
        MemoryStore->>MemoryStore: record_corroboration(b.id, source_type)
        MemoryStore-->>BackgroundCapture: (b.id, True)
    end
Loading

Sequence diagram for wonder_ingest UNIQUE-constraint guard

sequenceDiagram
    participant WonderLifecycle
    participant MemoryStore

    WonderLifecycle->>WonderLifecycle: wonder_ingest(phantoms)
    WonderLifecycle->>WonderLifecycle: _constituent_key(phantom.constituent_belief_ids, phantom.generator)
    WonderLifecycle->>MemoryStore: get_belief_by_content_hash(key, include_retired=True)
    alt belief (including retired) exists
        WonderLifecycle-->>WonderLifecycle: skipped += 1
    else no belief
        WonderLifecycle->>MemoryStore: insert_belief(phantom_belief)
    end
Loading

File-Level Changes

Change Details Files
Make content-hash belief lookup lifecycle-aware and support optional inclusion of retired rows.
  • Add include_retired boolean parameter to get_belief_by_content_hash with default excluding retired beliefs via valid_to filter, aligning with get_belief semantics
  • Use a dynamic SQL fragment to conditionally filter on valid_to depending on include_retired
  • Update docstring to explain default exclusion and clarify that include_retired is for UNIQUE-constraint guard callers
src/aelfrice/store.py
Define and apply a tiered policy for re-asserting retired content in insert_or_corroborate.
  • Extend insert_or_corroborate to always resolve via get_belief_by_content_hash(include_retired=True) so tombstones owning a content_hash are visible to policy logic
  • Introduce a branch where only explicit user sources revive retired beliefs by calling restore_belief and recording a neutral audit feedback event; background capture returns early without inserting or corroborating against tombstones
  • Keep the skip path semantics (return existing.id, False) while ensuring re-assertions of retired content either revive or do nothing instead of accumulating corroboration on tombstones
src/aelfrice/store.py
Model explicit user corroboration sources and a dedicated feedback audit source for revival events.
  • Add CORROBORATION_SOURCES_USER_EXPLICIT frozenset grouping CLI and MCP remember sources as "person" tier inputs consulted by MemoryStore.insert_or_corroborate
  • Define FEEDBACK_SOURCE_REASSERT_REVIVE as a feedback_history source string used to audit revival events with valence 0.0, preserving the belief’s posterior
  • Document semantics for these constants and their role in separating user assertions from background capture
src/aelfrice/models.py
Ensure wonder_ingest participates in lifecycle-aware content-hash guarding to avoid regenerating retired phantoms and violating uniqueness.
  • Change wonder_ingest to call get_belief_by_content_hash with include_retired=True so GC’d phantoms still block re-insert attempts on their synthetic content-hash key
  • Document that this opt-in is a UNIQUE-constraint guard, not a content read, and that skipping regeneration of retired phantoms is the desired lifecycle behavior
src/aelfrice/wonder/lifecycle.py
Document the behavioral change and its rationale in the v4 changelog. CHANGELOG/v4.md
Add regression tests covering re-assertion of retired beliefs, tiered revival policy, audit events, and wonder-ingest uniqueness behavior.
  • Introduce tests verifying that get_belief_by_content_hash excludes retired beliefs by default and returns tombstones when include_retired=True
  • Add parameterized tests that background capture sources neither revive retired beliefs nor write corroborations against tombstones, but still corroborate live beliefs as a negative control
  • Add tests confirming explicit user sources revive retired beliefs, preserve posterior alpha/beta, restore search visibility, and record FEEDBACK_SOURCE_REASSERT_REVIVE audit events, with a control ensuring no audit row for live-only paths
  • Assert that CORROBORATION_SOURCES_USER_EXPLICIT is a subset of CORROBORATION_SOURCE_TYPES and matches the intended source set
  • Add a wonder_ingest test ensuring a retired phantom is not regenerated and that the UNIQUE(content_hash) guard works via include_retired opt-in
tests/test_reasserted_retired_belief_1215.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1215 Change content-hash-based ingest so that re-asserting a retired statement has a defined, documented outcome instead of being silently dropped, and ensure retired beliefs do not accrue corroborations from content-hash resolution paths.
#1215 Ensure that aelf lock on a retired statement either surfaces the belief (revives it) or fails loudly, rather than reporting success while leaving the belief invisible in normal retrieval surfaces.
#1215 Add regression tests that assert the new invariants for re-asserting retired vs live content, including a negative control that re-assertion of live content still corroborates normally.

Possibly linked issues


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

@github-actions

Copy link
Copy Markdown

PR-size soft cap

This PR is over the advisory size threshold:

  • 350 changed lines (limit: 200)
  • 5 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.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Toug:2026-07-30T21:11:01Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Review — LGTM, verified independently

Policy is the ratified one (person revives, capture never does), so I did not
re-litigate it. I checked the things that could go wrong in the implementation
of it rather than reading for agreement.

The default flip is safe

Changing get_belief_by_content_hash to exclude retired rows by default is
the risky half of this PR — any caller using it as a UNIQUE-constraint guard
would now re-insert and trip content_hash NOT NULL UNIQUE. There are exactly
two call sites in src/ and both are handled (insert_or_corroborate,
wonder/lifecycle); scanner.py:172 is a prose reference, not a call. ✅

Behaviour matches the table, across every source

Rebuilt the scenario independently — insert, soft_delete_belief, re-assert
the same content — and swept all 8 members of CORROBORATION_SOURCE_TYPES:

source visible searchable corroborations belief rows
cli_remember 1 1
mcp_remember 1 1
claude_memory_mirror 0 1
commit_ingest 0 1
consolidation_migration 0 1
filesystem_ingest 0 1
transcript_ingest 0 1
wonder_ingest 0 1

The specific defect is gone: capture no longer accrues evidence against a
tombstone (0 corroborations, was 1), and a person's re-assertion comes back
into search_beliefs rather than landing on an unreadable row. Exactly one
belief row in every case, so the UNIQUE constraint is never in play. ✅

Re-asserting three times as a person yields 1 row and exactly 1
reassert:revive audit row — restore_belief's valid_to IS NOT NULL guard
makes the revive fire once rather than on every subsequent lock. Worth having;
it is the difference between an audit trail and noise. ✅

restore_belief does rehydrate the FTS row, which is what makes "back in
search" true rather than just "valid_to cleared". ✅

The tests are distinguishing, not decorative

Reverted each half and confirmed the suite notices:

revert result
drop include_retired=True in wonder/lifecycle 2 failed
let capture sources revive too (remove the tier check) 8 failed

Note on the first: one of those two failures is
test_speculative_phantom_trust::test_regenerating_a_gc_reaped_phantom_does_not_collide,
which pre-dates this PR — so that call site had independent coverage already
and the new test is belt-and-braces there rather than the only thing holding
it. Not a criticism; just so the coverage story is accurate if someone later
prunes one of them.

Full suite on the branch: 6454 passed, 69 skipped, 71 xfailed. CI green.

One observation, no action needed

For capture sources the skip path returns (existing.id, False) — an id
pointing at a row nothing can read. The PR body justifies this for the
ingest-log stamp and I agree. Worth noting that ingest.py also builds
DERIVED_FROM edges between returned ids, so a capture pass that re-observes
retired content can attach an edge to a tombstone. That behaviour is
unchanged by this PR — the same id was returned before — so it is not a
regression here, and retrieval filters retired beliefs anyway. Flagging only
so it is on the record rather than rediscovered later.

Adding ready-to-merge.

@robotrocketscience robotrocketscience added the ready-to-merge Trigger merge-train: FF main to this PR's head label Jul 30, 2026
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Toug:2026-07-30T21:21:49Z]

@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
github-actions Bot merged commit b90cab7 into main Jul 30, 2026
33 of 42 checks passed
@github-actions

Copy link
Copy Markdown

merge-train: merged b90cab7main via FF push.

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

Labels

attn:review Needs review (PR open, awaiting reviewer) author-garsecg PR coordination mutex

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(store): re-asserting a retired statement is swallowed — get_belief_by_content_hash resolves to the tombstone

1 participant