fix(governance): remove the entity rows mined out of a dropped memory - #1297
Conversation
|
@claude please review at head What I most want challenged:
Note this overlaps #1292 (H-10), which adds a different cascade to the same two drop branches and is in review. Whichever lands second needs a mechanical rebase. |
9550adf to
ac7b8a2
Compare
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
ac7b8a2 to
82d5dc4
Compare
|
Both correct. Fixed at The High, and why my own reasoning was wrongI wrote that failures propagate here "matching every other unapplied-policy path in this module". That is wrong about the caller, and you traced why: the other paths run under I verified the invariant rather than taking it on trust — The purge failure is now caught and logged at ERROR naming the memory. The trade is bounded and worth stating: the memory is already gone, so the content is not live — what remains is graph rows, and the log is enough to purge them by hand. Probe: letting it propagate again fails the new test with the raw The LowTaken. The client reserves One cost I recorded rather than hid: a lost response followed by a successful retry logs zero counts for a purge that did remove rows. A wrong number in an INFO line, against leaving forbidden content live. This same defect is in #1292, and I am fixing it there next
Flagging it here because the two PRs touch the same branches and the fix has to be consistent between them. VerificationSuite 6092 passed / 0 failed on the rebased tree; storage suite 343; ruff, mypy, ratchet, sentinel, tenant-scope and the broker baseline all clean after |
82d5dc4 to
507f40b
Compare
|
@claude re-review at head Your High is fixed, and the fix itself has not been reviewed. Your last review ran against
What I want attacked. The catch site. Swallowing here is a real trade, not an obvious win: is there a caller or a failure mode where a silently-failed purge should have aborted something downstream? I convinced myself there is not because the purge runs last and nothing reads its result — falsify that if you can. The over-deletion bound, again. It is the part I would least like to be wrong about, because under-deleting is recoverable and deleting another caller's rows is not. The candidate set is bounded to entities this memory linked to, then filtered to those with no remaining links and no remaining relations. My first draft deleted every unlinked entity in the tenant. Is the bounded version still reachable by a concurrent write — an entity created and linked between the candidate One thing I will say plainly rather than let the test count imply otherwise: unlike the other findings in this series there was no pre-existing code path to make fail, because nothing could reach these rows at all. What is probe-confirmed is each guard I added — reverting the candidate bounding fails the over-deletion test with 🤖 Generated with Claude Code |
This comment was marked as resolved.
This comment was marked as resolved.
293286d to
5abc12d
Compare
|
@claude re-review at head You were right, and the tell was a comment I wrote three lines below the bug: "Tenant-scoped like everything else here." The entity delete was. The two statements above it were not. That is the same defect class this backlog keeps turning up — prose asserting a property the code does not have — and it has now landed in my own writing on both PRs in this pair. What was wrong. Reachability, stated honestly rather than talked up. The live caller passes tenant and memory from the same row, so this is not currently exploitable through governance. It is the invariant breaking ahead of anything exploiting it, on a storage endpoint whose entire job is to be called with caller-supplied ids. I would not have argued it down to Medium on that basis. One deliberate divergence, which I want you to attack specifically. Both statements are now scoped through Tests. The mismatched-pairing one is probe-confirmed: without the scoping it fails with You were also right that Verified at this head: core-storage-api 353 passed, core-api 6092 passed, ruff check and format clean at CI's scopes, mypy clean on Note the branch was force-rebased three times today by its author while I worked; I confirmed by patch-id each time that content was unchanged ( 🤖 Generated with Claude Code |
This comment was marked as resolved.
This comment was marked as resolved.
5abc12d to
0bc8a5c
Compare
|
@claude re-review at head You were right that the three anti-join subqueries were unscoped and that correctness was never at risk from that. Narrowing them is worth doing on a path a drop-configured tenant runs constantly. Where I diverged. The proposal scopes the two relation subqueries on I wrote the test before believing myself, and it fails under the suggested version with What I did instead. All three subqueries are narrowed by the entity's tenant — joined to
Verified at this head: core-storage-api 354 passed (353 + the new straddling-relation test), ruff check and format clean, mypy clean on Worth noting for the record that both fixes on this PR — round 3's tenant scoping and round 4's narrowing — turned on the same distinction: which end of a link or relation you key on. Keyed on the memory, deleting is safe and reading leaks. Keyed on the entity, reading is safe and deleting over-reaches. The two halves of this function key on opposite ends deliberately, and each now says so where it does it. 🤖 Generated with Claude Code |
0bc8a5c to
626b531
Compare
|
@erni-a please approve |
This comment was marked as resolved.
This comment was marked as resolved.
626b531 to
5855c6d
Compare
|
@claude re-review at head Your Medium was right, and it found the hole in my own reasoning rather than in my code. Rebased onto main now that #1292 has merged — four files conflicted, all additive collisions between the two cascades. One resolution was a real decision rather than a mechanical merge, flagged below. The Medium: the check ran too early to close the window it targetedYou're correct, and the tell was my own comment: "This narrows the window rather than closing it… the cascade in So the fix had a blind spot shaped exactly like the leak it was closing. Closed by re-checking after the writes and purging what was just written, which I picked over your option (a) because moving the check earlier still leaves a race — it just makes it smaller. The post-write version is an argument about what's observable rather than about timing:
No ordering survives. I considered your option (c), enforcing it at the storage layer, and it is the better long-term shape — but it changes the contract of link and relation upserts for every caller, which is not something to slip into this PR. Failures log rather than raise: this runs after the links are written, so raising would abort the subject write-back and cross-link discovery below over a cleanup concern. The early check stays, downgraded in the comments to what it honestly is — an optimisation that avoids the work when the row is already gone. Two tests. The drop-during-writes one is probe-confirmed: removing the post-write call fails it. The second is an over-refusal guard on the ordinary live-row path, and I want to be explicit about why it exists rather than folding it into the first — a post-write purge that fired on a live row would delete the graph rows of every successfully extracted memory in the install. The failure mode of this fix is considerably worse than the leak it closes, so it gets its own test. The LowFixed — the route now returns 422 on a malformed The merge decision worth reviewingBoth drop branches now run the entity purge and #1292's child cascade, and the purge goes first. One thing I noticed during the merge and deliberately did not fix here. #1292's cascade soft-deletes child memories, and those children can have graph rows of their own — the cascade doesn't purge them. That is this same leak, one level down, created by the interaction of the two PRs rather than by either alone. It needs its own tests around the child path and I'd rather it be reviewed as its own change than appended to a PR already five rounds in. Filing it. Verified at this head: core-api full suite green, core-storage-api 358 passed, ruff check and format clean at CI's scopes, mypy clean on 🤖 Generated with Claude Code |
This comment was marked as resolved.
This comment was marked as resolved.
7934557 to
1e1e421
Compare
|
@claude re-review at head Your High is a defect in the previous round's own fix, and you found the thing that hid it.
The sharper half of your finding is the fixture, not the code: every test in that file leaves Fixed. The helper returns I also split the two failure modes while in there, since they're different states:
The Medium, with a correction to its premiseTaken — each cascaded child is now purged after its delete succeeds, never counted as a cascade failure (the helper logs and swallows, matching the parent). But the reasoning in the finding is broader than what the code does, and I checked before writing it. Auto-chunk children go through So this closes a narrow real case and keeps the invariant true of the cascade whatever populates children in future. It is not the broad "children leak PII entities" the wording implies, and I'd rather say so than let the fix look bigger than it is. I'd raised this myself last round in the broad form; verifying it is what corrected me. Three tests: the cascade purges each child; a child whose delete failed is not purged (still live, so its graph rows describe content no policy removed); Rebased onto the newer main under it; patch-id verified unchanged ( 🤖 Generated with Claude Code |
1e1e421 to
c3a8eb1
Compare
This comment was marked as resolved.
This comment was marked as resolved.
c3a8eb1 to
dfab6c5
Compare
|
@claude re-review at head Taken — and the suggested form of it would have left a live memory losing its relations. I have the failing test. You're right that the invariant belongs where the deleting happens. Both callers check that the memory is dropped, so this wasn't reachable; but the method deletes across three tables and can't be undone, and "only purge what governance actually dropped" shouldn't live only in the callers' heads. A stale call, a reordering, or a future caller written from the method name alone would have wiped a live memory's entity graph. Where I diverged. The proposal was to add I wrote the test before trusting that reading, and under your exact suggestion it fails with What I did instead: one guard, before anything is deleted — a row with this id, in this tenant, with Two things about the tests worth flagging rather than burying. The storage tests were creating live memories and purging them — a state no caller produces. They now soft-delete the target first via a And Verified at this head: core-storage-api 359 passed, core-api full suite green, ruff check and format clean, mypy clean on 🤖 Generated with Claude Code |
SummaryThis PR closes the H-02 gap where entities/links/relations mined from a memory could survive a governance soft-delete (since FK cascades only fire on hard delete). The storage-side purge ( Medium/Low IssuesPost-write liveness re-check doesn't cover relations/subject write-back/cross-link discoverySeverity: Medium 🤖 Claude Code PromptReviewed by |
dfab6c5 to
2c07fbc
Compare
|
@claude re-review at head Right again, and it lands on the paragraph that existed to justify the mechanism. Round 6 fixed the case where the check returns And the docstring said "there is no ordering left in which the rows survive." True of rows written before the call; false of everything after it. Same overclaim as last round, one round later, in the paragraph whose whole job was to be precise about this. That's three times on this PR that my prose has asserted a property the code didn't have — it is reliably the weakest thing I produce here, and it is why I'd rather these rounds keep happening than stop early. Fixed by calling it twice. The first call keeps its early-exit role: it saves the relation upserts and a cross-link discovery pass in the common case where the row was already gone. The second sits after every graph-mutating write, so the rows it can find are all of them — that's the one that actually closes the window. The docstring now says which call does what, rather than claiming the property for the mechanism as a whole. I did not take the "move it later" half of your suggestion, because the early exit is worth keeping — cross-link discovery is a storage round-trip with a vector search, and skipping it for a row that's already gone is free. One thing deliberately left uncovered, stated rather than assumed: contradiction detection. It's spawned via The test drives a drop that only becomes visible after the relation write — live at the pre-write check, live at the post-link check, dropped at the final one — and asserts both that the relation was written (that's the premise) and that the purge ran. Removing the final call fails it. Verified at this head: core-api full suite green, core-storage-api 359 passed, ruff check and format clean, mypy clean apart from the two pre-existing 🤖 Generated with Claude Code |
SummaryThis PR closes a real gap (H-02): entity/relation/link rows mined out of a memory that governance drops mid-extraction previously survived forever, since soft-delete doesn't trigger the FK cascades. The storage-side purge ( Medium/Low IssuesTransient liveness-check failures are treated as a confirmed drop, silently discarding audit/contradiction/cross-link work for a likely-still-live memorySeverity: Medium 🤖 Claude Code PromptEvery entity-extraction run now makes 2–3 extra synchronous reads against the storage WRITERSeverity: Low 🤖 Claude Code PromptReviewed by |
bc77575 to
41a8f36
Compare
|
Head The MediumYou were right, and the part I want to acknowledge specifically is the cost I had not weighed. My docstring reasoned about the indeterminate case only in terms of losing relations, which a later content update rebuilds. It also skipped the audit-log entry, the contradiction trigger and cross-link discovery, because the first call site returns before all three. An audit record lost to a read timeout is rebuilt by nothing, and the task is fire-and-forget. The root cause was a boolean carrying a policy it couldn't express — "you must stop" forced the indeterminate case to impersonate one of the two real answers. The helper now returns
A failed purge still reports The Low — not fixed, and whyThree writer-routed reads per extraction is real added load on a path that runs for essentially every memory with entities. The routing is deliberate (a replica can't see a just-committed delete), but you're right that nobody has load-tested the volume. That's an operational question — it wants a look at writer read-QPS under bulk import, not a code change decided inside this PR. It's recorded in the commit message as explicitly not done rather than left implied. A correction about my own verification claimsSeveral of my earlier comments on this PR ended with a line like "ratchet 'No new lines.', sentinel 'All 35 protected strings survive.', tenant-scope gate exit 0." Those three gates were being run against the wrong working tree. My shell's directory resets between commands, and those scripts inspect the current directory rather than their own location — so they were measuring a different worktree, one sitting on the already-merged #1292 branch. They reported green, and re-run correctly on this branch they are still green (ratchet 0, sentinel 0, tenant-scope gate 0), so nothing bad shipped. But for several rounds those lines were not evidence about this PR, and I stated them as if they were. The bug surfaced only because #1292 merged and that stale tree drifted from main, flipping a false pass into a false failure — which is exactly the way this class of mistake normally stays invisible. Also rebased onto current main, which the branch had fallen behind. Verified at this head, from the correct tree: core-api 6132 passed, core-storage-api 359 passed, ruff check and format clean, mypy clean on Not requesting another review round on this one — nine is enough, and the remaining item is an operational question rather than a code finding. 🤖 Generated with Claude Code |
41a8f36 to
472f12c
Compare
|
@claude re-review at head This round reviews a change nothing has seen yet, and the finding is mine rather than a reviewer's. Round 9's fix left an exit uncovered. What was wrongThe check at the end of the Round 9 is what made this reachable rather than theoretical. Falling through on The comment I wrote there called the final check "the guarantee". It is the guarantee for one of the two ways out of the function. That is the third time on this PR that my prose has asserted a property the code did not have, and the second time the overclaim was in the paragraph written to justify the mechanism. The fixThe Not a The flag is set BEFORE the upsert await, not after, and that is load-bearing. "The call raised" is not "nothing was written": a timeout can land on a request storage already committed, and then the rows exist while the caller only ever saw an exception. Setting the flag afterwards would skip the check on exactly that case and leak the rows. Being wrong in the other direction costs one writer read on a call that never landed, on a path that is already failing. TestsFour, and I am explicit about which is which:
Also in this pushTwo comment corrections, both found by reading my own diff rather than by a reviewer:
And a commit-message repair unrelated to the code: the body had lost its opening line in some earlier amend, so it began mid-sentence on Still not done, deliberatelyYour round-8 Low — three writer-routed reads per extraction task, fleet-wide, never load-tested — is unchanged, and this push makes it four on the failure path. The routing is deliberate (a replica cannot see a just-committed delete) but the volume is an operational question, not one to settle inside this PR. Verification
|
SummaryThis PR closes an entity/relation graph leak (H-02) where memories dropped by governance mid-extraction, or via cascade, left orphaned entity/link/relation rows behind. The core storage-layer purge ( Missed opportunity to reuse the
|
472f12c to
bb7e3d8
Compare
|
@claude re-review at head Taken — and the suggested form of it would have reopened H-02. I have the failing test. You were right on the observation. The end-of- Why the stated fix leaks
Cross-link discovery is gated on insert_link_returning = (
pg_insert(MemoryEntityLink)
.values(rows)
.on_conflict_do_nothing(index_elements=["memory_id", "entity_id"])
...
)
Probe-confirmed. Applying the suggestion verbatim: What I did insteadBoth writers set the flag, each before its own await, and then the final check is gated. That is the efficiency you asked for — a run that wrote nothing and had linking disabled no longer pays for a writer read — without the leak. Behaviour is unchanged wherever anything was actually persisted. The flag's declaration now states what it has to mean for the gate to be safe: "this memory MAY have graph rows", not "the persistence block ran". Gating on the narrower reading is the bug, so the name and the comment both have to carry the wider one. The helper docstring is corrected in the same pass: it said the A pattern worth namingThis is the third time on this PR that the observation was worth acting on while the remedy was wrong in the unrecoverable direction — after the relation anti-join scoped by VerificationFull core-api suite 6137 passed, 0 failed. Still unchanged and still deliberate: your round-8 Low on writer-routed read volume. That is an operational question for a load test, not something to settle in this PR. |
SummaryThis PR implements H-02: purging derived entity/relation/link graph rows when a memory is governance-dropped, both for the entity-extraction worker race window and for the governance remediation cascade. The core SQL purge logic ( Medium/Low IssuesUnguarded
|
bb7e3d8 to
4eb5a9d
Compare
|
@claude re-review at head Taken, in full — and your severity note on the worker site is understated rather than lower. You're right that The tell you identified is in the client itself: return await self._post( # type: ignore[return-value]
"/memories/purge-entity-artifacts",
...
Where I differ
True of two of the three call sites. Not the third. The one added in round 10 is the catch-all — it lives in the What the fix saysBoth sites now treat a non-object response as its own state instead of forcing it into one of the two answers they already had — the same mistake as the bool-with-a-policy from round 9, in a different place. A 2xx means the call succeeded. A body we cannot read means we cannot say what it did. So neither "purged" nor "failed" is a claim worth making: governance logs at ERROR that the purge cannot be confirmed and the rows may still be listable, and the worker logs that the counts are unknown while still returning A note on my own testThe governance test failed the first time I ran it, and the reason is worth recording. I wrote It now also asserts the Both new tests are probe-confirmed: removing the two guards fails exactly those two and nothing else. VerificationFull core-api suite green. Still unchanged and still deliberate: the round-8 Low on writer-routed read volume, which is a load-test question rather than a PR one. |
SummaryThis PR closes an entity/relation-graph data leak (H-02) where governance-dropped memories left their extracted PII/entity rows behind. The implementation is extensive and unusually well-reasoned (race windows, idempotency, tenant scoping, over-deletion guards are all carefully handled and tested). I found one real correctness gap in the purge's completeness guarantee; the rest is solid. Medium/Low IssuesPurge misses entities that were created but never linkedSeverity: Medium 🤖 Claude Code PromptReviewed by |
#808 named this case when it fixed the inline path: "entities mined out of dropped content are the same leak in another table". It fixed that path by ordering — _enrich_memory_background runs remediation first, and its early return on a drop skips the entity extraction scheduled below it. Both non-inline paths schedule extraction independently, at write time, as a fire-and-forget task that races the verdict. ScheduleBackgroundTasks fires it alongside the enrichment carrying run_governance_remediation=True in the fast branch, and at write time in strong+deferred. process_entity_extraction never re-checked the row. And the schema's own expression of "these rows must not outlive the memory" never fires: memory_entity_links.memory_id is ON DELETE CASCADE and relations.evidence_memory_id is ON DELETE SET NULL, both on a HARD delete. Governance soft-deletes — it sets deleted_at — so neither ever runs. The entity row itself has no FK to the memory at all, so nothing would remove it even on a hard delete. Result: a tenant configured to drop had the memory removed and audited while the names mined from it (person names, under a PII policy) stayed listable tenant-wide through /entities and /graph, with nothing tying them to the drop. Verified from the code rather than reproduced as one failing assertion, and the distinction is worth being straight about: unlike the earlier findings in this series there was no existing code path to make fail, because nothing could reach these rows at all. What IS probe-confirmed is each guard added here — reverting the candidate bounding fails the over-deletion test with 2 == 1, and neutering the liveness check fails the dropped-row test. Two halves, and they are not alternatives. 1. A purge on the drop path. New storage call, one transaction: delete the memory's entity links, then relations whose evidence IS this memory (one row carries one evidence id, so a relation attributed to dropped content has no other justification), then — from the entities this memory linked to and only those — the ones now left with no links and no relations. Both destructive dispositions cascade, not just the non-business one the finding described: they are separate branches reading separate configs, and a PII drop policy leaked identically. The candidate set is bounded on purpose. A first draft deleted every entity in the tenant with no links, which would sweep entities orphaned for unrelated reasons and race an entity a concurrent write had created but not yet linked. Under-deleting is recoverable; over-deleting another caller's rows is not. A test pins it: the unbounded version fails with 2 == 1. 2. A liveness re-check in the worker, immediately before persisting, reading the WRITER — the whole point is to observe a delete that just committed, and a replica under lag would report the row live exactly when the check most needed to fail. Half 1 covers the common ordering: extraction is one LLM call while the verdict needs enrichment plus an event round-trip, so extraction usually finishes first and its rows are there to purge. Half 2 covers the tail where it does not — the purge has already run by then and would miss what lands afterwards. Neither half covers the other's case. The purge is deliberately NOT gated on a marker, unlike H-10's child cascade: any dropped memory may have been extracted from, no flag on the row says so, and the purge is three targeted deletes keyed on memory_id. The purge runs AFTER the soft-delete, and that ordering is pinned. Purging first would destroy graph rows for a memory that is still live if the delete then failed, and nothing would put them back. Tests. Five in core-storage-api against real Postgres, because the query's correctness is entirely about what it does and does not reach and a stub would assert the code calls itself — including the over-deletion guard, the still-asserted-by-another-memory case, and the tenant boundary. Three in core-api for the wiring, one of them an over-refusal guard that flag and keep_private purge nothing (those rows describe content that is still there and still allowed). Two for the worker guard, asserting on the WRITES rather than the early return so a refactor that keeps the check and persists anyway fails, plus one that the check reads the writer. Four existing entity-extraction test files gained a get_memory stub. That is the honest cost of the worker now depending on a storage read it did not before, not churn to hide a problem. Overlaps #1292 (H-10), which is in review and adds its own cascade to the same two drop branches. Whichever lands second needs a mechanical rebase; the two mechanisms are independent — one covers rows derived into the memories table, this one covers rows derived into the graph. Review round: the purge call is marked idempotent. _post only retries connection-phase failures unless told the endpoint is safe to replay. The caller lets failures propagate, so without that a transient 5xx aborted a remediation whose soft-delete had ALREADY committed, leaving the graph rows behind until someone read the failed task. This client reserves idempotent=True for endpoints that dedup replays storage-side. The purge qualifies for a different reason worth writing down: a replay finds the rows already gone and deletes nothing more. The one cost is cosmetic — a lost response followed by a successful retry logs zero counts for a purge that did remove rows, which is a wrong number in an INFO line against leaving forbidden content live. Second review round: a purge failure must not nack the event. The first draft said failures propagate "matching every other unapplied-policy path in this module". That was wrong about the CALLER, and review caught it. The other paths run under _enrich_memory_background, where a raise becomes a BackgroundTaskLog row. This one also runs under consumer.handle_memory_enriched, which has no guard, and the Pub/Sub dispatcher nacks on a handler exception — a documented, load-bearing invariant. A raise redelivers the same event, re-runs the whole drop branch, and emits a SECOND critical=True audit for a memory that was already dropped. Repeatedly. The purge failure is now caught and logged at ERROR naming the memory. The trade is bounded: the memory is already gone so the content is not live, what remains is graph rows, and the log is enough to purge them by hand. Transient failures do not reach that path at all now that the call is marked idempotent. A test pins it: letting the failure propagate again fails with the raw RuntimeError. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Third review round: memory_id is an identifier, not an authorisation. The link delete and the candidate select were keyed on memory_id alone. Review caught it, and the tell was a comment I had written three lines below them: "Tenant-scoped like everything else here." The entity delete was. The two statements above it were not. memory_entity_links has no tenant_id column, so a link row carries no predicate of its own — which is exactly why this file already has _link_within_tenant, used by the method IMMEDIATELY above this one, and _owned_link_endpoints on the write side, both there because of GHSA-wgvw-28pq-jc36. This method ignored both. A caller passing a memory_id its tenant does not own deleted the OWNING tenant's link rows and got a success response saying how many. Not reachable through the live caller — governance passes the tenant and memory from the same row — so this is the invariant breaking before anything exploits it, on a storage endpoint whose whole job is to be called with caller-supplied ids. Both statements are now confined to links whose memory belongs to tenant_id, so a mismatched pairing is a no-op. Deliberately the memory end only, NOT _link_within_tenant. That helper requires BOTH ends because a READ returning a straddling row hands back the other tenant's UUID. Deleting asks a different question: this row references a memory we own and are dropping, so a foreign entity on the far end is a reason to keep the ENTITY — the tenant-scoped entity delete already does — and never a reason to keep a link pointing at dropped content. Requiring both ends would strand exactly the historical straddling rows the write path has refused to create since #1085/#1124. Two tests. The mismatched-pairing one is probe-confirmed: without the scoping it fails with links: 1 where 0 is required. The straddling-link one passes either way against the original bug and is honest about what it is for — it pins the choice above, and fails if someone "fixes" this by reaching for _link_within_tenant. The existing test_does_not_cross_tenants did not catch this and could not: it uses matched tenant/memory pairs throughout, so the unscoped delete only ever touched rows the caller did own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Fourth review round: the "still referenced?" anti-joins are narrowed, but not the way the review suggested. The three subqueries behind the entity delete were unscoped, so each anti-join considered every install's links and relations on a path a drop-configured tenant runs constantly. Correct but wasteful, which is how review graded it. The suggested narrowing was Relation.tenant_id on the two relation subqueries. That one is not safe. A historical straddling relation — one in another tenant naming an entity here — drops out of the anti-join under that filter, and the entity is then deleted while something still references it. Over-deleting is the direction that does not come back, and this file has already been through that once in round 1. All three are narrowed by the ENTITY's tenant instead: joined to Entity and filtered on Entity.tenant_id. Same reduction in scan, and it cannot lose a reference — every row that could name a candidate names an entity in this tenant, because that is what a candidate is. Erring wide costs nothing here, since a surplus reference only keeps an entity alive. A test pins the difference: a relation in another tenant naming this tenant's entity must leave the entity standing. Under the suggested version it fails with entities: 1 where 0 is required. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Rebased onto main after #1292 (H-10) merged. Four files conflicted; all four were additive collisions between the two cascades, resolved by keeping both. One resolution is a real decision rather than a mechanical merge. Both drop branches now run the entity purge AND the child cascade, and the purge goes FIRST. _drop_children raises once any child fails, so ordering the cascade first would skip this parent's own graph rows on exactly the runs where something had already gone wrong. The purge cannot raise — it logs — so it never blocks the cascade in return. Fifth review round: the liveness check ran too early to close the window. The check sat immediately after the extraction LLM call, but the writes are several round-trips further on — embeddings, resolve, upsert, links. A drop landing inside that gap runs its own purge while these rows do not exist yet, finds nothing, and the entities land afterwards. Nothing revisits them: the memory is gone, so no later verdict names it. That is the exact leak this PR exists to close, reachable through the fix's own blind spot. My comment there said "narrows the window rather than closing it" and pointed at the governance-side purge as the cover. That was wrong in one direction — the purge covers extraction finishing BEFORE the verdict, not after. Closed by re-checking AFTER the writes and purging what was just written if the row died. The argument is about what is observable, not about timing: - drop committed before our writes: its purge found nothing, our post-write check sees the row deleted, we purge, - drop commits after our writes: its own purge sees our rows and takes them, - drop commits between: whichever purge runs later sees the rows, and both are keyed on the same memory_id. No ordering survives. The WRITER read is load-bearing for the same reason as the earlier check: the question is whether a delete that just committed is visible. Failures log rather than raise — this runs after the links are written, so a raise would abort the subject write-back and cross-link discovery below over a cleanup concern. The early check stays, downgraded to what it honestly is: an optimisation that avoids doing the work when the row is already gone. Two tests. The drop-during-writes one is probe-confirmed — removing the post-write call fails it. The second is an over-refusal guard on the ordinary path, and it earns its place: a post-write purge that fired on a LIVE row would delete the graph rows of every successfully extracted memory in the install. The failure mode of this fix is worse than the leak it closes, so it does not ride on the first test. Also fixed while in the file: the purge route parsed memory_id as a UUID unguarded, so a malformed id surfaced as a 500 where every sibling route in that file returns 422. The caller lets failures propagate out of a remediation, so "the purge broke" was the wrong thing for it to hear. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Sixth review round: purging was only half of it, and the children were missed. The High is a defect in the previous round's own fix. _purge_written_artifacts_if_dropped detected the drop and cleaned up, then returned None and let process_entity_extraction carry straight on — relation upserts carrying evidence_memory_id, the subject write-back, contradiction detection, cross-link discovery. It cleaned the link table and immediately refilled the relation table. The leak moved; it did not close. Every test in that file left graph.relations empty, so the relation loop had nothing to iterate and none of them could have caught it. That is the more useful half of the finding: the fixture, not the code, is what hid it. The helper now returns bool and the caller returns on True. A test with a non-empty graph.relations pins it — restoring the fall-through fails it on upsert_relation having been awaited — and a second test pins the other direction, that a LIVE row still gets its relations and cross-links, because a short-circuit that fired unconditionally would silently stop writing them for every extracted memory in the install. Failure handling is split while there, because the two failures are different states. A failed liveness read is indeterminate: it returns True, refusing to write more graph rows for a row that cannot be shown to be live. A failed purge also returns True — a purge that did not run does not make the memory live again, so continuing would be strictly worse than the failure. Only an affirmatively live row returns False. The Medium: _drop_children soft-deletes children without purging their graph rows, so the invariant this PR enforces for the parent did not hold one level down. Each cascaded child is now purged after its delete succeeds, never counted as a cascade failure — the helper logs and swallows, matching the parent. Worth stating accurately rather than overselling, because I checked the paths before writing it: auto-chunk children go through sc.create_memories directly and get NO extraction of their own. The parent is what gets extracted, over the full document, so the names mined from chunked content hang off the PARENT and its purge already reached them. A child acquires graph rows only when something later rewrites its content, since update_memory re-extracts. So this closes a narrow real case and keeps the invariant true of the cascade whatever populates children later — it is not the broad leak the finding's wording implies. Three tests: the cascade purges each child; a child whose DELETE failed is not purged (it is still live, so its graph rows describe content no policy removed); and keep_private purges nothing. Probe-confirmed — removing the call fails the first with "a dropped row kept its graph rows: {'m1'}". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Seventh review round: the purge now refuses to run against a live memory. It deleted graph rows for any (tenant_id, memory_id) pair a caller named. Both callers check that the memory is dropped first, so this was not reachable — but the method deletes across three tables and cannot be undone, and "only purge what governance actually dropped" should not be an invariant that lives only in the callers' heads. A stale call, a reordering, or a future caller written from the method name alone would have wiped a live memory's entity graph. One guard, checked before anything is deleted: a row with this id, in this tenant, with deleted_at NOT NULL. Otherwise an early return with zero counts. Deliberately an early return rather than the narrower fix of adding deleted_at IS NOT NULL to the ownership subquery, and the difference is not stylistic. That subquery gated the LINK statements only — the relation delete keyed on evidence_memory_id and the tenant alone and never took it. Narrowing only the subquery leaves a live memory losing its RELATIONS while its links and entities survive: partial destruction, which is harder to diagnose than either outcome and still unrecoverable. Probe-confirmed — with that version the new test fails on relations: 1 where 0 is required. The storage tests were creating LIVE memories and purging them, which is a state no caller produces. The purge targets are soft-deleted first now, through a _dropped_memory helper; the rows that must survive (the second asserting memory, the other tenant's) stay live deliberately. The mismatched-tenant test was updated to soft-delete its memory too. Left live it would have started passing for the wrong reason — the liveness guard rather than the tenant check it exists to pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Eighth review round: the post-write check was still too early. Round 6 fixed the case where the check returns True — it now stops the caller instead of purging and falling through. It did nothing for the case where the check returns FALSE. The row is live at that moment, execution proceeds, and the subject write-back, the relation upserts carrying evidence_memory_id, and whatever cross-link discovery creates are all written AFTER the only liveness check on that path. A drop landing across that stretch runs its own purge against rows that do not exist yet, and nothing revisits them. The docstring claimed "there is no ordering left in which the rows survive". That was true of rows written before the call and false of everything after it — the same overclaim, one round later, in the paragraph that exists to justify the mechanism. _purge_written_artifacts_if_dropped is now called TWICE. The first call keeps its early-exit role and saves the relation upserts and a cross-link discovery pass when the row is already gone. The second sits after every graph-mutating write, so the rows it can find are all of them, and it is the one that actually closes the window. The docstring says which call does what instead of claiming the property for the mechanism as a whole. Contradiction detection is deliberately not covered: it is spawned via track_task and writes conflict rows rather than graph rows, and it re-checks deleted_at itself for this exact race. A test drives a drop that only becomes visible after the relation write — live at the pre-write check, live at the post-link check, dropped at the final one — and asserts both that the relation WAS written and that the purge ran. Removing the final call fails it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Ninth review round: three states, not a bool with a policy baked in. _purge_written_artifacts_if_dropped returned bool meaning "you must stop". That forced the indeterminate case — the liveness READ itself failed — to pick one of the two real answers and pretend. It picked "stop", and the reasoning recorded for that choice only weighed losing relations, which a later content update rebuilds. It also skipped the audit-log entry, the contradiction trigger and cross-link discovery, because the first call site returns before all three. An audit record lost to a transient read timeout is not rebuilt by anything, and the task is fire-and-forget so nothing retries it. That cost was never in the trade I wrote down. The helper now reports what it found — live, dropped, unknown — and the callers decide, because they legitimately differ: - the first call site acts on 'dropped' ONLY. It exists to save the relation upserts and a cross-link discovery pass, so it has no business destroying the audit work on a non-answer. A real drop is still caught by the final check, which is the guarantee. - the final call site does not branch at all: nothing follows it, so 'live' and 'unknown' are the same instruction, and 'dropped' has already purged by the time it returns. A failed PURGE still reports 'dropped' — the memory is gone whether or not the cleanup worked, and the caller's decision does not change. Only a failed READ is 'unknown', and it carries a structured liveness_check field so its real-world frequency is measurable rather than inferred. This is the same shape as the defect three rounds ago: a boolean carrying a policy it could not express. Naming the states is what makes the two call sites readable, and it is why the fix is smaller than the rounds that preceded it. A test drives a transient read failure at the middle check and asserts the audit entry, the relations and cross-link discovery all still happen, and that nothing was purged. Reverting the indeterminate case to 'dropped' fails it. Not done, and worth stating rather than leaving implied: review also asked whether three writer-routed reads per extraction is affordable fleet-wide. The routing is deliberate — a replica cannot see a just-committed delete — but the volume has not been load-tested, and that is an operational question rather than something to settle inside this PR. Tenth round, and this one is mine rather than a reviewer's: the round-9 fix left an exit uncovered. The check at the end of the try block is the leak guarantee for the path that completes. It is unreachable on the path that does not. Anything between the link upsert and it that raises — a relation upsert, the subject write-back, the audit call — jumps straight to the except handler, and the entities and links already committed stay behind for a memory that may have been dropped. There is no finally; that call sits inside the try body like everything else. Round 9 is what made this reachable rather than theoretical. Falling through on 'unknown' was the right call — it stopped a transient read timeout destroying an audit record nothing rebuilds — but it means the first call site now hands the responsibility forward to a later check, on a path where a later check may never run. The comment I wrote there called the final check "the guarantee". It is the guarantee for one of the two ways out of this function. The except handler now carries the same check, guarded on whether anything was written at all. Not a finally, and the three early returns are why. The no-entities exit happens before sc is bound, so an unguarded finally raises NameError out of a fire-and-forget task. The already-dropped exit would spend a writer read to learn what it just learned. The 'dropped' exit would repeat a purge that had only just run. Those are the common paths, not the rare ones. The flag would have to gate a finally anyway, so all finally buys is one fewer call site. The flag is set BEFORE the upsert await, not after, and that is load-bearing. "The call raised" is not "nothing was written" — a timeout can land on a request storage already committed, and then the rows exist while the caller only ever saw an exception. Setting the flag afterwards would skip the check on exactly that case and leak the rows. Being wrong in the other direction costs one writer read on a call that never landed, on a path that is already failing. Four tests, one of them the reproducer: a relation upsert raises after the links are committed, and the memory is dropped. Without the fix get_memory is awaited twice instead of three times and nothing is purged. A second pins the flag's position — moving the assignment one line down fails it. The remaining two are guards on the fix and pass either way, which their docstrings say outright: a failure that wrote nothing must not pay for a writer read, and a failure before sc exists must not raise NameError out of the task. Two comment corrections in the same pass, both found by reading the diff rather than by a reviewer. The helper docstring said this function is called TWICE; it is called three times now, and the docstring lists what each call site is for instead. And a sentence weighing what an unguarded finally would cost said "two of those would spend a writer read" when one of the three would not get that far — it would raise NameError on the unbound sc. Eleventh review round: the final check is guarded too, but not on the flag as review read it. Review was right that the end-of-try check runs unconditionally and that there is a path reaching it having written nothing — every extracted name filtered out by the blocklist or _is_valid_entity, so filtered is empty and the persistence block never runs. It asked for the same "if wrote_graph_rows:" the except handler uses. That fix leaks. Cross-link discovery is gated on auto_entity_linking_enabled ALONE, not on name_to_id, so it runs on exactly that path — and storage-side entity_discover_cross_links is an ON CONFLICT DO NOTHING insert into memory_entity_links, the table the purge deletes from. A memory can therefore acquire graph rows without bulk_upsert_entities ever being called. Gating the final check on a flag that only tracks the persistence block skips the purge and strands the links discovery just created for a dropped memory: H-02 again, through this fix's own guard. Probe-confirmed. Applying the suggestion verbatim fails the new test on the purge never being awaited. So the flag is set at BOTH writers, before each await, and then the final check is gated. That is the efficiency review asked for — a run that wrote nothing and had linking disabled no longer pays for a writer read — without the leak. The flag's declaration says what it has to mean for the gate to be safe: "this memory MAY have graph rows", not "the persistence block ran". Third time on this PR that a review's observation was worth acting on while its remedy was wrong in the unrecoverable direction, after the relation anti-join and the deleted_at subquery. The shape repeats: a narrowing that is locally consistent and drops a case the wider code still depends on. The helper docstring is corrected in the same pass. It said the except-site check is the guarded one; both trailing checks are guarded now. Twelfth review round: the purge response was read as a dict without ever being one. counts = await sc.purge_entity_artifacts(...) is wrapped in try/except, but the counts.get(...) reads that follow sit OUTSIDE it. A 2xx whose body is not an object satisfies raise_for_status, never reaches the except, and then raises AttributeError from unguarded code. Nothing between the caller and the wire enforces the shape: _post is declared dict | list, and the client's own purge_entity_artifacts silences the mismatch with a type: ignore[return-value]. The ignore is exactly where the promise was made without evidence. On the governance side this is not a stray log line. _purge_entity_artifacts has one hard requirement — it must not raise — because remediate_after_enrichment runs under consumer.handle_memory_enriched, which catches GovernanceCascadeError and nothing else, and the dispatcher nacks on anything else. So the AttributeError redelivers the event, re-runs the whole drop branch, and writes a SECOND critical=True audit for a memory already dropped. Every redelivery. That is precisely the failure round 2 of this PR fixed, arriving back through a type nobody checked. Both sites now treat a non-object response as its own state, and say so accurately rather than reaching for one of the two answers they already had. A 2xx means the call succeeded; a body we cannot read means we cannot say what it did. Neither "purged" nor "failed" is a claim worth making, so the governance log says the purge cannot be confirmed and the rows may still be listable, and the worker log says the counts are unknown. Review rated the worker site lower risk because it sits inside process_entity_extraction's catch-all. That is true of two of its three call sites and false of the third: the one added in round 10 IS the catch-all, and an exception raised inside an except block propagates out of the function, surfacing as an unhandled exception on a fire-and-forget task. The test drives that site specifically. Two tests, both probe-confirmed by removing the guards: the governance one asserts the drop still completes and the failure is logged rather than raised, and the worker one asserts the except-handler path returns instead of escaping. The governance test failed first time for a reason worth keeping: it configured pii={"enabled": True, "disposition": "drop"}, and the PII branch keys on "action" — "disposition" is the non-business key. The wrong key lands silently on flag, which never purges, so the test would have passed through a code path that never reached the line under test. It now asserts the pii_drop audit was emitted, so a config that does not reach the purge fails loudly instead of passing vacuously. Thirteenth review round: the purge's one blind spot is now named in the log rather than reported as a clean zero. memory_purge_entity_artifacts finds entities THROUGH the memory's links. So an entity row committed by bulk_upsert_entities whose link never landed — bulk_upsert_entity_links raised in between — is reachable by nothing. A purge for that memory runs, finds no links, deletes nothing and honestly returns {"links": 0, "relations": 0, "entities": 0}. Review's sharpest point is not the gap but its shape: that response is indistinguishable from "there was nothing to purge", so nobody is ever pointed at the rows. Everything else this PR leaves behind, it leaves behind loudly. Review offered two fixes. Taking the second, and not the first. Passing the upserted ids to the purge as an extra candidate source re-introduces exactly the race the candidate bounding exists to prevent. Round 1 already went through this: the first draft deleted every unlinked entity in the tenant, which sweeps rows orphaned for unrelated reasons and races a concurrent writer that has created an entity but not yet linked it. An id THIS run upserted is not necessarily an id this run created — bulk_upsert_entities resolves to an existing row when there is one — so the same hazard applies to the narrower list. Over- deleting is the direction that does not come back, and a person handed a concrete list can check what a query cannot. So the link upsert gets its own try/except that logs the ids and re-raises. The re-raise matters: the except handler below still runs its liveness check and still purges whatever IS reachable, so this only adds the diagnostic. Only entities this run CREATED are named. bulk_upsert_entities reports action per row, and an entity that already existed is reachable through whatever linked it before — naming it would send an operator after rows that are nobody's orphans. The gap itself remains, deliberately, and the code says so where it lives rather than in a commit message nobody will find. A test asserts the created id appears in the log; removing the handler fails it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Eldad Caura <eldad@caura.ai>
4eb5a9d to
51efc4f
Compare
|
@claude re-review at head Taken — your second option, not your first, and the reason is the one this PR has already been burned by twice. The gap is real and I'm not disputing it. The purge finds entities through the memory's links, so an entity row committed by Your sharpest point isn't the gap, it's its shape. That response is indistinguishable from "there was nothing to purge". Everything else this PR leaves behind, it leaves behind loudly — a failed purge, a failed cascade, an unreadable response all name the memory in an ERROR. This one was the exception, and that's what made it worth changing. Why not option 1Passing the upserted ids to the purge as an extra candidate source re-introduces the exact race the candidate bounding exists to prevent. Round 1 went through this: the first draft deleted every unlinked entity in the tenant, and it was narrowed because that sweeps rows orphaned for unrelated reasons and races a concurrent writer that has created an entity but not yet linked it. The narrower list doesn't escape that. What went inThe link upsert gets its own Only entities this run created are named. The gap itself stays, deliberately, and the code now says so at the place it lives rather than in a commit message nobody will find. On the test you asked for
The "removes it" half is already pinned in the opposite direction by an existing storage test from round 1 — the unbounded candidate set fails it with VerificationFull core-api suite 6140 passed, 0 failed. Probe-confirmed. Still unchanged and still deliberate: the round-8 Low on writer-routed read volume — a load-test question rather than a PR one. |
Claude Code Review ✅ No issues found. Reviewed by |
|
@erni-a please approve |
Closes audit finding H-02.
#808 named this case when it fixed the inline path: "entities mined out of
dropped content are the same leak in another table". It fixed that path by
ordering — _enrich_memory_background runs remediation first, and its early
return on a drop skips the entity extraction scheduled below it.
Both non-inline paths schedule extraction independently, at write time, as a
fire-and-forget task that races the verdict. ScheduleBackgroundTasks fires it
alongside the enrichment carrying run_governance_remediation=True in the fast
branch, and at write time in strong+deferred. process_entity_extraction never
re-checked the row.
And the schema's own expression of "these rows must not outlive the memory"
never fires: memory_entity_links.memory_id is ON DELETE CASCADE and
relations.evidence_memory_id is ON DELETE SET NULL, both on a HARD delete.
Governance soft-deletes — it sets deleted_at — so neither ever runs. The entity
row itself has no FK to the memory at all, so nothing would remove it even on a
hard delete.
Result: a tenant configured to drop had the memory removed and audited while the
names mined from it (person names, under a PII policy) stayed listable
tenant-wide through /entities and /graph, with nothing tying them to the drop.
Verified from the code rather than reproduced as one failing assertion, and the
distinction is worth being straight about: unlike the earlier findings in this
series there was no existing code path to make fail, because nothing could reach
these rows at all. What IS probe-confirmed is each guard added here — reverting
the candidate bounding fails the over-deletion test with 2 == 1, and neutering
the liveness check fails the dropped-row test.
Two halves, and they are not alternatives.
A purge on the drop path. New storage call, one transaction: delete the
memory's entity links, then relations whose evidence IS this memory (one row
carries one evidence id, so a relation attributed to dropped content has no
other justification), then — from the entities this memory linked to and only
those — the ones now left with no links and no relations. Both destructive
dispositions cascade, not just the non-business one the finding described:
they are separate branches reading separate configs, and a PII drop policy
leaked identically.
The candidate set is bounded on purpose. A first draft deleted every entity in
the tenant with no links, which would sweep entities orphaned for unrelated
reasons and race an entity a concurrent write had created but not yet linked.
Under-deleting is recoverable; over-deleting another caller's rows is not. A
test pins it: the unbounded version fails with 2 == 1.
A liveness re-check in the worker, immediately before persisting, reading the
WRITER — the whole point is to observe a delete that just committed, and a
replica under lag would report the row live exactly when the check most needed
to fail.
Half 1 covers the common ordering: extraction is one LLM call while the verdict
needs enrichment plus an event round-trip, so extraction usually finishes first
and its rows are there to purge. Half 2 covers the tail where it does not — the
purge has already run by then and would miss what lands afterwards. Neither half
covers the other's case.
The purge is deliberately NOT gated on a marker, unlike H-10's child cascade: any
dropped memory may have been extracted from, no flag on the row says so, and the
purge is three targeted deletes keyed on memory_id.
The purge runs AFTER the soft-delete, and that ordering is pinned. Purging first
would destroy graph rows for a memory that is still live if the delete then
failed, and nothing would put them back.
Tests. Five in core-storage-api against real Postgres, because the query's
correctness is entirely about what it does and does not reach and a stub would
assert the code calls itself — including the over-deletion guard, the
still-asserted-by-another-memory case, and the tenant boundary. Three in
core-api for the wiring, one of them an over-refusal guard that flag and
keep_private purge nothing (those rows describe content that is still there and
still allowed). Two for the worker guard, asserting on the WRITES rather than
the early return so a refactor that keeps the check and persists anyway fails,
plus one that the check reads the writer.
Four existing entity-extraction test files gained a get_memory stub. That is the
honest cost of the worker now depending on a storage read it did not before, not
churn to hide a problem.
Overlaps #1292 (H-10), which is in review and adds its own cascade to the same
two drop branches. Whichever lands second needs a mechanical rebase; the two
mechanisms are independent — one covers rows derived into the memories table,
this one covers rows derived into the graph.
Verification
core-storage-api/tests/: 343 passed, on its own scratch database.ruff checkandruff format --checkrun separately at CI's exact scopes — clean.mypyclean on both packages (core-apikeeps its 2 pre-existingtypes-python-dateutilerrors in an untouched file). The three newrowcountignores matchmemory_soft_delete_by_ids' existing pattern.git add.origin/main.🤖 Generated with Claude Code