fix(forget): delete across every substrate holding the content (#366) - #374
Merged
Conversation
PRIVACY.md tells users "The `forget` tool deletes individual memories", but a
hard delete issued a single DELETE FROM memories. Two substrates kept the
content:
- the raw full text of an oversized auto-capture, in its content-addressed
artifact under ~/.claude/methodology/artifacts/ — artifact_store.py had no
removal path at all, so the row's gist went and the complete original
stayed readable on disk;
- wiki claim events derived from the memory, which survive by design:
pg_schema.py declares wiki.claim_events.memory_id ON DELETE SET NULL, so
after the row goes the claim persists and can no longer be found by id.
Root cause of the first was not the missing unlink but the missing contract:
the artifact pointer format was duplicated as an f-string in
hooks/post_tool_capture and handlers/backfill_helpers, so nothing could parse
it back safely. format_artifact_pointer / parse_artifact_pointer now own that
format in core, and both writers call it.
Removal is reference-counted. store_artifact is content-addressed with dedup,
so byte-identical captures map to ONE file; unlinking it for the first of two
referrers would strip the survivor's content. artifact_gc counts referrers
first and fails CLOSED — an unknown count keeps the file.
Two orderings are load-bearing and each has its own test: claims must be
deleted BEFORE the row (the FK nulls the link), and the artifact reference
count must be taken AFTER it (or the memory counts itself and nothing is ever
collected). Neither is discoverable from the happy path.
soft=true keeps the artifact: a soft delete is recoverable by design, so its
content must survive with it. Reported as artifact_deleted=false rather than
omitted, so a caller can tell "kept on purpose" from "we forgot to look".
PRIVACY.md now states the exact scope including both exceptions, and adds the
artifacts directory to the user's controls.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scoping mutmut to the three changed files reported 8 genuine survivors, 6 of
them in the code this series added. Each was a real gap, not an equivalent:
forget/_delete_derived_claims: no test proved claims are actually deleted —
passing the wrong arity still returned 0 through the mechanism boundary, so
half of #366's purpose was unasserted. Now seeds a wiki claim_event, forgets
the memory, and asserts both the reported count and that the claim is gone.
forget/_handler_impl: the claims_deleted key was never read by a test, so
renaming it survived. Asserted, alongside the zero case.
artifact_gc/delete_artifact_if_unreferenced: the already-absent branch logged
a path nothing checked. §13 F1 wants the signal itself asserted, so the test
now pins that the message names the path it expected.
Two pre-existing gaps in the same file, in scope under §14 because the run that
saw them was this change's own verification:
_fill_signal's budget check and char charge. Both inflate the gist past its
stated bound, which defeats the reason the budget exists (an oversized
capture must stop outranking curated memories). Bounded by a measured
assertion plus a check that the elision marker's kept count agrees with the
budget actually consumed.
extract_gist's elision total and _fill_tail's direction/skip, from the first
round: a reversed step makes the tail range EMPTY so the end of every dump
vanishes, and break-instead-of-continue truncates the tail whenever a signal
line sits inside the tail window.
Scoped mutation run now reports 0 surviving mutants, 347/347.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cdeust
force-pushed
the
fix/forget-cross-substrate-366
branch
from
August 7, 2026 08:41
b030073 to
39f476e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #366.
PRIVACY.md tells users "The
forgettool deletes individual memories." A hard delete issued a singleDELETE FROM memories. Two substrates kept the content:~/.claude/methodology/artifacts/—artifact_store.pyhad no removal path at all, so the row's gist went and the complete original stayed readable on disk;pg_schema.py:221declareswiki.claim_events.memory_id ON DELETE SET NULL, so after the row goes the claim persists and can no longer be found by id.Root cause, not just the symptom
The missing
unlinkwas downstream of a missing contract: the artifact pointer format was duplicated as an f-string inhooks/post_tool_captureandhandlers/backfill_helpers, so nothing could parse it back safely and a drift in either writer would have broken any deleter silently.format_artifact_pointer/parse_artifact_pointernow own that format incore, and both writers call it.Three things that would have made a naive fix wrong
artifact_gccounts referrers first and fails closed: an unknown count keeps the file.memory_id.Neither ordering is visible from the happy path; each has its own test.
soft=truekeeps the artifact (a soft delete is recoverable by design) and reportsartifact_deleted: falserather than omitting it, so a caller can tell kept on purpose from we forgot to look.Completion Ledger
Test names below are enumerated from
git diff origin/main...HEAD— not recalled.Diff paths → asserting test
test_hard_delete_removes_the_artifact_filetest_hard_delete_reports_artifact_removaltest_soft_delete_keeps_the_artifacttest_refused_delete_keeps_the_artifacttest_shared_artifact_survives_while_another_memory_references_itfalse, notNonetest_memory_without_artifact_reports_false_not_nonetest_hard_delete_removes_derived_claims_and_reports_the_counttest_memory_with_no_claims_reports_zerotest_already_absent_artifact_logs_the_path_it_expectedtest_round_trip_recovers_the_path,test_round_trip_inside_a_full_memory_bodytest_path_containing_spaces_survivestest_char_count_suffix_is_not_absorbed_into_the_pathtest_absent_pointer_returns_none,test_empty_content_returns_none,test_malformed_pointer_returns_none_rather_than_a_bad_pathtest_first_pointer_wins_when_a_body_carries_two§13.1 checklist
delete_artifact_if_unreferencednever raises; each non-removal path logs why and returnsFalseartifact_deleted/claims_deletedadded to the result; no field removedCORTEX_CLAUDE_DIR+ throwaway DBruff check+ruff format --checkclean on all changed filescore/imports noos/pathlib/infrastructuredocs/mcp-tools.md, CHANGELOGartifact_gc.py,gist_extraction.py,forget.pyextract_gist/_fill_tail— a reversed step made the tail range EMPTY (the end of every dump vanished) andbreak-instead-of-continuetruncated it; all fixed here, not deferredVerification honesty
The first mutation run reported 3 survivors and I treated it as trustworthy. It was not: the run that matters reported 8, six of them in this PR's own new code, including
_delete_derived_claims— where no test proved claims were actually deleted, so half of #366's purpose was unasserted while the suite stayed green. All 8 are now killed.🤖 Generated with Claude Code