fix(governance): cascade a drop or keep_private to rows derived before the verdict - #1292
Conversation
|
@claude please review at head Five things I want challenged:
Not in scope, deliberately: H-02, the sibling finding where entity rows mined from dropped content survive the same way. It needs its own cascade in a different table and is a separate PR. |
SummaryThis PR adds an H-10 governance cascade so that a drop/keep_private verdict on an auto-chunked parent memory also reaches child rows created before the verdict arrived. The storage-layer query, router endpoint, and client method all closely mirror the existing Medium/Low IssuesCascade loop has no partial-failure containment once the parent is already deletedSeverity: Medium 🤖 Claude Code PromptCascade summary log count includes children skipped for missing idsSeverity: Low 🤖 Claude Code PromptReviewed by |
SummaryThe PR adds a parent→child lookup so governance remediation (drop / keep_private) cascades to auto-chunk children that were committed before a deferred governance verdict arrived. The storage-side query, router endpoint, and core-api wiring are consistent with existing conventions in the file, and ordering (children looked up before the parent's own audit+delete) is deliberate and well-reasoned. One correctness issue stands out around how the new lookup is transported. Critical/High IssuesGovernance cascade lookup goes through the read replica, contradicting
|
48db742 to
8506876
Compare
|
Both correct, both fixed at The MediumI argued failures should propagate because "a policy that could not be applied must not be treated as applied". That is right for the lookup, which runs before anything is mutated. It is wrong for the loop, which runs after the parent is already deleted — and I applied the same reasoning to both without noticing the asymmetry. Reproduced before fixing: a failure on Parent gone, two children still live with the dropped content. The error handling reintroduced the leak the cascade exists to close — which is the same shape as the last round of #1278, where a loop-level catch would have cancelled every other child's repair. Fixed per child, raised once at the end, after every child has been attempted:
On your duplicate-audit point — a retry does re-emit the parent's audit. I am taking that rather than fixing it here: it is the cost of not silently leaving forbidden content live, and it is pre-existing behaviour for any remediation that raises, not something this cascade introduces. Say if you'd rather I made the parent audit idempotent as part of this, but it felt like a separate change. One thing I went further on than you suggested: a child with no usable id now counts as a failure rather than only being logged. "We could not identify it" is not "it is handled" — the row still holds forbidden content. A retry won't fix that one, so it should stop being quiet and get a person's attention. The LowRight, and it is the same defect class the rest of this backlog keeps turning up: a line asserting something the code does not deliver. Probe: reverting it fails the new test with TestsTwo more, both probe-confirmed in both directions:
Suite 6078 passed / 0 failed; ruff, mypy, ratchet, sentinel, tenant-scope gate and the broker baseline all clean after Still open from my review requestPoints 1, 2, 3 and 5 went unanswered and I am not reading silence as agreement — particularly 3, which is the one I flagged as having the worst answer if I am wrong: |
|
@claude re-review at head Please also return to the questions from my first request, which the last pass did not reach. In priority order:
|
8506876 to
970834d
Compare
SummaryThis PR adds a governance-cascade mechanism (H-10) so that when a parent memory is dropped or made private, auto-chunked child rows created before the verdict arrived are swept up too. The core-storage-api query and endpoint additions are sound and mirror existing patterns. However, the Critical/High IssuesCascade failure silently drops the parent's own audit entry for
|
970834d to
724a71d
Compare
|
Correct, and it is a defect my previous fix created. Fixed at Making the cascade raise was right. Putting it where I put it was not: Reproduced before fixing: The parent durably narrowed to Your comparison is the part that makes it obvious in hindsight: the drop branches never had this exposure because they audit before the destructive delete, which leaves the cascade already last. Parent audit now precedes the cascade, and a test pins it — reverting the order fails with the output above. That is two rounds running where my fix caused the next findingRound 2 was the loop-level abort abandoning later children; round 3 is this. Both were introduced by the previous round's fix, and both were in error-handling paths rather than the feature itself. Worth saying plainly rather than letting the PR body read as a clean progression. On the index suggestionAgreed and deliberately not here: a partial expression index on VerificationSuite 6081 passed / 0 failed on Still unanswered across three requestsNot treating silence as agreement — the JSON-key delete question in particular. The deletion set is keyed on other rows pointing at the parent, not on anything the parent's own writer controls. To make the cascade delete a row they should not reach, an attacker would need that row's metadata to name a parent they can get dropped — and they do not write other rows' metadata. The inverse (pointing their own row at someone else's parent) deletes their own row, which is self-harm rather than escalation. Cross-tenant is blocked by the predicate, probe-confirmed. I believe that holds, but I reasoned it rather than tested it, and it is the claim with the worst consequence if I am wrong. A second opinion on it is what I would most like from this round. |
|
@claude re-review at head The one thing I most want from this round is the question three requests have not reached, so I have done the analysis myself and would like it falsified rather than repeated: Claim: the cascade cannot be induced to delete rows the triggering writer should not reach. The reasoning: the deletion set is Please check that against the code — in particular whether any path lets one principal write Lower priority, still open from earlier rounds: whether you would drop the |
SummaryThis PR adds a governance-remediation cascade (H-10) so that PII/non-business drop and keep-private verdicts landing on an auto-chunked parent also reach its already-committed children. The code is heavily documented and covered by tests for the ordering/failure-handling invariants it claims to provide. I found one substantive correctness concern around how the child lookup is wired to the storage client's read-replica routing, which undermines a specific invariant the code documents. Medium/Low IssuesChild lookup hits the read replica, undermining the documented "retry only re-touches failures" invariantSeverity: Medium 🤖 Claude Code PromptReviewed by |
724a71d to
bdb9db1
Compare
|
Correct, and it lands on my prose rather than my logic — which makes it the same defect class this whole backlog keeps turning up. Fixed at I wrote "the lookup excludes soft-deleted rows, so the children that succeeded are not revisited and only the failures are re-attempted" — a claim about retry behaviour that depends on read-your-write, against a helper that routes every call to the replica. Under lag the retry re-fetches a child whose delete already committed, then re-audits and re-soft-deletes it: a duplicate destructive entry in a compliance log for a row already handled. I chose (a) over (b) because correcting the docstring would document a worse behaviour when the fix is small and the precedent already exists —
TestsTwo, both probe-confirmed:
I also walked back an overclaim in my own new test while writing it: its docstring said every other Suite 6083 passed / 0 failed; ruff, mypy, ratchet, sentinel, tenant-scope and the broker baseline all clean after Four rounds, three of them my own fixesRound 2 abandoned later children; round 3 erased the parent's audit; round 4 is a docstring asserting a guarantee the transport doesn't provide. Only round 1 was in the original feature. Worth stating plainly. Still unanswered across four requestsThe JSON-key delete question, which I analysed myself two comments up and would still like falsified rather than repeated: I could find no reserved-key filter on caller-supplied metadata, so |
|
@claude re-review at head The one question four requests have not reached, and the only one I would hold this PR for: Can the cascade be induced to delete rows the triggering writer should not reach? My argument that this is safe: the deletion set is keyed on other rows pointing at the parent, not on anything the parent's writer controls. An attacker cannot make a victim's row point at a parent they can get dropped. Pointing their own row at a victim's parent deletes their own row. Cross-tenant is blocked by the predicate, which a storage test pins. That is reasoning, not a test. Please try to break it against the code — specifically whether any path lets one principal write metadata into a row another principal owns, and whether an agent-scoped or fleet-scoped writer can reach rows outside its own scope through this. Lower priority, still open: whether you would drop the |
bdb9db1 to
970351d
Compare
SummaryThe core storage-side plumbing (new Medium/Low Issues
|
970351d to
d8af96c
Compare
#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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Eldad Caura <eldad@caura.ai>
d8af96c to
7ace435
Compare
#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> Signed-off-by: Eldad Caura <eldad@caura.ai>
7ace435 to
486f545
Compare
|
I found a High on this PR while working the sibling H-02 change, and it retracts something I told you two rounds ago. Fixed at
Two rounds ago I answered the return-contract finding by saying a duplicate parent audit was "the cost of not silently leaving forbidden content live, and pre-existing behaviour for any remediation that raises". That framing assumed a one-off. It is an unbounded loop against a tamper-evident log, for a row nothing further can be done to. I was wrong, and the reasoning that was wrong is the same reasoning I had to retract on #1297 an hour later. Where the catch goes, and why not deeperIn the consumer, not in
Probe: removing the guard fails the new test with the raw VerificationSuite 6098 passed / 0 failed; ruff, mypy, ratchet, sentinel, tenant-scope and the broker baseline all clean after Tally, since it is not flatteringFive rounds. Round 1 found the original defect; rounds 2, 3, 4 and 5 all found problems introduced by the previous round's fix, every one of them in error handling rather than in the feature. That is worth saying plainly rather than letting the PR body read as steady progress. Still unanswered across four requests: whether the cascade can be induced to delete rows the triggering writer should not reach. I analysed it myself two comments up and would still rather have it falsified than repeated. |
#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> Signed-off-by: Eldad Caura <eldad@caura.ai>
486f545 to
e3da577
Compare
|
@claude re-review at head Two things happened since your last review, and neither has been looked at. Your last review ran against
What I want attacked, in priority order. The consumer guard is the part with no review at all, and it is the fifth consecutive change to error handling on this PR. Rounds 2, 3, 4 and 5 each found a defect introduced by the previous round's fix, every one of them in error handling rather than in the feature. That record is the reason I would rather this not land unreviewed than trust my own read of it. Specifically: is swallowing Still unanswered across five requests, and it is the one I care most about: the child lookup filters on 🤖 Generated with Claude Code |
SummaryThis PR adds an H-10 governance cascade so that soft-deleting/downgrading a parent memory also reaches auto-chunked children that already existed when the tenant's governance verdict arrives. The implementation is thorough (writer-routed lookup to avoid replica lag, per-child failure containment, audit-before-mutate ordering), and test coverage is extensive. One real gap remains in how a partial child-cascade failure is meant to be recovered. Medium/Low IssuesFailed child soft-deletes have no automatic retry path and can leave a false "dropped" audit entrySeverity: 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> Signed-off-by: Eldad Caura <eldad@caura.ai>
dd26654 to
fb993bf
Compare
#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> Signed-off-by: Eldad Caura <eldad@caura.ai>
|
@claude re-review at head You described a missing retry path. What was actually there was a docstring describing a retry that the previous round had deleted. Round 5 made the consumer catch So this is round 6 of a PR where rounds 2 through 6 each found something the previous round's fix introduced — and this one is the third time the defect has been in my prose rather than my logic. That is the harder direction to catch, because nothing fails. What changed. The docstring now says nothing retries a failed child, because nothing does — the parent's remediation has already succeeded and no later event revisits the row. That makes the log the whole recovery path, so it had to become usable as one. The audit and the delete are now in separate
Both carry On the ordering you were implicitly questioning: the audit still precedes the delete, and that is what makes the bad state reachable. I kept it deliberately. A false "dropped" entry is discoverable and the content is still there to remove; auditing afterwards converts the same failure into a deletion with no record of it at all. What I did not do: your option (1), the reconciliation sweep. It needs a query over audit rows carrying Verified at this head: full suite 6100 passed, 5 skipped, 1 xfailed; ruff check and format clean at CI's scopes; mypy clean apart from the two pre-existing The branch was force-rebased three times today by its author while I worked. I confirmed by patch-id each time that the content was unchanged ( Still unanswered across six requests — the one I care most about, and the only thing on this PR I would call a security question rather than a robustness one: the child lookup filters on 🤖 Generated with Claude Code |
Confirmed both existing Given the thoroughness of this review, I did not find a concrete correctness/security bug in the changed source files. The design is unusually well-documented and self-consistent, and the ordering/error-handling invariants it depends on (audit-before-mutate, cascade-after-parent-mutation, exception-carries-outcome, opt-in writer routing) all check out against the actual code paths. Claude Code Review ✅ No issues found. Reviewed by |
|
Answering my own question rather than asking a seventh time. You've now passed this PR clean, and across six requests the The question. The child lookup filters on Cross-tenant: no, structurally. Intra-tenant, same agent: no privilege gained. For a row to be cascaded it must carry Intra-tenant, cross-agent: gated behind a privilege that already subsumes it. For agent A to get agent B's rows cascaded, A must write Conclusion: not a privilege escalation. The missing reserved-key filter is real, and I would still rather it existed, but every path to abusing it requires a write privilege that already permits the same outcome more directly. The one residual I do want on the record, because it is not a data-access problem and so does not disappear in the analysis above. At trust 3, an attacker can cause B's rows to be soft-deleted with I am not fixing that here. It is a property of trust-3 semantics plus audit attribution, not of this cascade, and a fix belongs where reserved metadata keys are decided — not bolted onto a remediation path. But it is the sharpest thing in this area and it should not be lost because six review rounds went by without anyone naming it. Filing two follow-ups rather than widening this PR: a reserved-key filter for 🤖 Generated with Claude Code |
#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> Signed-off-by: Eldad Caura <eldad@caura.ai>
#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> Signed-off-by: Eldad Caura <eldad@caura.ai>
…e the verdict
On a deferred deployment — the production SaaS write path — the auto-chunk
branch's GovernanceDecision runs with enrichment=None, takes its documented
uncertain branch and enforces nothing. The children are built and committed
immediately, at scope_team, with no governance metadata of their own.
The parent's real verdict arrives minutes later: ENRICH_REQUESTED -> worker
PATCH -> ENRICHED -> remediate_after_enrichment. That soft-deleted or downgraded
ONLY the row the event named. parent_memory_id was written onto every child and
queried nowhere in production code — five sites, all writes.
So a tenant configured non_business.disposition=drop had its parent dropped and
audited while N children carrying the same content stayed live and team-visible.
Permanently: children are never enriched, so no later pass revisits them, and
they carry clean metadata so any future sweep reads them as fine. There was no
audit row tying them to the drop either, which means the compliance log recorded
one deletion where one row was removed and N were not.
Reproduced before fixing:
AssertionError: the dropped content survives in the children: {'m1'}
This is the #808 shape the codebase documents as fixed. It IS fixed for the
atomic-fact fan-out, by ordering: that one lives inside _enrich_memory_background
and runs remediation BEFORE it fans out, so a drop early-returns and a downgrade
is carried onto the children via effective_visibility. Its own comment states the
rule — "a policy that could not be applied must not be followed by rows it might
have forbidden". Auto-chunk in deferred mode is the path where the derived rows
already exist when the verdict lands, so ordering cannot save it and a cascade is
what is missing.
remediate_after_enrichment now resolves the derived rows and applies the same
action to them: soft-delete on either drop disposition, visibility downgrade on
keep_private. Both destructive branches were fixed, not just the non-business one
the finding described — they are separate branches reading separate configs, and
a tenant on a PII drop policy leaked identically.
The lookup is resolved BEFORE the parent's audit and delete, so a lookup failure
leaves everything intact and remediable rather than a dropped parent whose
children were never found. Failures propagate rather than being swallowed: this
module's contract is that a policy which could not be applied must not be quietly
treated as applied, and the enclosing tracked_task surfaces the failure.
Each cascaded row gets its own audit row, carrying cascaded_from so a compliance
review can see why a row with no governance signals of its own was removed.
Per child rather than one rolled-up entry, because each is a separate
soft-delete and a log recording one deletion while N happened misstates the
record in the direction that matters.
New storage query, because the parent->child link lives in child metadata JSON
and nothing could read it. Tenant-scoped, live rows only, no status or
visibility filter — remediation must reach every derived row whatever state it
is in, the same reasoning memory_find_by_supersedes_id records for retraction.
Gated on the parent's auto_chunked marker rather than querying unconditionally.
The query filters a JSON key with no supporting index and a tenant configured
drop remediates constantly, so an ungated version would tax every ordinary drop
to serve the rare chunked one. auto_chunked is safe to gate on for a reason
worth stating: it is stamped unconditionally in the same function that builds
the children, and it is already on the production rows this has to reach — a NEW
marker would only appear on rows written after the deploy and would leave the
existing leak in place.
Tests. Five in core-api, four confirmed failing without the fix (the fifth is
the over-refusal guard: an ordinary row must not run the lookup at all). Four
more in core-storage-api against real Postgres, because the core-api side stubs
storage entirely — without them the cascade could ship with a query matching
nothing and every test above it would still pass. The tenant-boundary one was
probe-confirmed: removing the tenant predicate fails it with 2 == 1, which on a
drop would mean deleting another tenant's rows.
One test lives beside the WRITE rather than the remediation: the cascade's gate
depends on auto_chunked being stamped on the parent and parent_memory_id on each
child, and if either stops being written the cascade silently stops running with
nothing failing near it. Asserting that in the remediation tests would be a stub
asserting itself.
Review round: the cascade loops are contained per child.
The first draft let a failure propagate straight out of the loop, on the
grounds that this module must not treat an unapplied policy as applied. That
reasoning is right for the LOOKUP, which runs before anything is mutated, and
wrong for the loop, which runs after the parent is already deleted — a failure
on the second child abandoned the third, leaving it live with its parent gone.
The error handling reintroduced the leak the cascade exists to close.
Each child's audit-and-mutate is now wrapped, the failures are collected, and
GovernanceCascadeError is raised once every child has been ATTEMPTED. One bad
row costs one row, and the failure still reaches the task tracker. A retry is
narrow: the lookup excludes soft-deleted rows, so only the failures are
re-attempted. It does re-emit the parent's audit, which is the cost of not
silently leaving forbidden content live, and is pre-existing behaviour for any
remediation that raises rather than something this introduces.
A child with no usable id now counts as a failure rather than only being
logged. "We could not identify it" is not "it is handled" — the row still holds
content a policy forbade, and a retry will not fix that one, so it should stop
being quiet.
The summary log counts rows actually remediated, not len(children). It
previously included rows the loop had skipped, so it overstated enforcement in
a line a compliance review reads.
Second review round: the keep_private ordering.
Making the cascade raise introduced a second, worse defect in the same branch,
and review caught it. keep_private mutates the parent and audits it AFTER,
which is right on its own — nothing is lost if a non-destructive audit fails
after the change. But the cascade had been dropped in between, so one failed
child raised before the parent's audit was ever emitted, leaving the parent
durably narrowed with NO audit row at all. An untracked mutation, which this
module's own docstrings forbid.
The drop branches never had that exposure: they audit before the destructive
delete, so the cascade is already the last thing they do. The parent's audit now
precedes the cascade in keep_private too, and a test pins it — reverting the
order fails with "the parent's visibility changed with no audit row: []".
Third review round: the lookup reads the WRITER.
_get_list routes every call to the read replica, with no opt-out — its own
comment said "all current callers are pure list/stats endpoints; none sit on
the write path, so no per-call opt-out is needed yet". True when written, and
falsified by this change: the cascade reads rows it is about to soft-delete and
reasons about what a retry will find.
Off the replica that reasoning is false under lag. A retry shortly after a
partial cascade failure would re-fetch a child whose delete had already
committed on the primary, then re-audit and re-soft-delete it — a duplicate
destructive entry in a compliance log for a row already handled. The docstring
asserting "only the failures are re-attempted" was a claim the code did not
support, which is this backlog's recurring defect class appearing in my own
prose.
_get_list now takes read: bool = True, mirroring _get, and the cascade lookup
passes read=False. The stale comment is replaced rather than left standing next
to a caller that contradicts it. Two tests: the lookup goes to the writer, and
an over-correction guard that the parameter did not move existing callers.
Not addressed here: review also suggested a partial expression index for the
JSONB lookup. Worth doing and deliberately left out — it needs a migration, and
the auto_chunked gate bounds the cost in the meantime.
Fourth review round: the raise carries the parent's outcome.
remediate_after_enrichment can now exit by exception after the parent's own
remediation SUCCEEDED, which its docstring did not admit. That is deliberate —
a caller about to create more derived rows must not proceed while existing ones
still hold forbidden content, and aborting is the fail-safe answer — but as
written it conflated two states a caller may need to tell apart: "nothing
happened, retry from scratch" and "the parent was handled, only the cleanup fell
short".
GovernanceCascadeError now carries the parent's RemediationOutcome, and the
function's docstring says it can raise and why. Callers that simply refuse to go
on, which is every current one, can keep ignoring it.
Fifth review round: the raise must not nack the event.
Found while reviewing the sibling H-02 change, which has the identical shape.
GovernanceCascadeError propagates out of consumer.handle_memory_enriched, which
has no guard, and the Pub/Sub dispatcher nacks on a handler exception. Redelivery
re-runs the whole drop branch and emits a SECOND critical=True audit for a memory
already dropped. Every redelivery. I had answered an earlier round by calling a
duplicate parent audit "the cost of not silently leaving forbidden content live"
— that framing assumed a one-off, and this is an unbounded loop.
Caught in the CONSUMER rather than in remediate_after_enrichment, because the two
callers need opposite things. _enrich_memory_background is about to create more
derived rows and must still abort — it does not catch this. The consumer creates
nothing, so it logs at ERROR and honours the parent's verdict from the outcome
carried on the exception, which is what that field is for.
A test drives the handler with a cascade failure and asserts it does not raise,
and that detection stays skipped because the row IS dropped. Removing the guard
fails it with the raw GovernanceCascadeError.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sixth review round: the docstring outlived the retry it described.
Round 5 removed the redelivery — the consumer now catches GovernanceCascadeError
and acks. _drop_children's docstring still described that redelivery as a "safe
and narrow" retry, and still carried the duplicate-parent-audit framing I had
already retracted in the round-5 message above. Two paragraphs asserting a
mechanism the same commit had deleted.
That is this backlog's most common defect class and the third time it has landed
in my own prose on this PR. Worth naming rather than quietly fixing: the code was
right and the comment was wrong, which is the harder direction to catch, because
nothing fails.
Nothing retries a failed child. The parent's remediation has already succeeded,
no later event revisits the row, and the docstring now says so.
That makes the log the entire recovery path, so it had to become usable as one.
The audit and the delete are now attempted in SEPARATE try blocks, because they
leave the row in two different states and the old single block reported them
identically:
audit_emitted=False nothing happened; the row is live and the compliance log
makes no claim about it
audit_emitted=True the log RECORDS the row as removed and it is still live
Both carry governance_cascade_needs_manual_remediation=True as a structured
field, so an alert can key on something other than message text.
The audit still precedes the delete. That ordering is what makes the second state
possible, and it is still the right trade: a false "dropped" entry is
discoverable and the content is still there to remove, whereas auditing
afterwards turns the same failure into a deletion with no record of it at all.
_privatise_children orders these oppositely for the same reason read the other
way — its update is non-destructive, so update-then-audit can only ever
under-record.
Two tests, both probe-confirmed failing without the split, and they assert the
structured field rather than the wording — a monitor keyed on that field breaking
silently is the failure mode being guarded.
What I have NOT done is build the reconciliation sweep the review offered as
option (1). It needs a query over audit rows with cascaded_from set against rows
with no deleted_at, and a scheduler to run it — a separate change with its own
tests, not a rider on this one. This PR takes option (2) and makes the manual
path real; the sweep is worth filing on its own merits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Eldad Caura <eldad@caura.ai>
fb993bf to
993f6dd
Compare
|
@erni-a please approve |
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> Signed-off-by: Eldad Caura <eldad@caura.ai>
🤖 I have created a release *beep* *boop* --- <details><summary>backend: 2.48.0</summary> ## [2.48.0](backend-v2.47.5...backend-v2.48.0) (2026-09-05) ### Features * **mcp:** refuse an over-plan write, behind a flag ([#1296](#1296)) ([4d81241](4d81241)) * **ops:** alert on embeddings written without provenance ([#1294](#1294)) ([491dd15](491dd15)) * **storage:** add a repair sweep for un-provenanced embeddings ([#1298](#1298)) ([76def6a](76def6a)) ### Bug Fixes * **client-python:** raise health check HTTP errors ([#1027](#1027)) ([831920c](831920c)) * **docs:** remove stale MCP tool count ([#571](#571)) ([e32d4ad](e32d4ad)) * **governance:** cascade a drop or keep_private to rows derived before the verdict ([#1292](#1292)) ([f453002](f453002)) * **health:** give the storage probe a budget bigger than one connect ([#1303](#1303)) ([e52c2a2](e52c2a2)) ### Documentation * **env:** add JWT_SECRET and SETTINGS_ENCRYPTION_KEY to .env.example ([#1299](#1299)) ([390f857](390f857)) </details> --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Signed-off-by: release-please[bot] <release-please[bot]@users.noreply.github.com> Co-authored-by: caura-deploy-bot[bot] <265395343+caura-deploy-bot[bot]@users.noreply.github.com>
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> Signed-off-by: Eldad Caura <eldad@caura.ai>
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> Signed-off-by: Eldad Caura <eldad@caura.ai>
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> Signed-off-by: Eldad Caura <eldad@caura.ai>
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> Signed-off-by: Eldad Caura <eldad@caura.ai>
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> Signed-off-by: Eldad Caura <eldad@caura.ai>
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> Signed-off-by: Eldad Caura <eldad@caura.ai>
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> Signed-off-by: Eldad Caura <eldad@caura.ai>
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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Eldad Caura <eldad@caura.ai>
#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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Eldad Caura <eldad@caura.ai>
#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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Eldad Caura <eldad@caura.ai>
#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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Eldad Caura <eldad@caura.ai>
#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>
…#1297) 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. 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. ## Verification - Full root suite: **6077 passed, 5 skipped, 1 xfailed, 0 failed**. - `core-storage-api/tests/`: **343 passed**, on its own scratch database. - `ruff check` and `ruff format --check` run separately at CI's exact scopes — clean. - `mypy` clean on both packages (`core-api` keeps its 2 pre-existing `types-python-dateutil` errors in an untouched file). The three new `rowcount` ignores match `memory_soft_delete_by_ids`' existing pattern. - ratchet *No new lines.* · sentinel *All 35 protected strings survive.* · tenant-scope gate exit 0 · broker OpenAPI baseline current. All after `git add`. - Checked for open PRs on this subsystem before starting; only #1292, noted above. - Branched from `origin/main`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Eldad Caura <eldad@caura.ai> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Closes audit finding H-10.
The defect
On a deferred deployment — the production SaaS write path — the auto-chunk branch's
GovernanceDecisionruns withenrichment=None, takes its documented uncertain branch and enforces nothing. The children are built and committed immediately, atscope_team, with no governance metadata of their own.The parent's real verdict arrives minutes later (
ENRICH_REQUESTED→ worker PATCH →ENRICHED→remediate_after_enrichment) and soft-deleted or downgraded only the row the event named.parent_memory_idwas written onto every child and queried nowhere — five sites in production code, all writes.So a tenant configured
non_business.disposition=drophad its parent dropped and audited while N children carrying the same content stayed live and team-visible. Permanently: children are never enriched, so nothing revisits them, and they carry clean metadata so any future sweep reads them as fine. No audit row tied them to the drop either — the compliance log recorded one deletion where one row was removed and N were not.Reproduced before fixing
Why this is the #808 shape, and why ordering can't fix it here
The codebase documents #808 as fixed, and it is fixed for the atomic-fact fan-out — by ordering. That path lives inside
_enrich_memory_background, runs remediation before it fans out, early-returns on a drop, and carries a downgrade onto the children viaeffective_visibility. Its own comment states the rule:Auto-chunk in deferred mode is the path where the derived rows already exist when the verdict lands. Ordering cannot save it; a cascade is the missing half.
The fix
remediate_after_enrichmentnow resolves the derived rows and applies the same action: soft-delete on either drop disposition, visibility downgrade onkeep_private.Both destructive branches, not just the one the finding described. PII-drop and non-business-drop are separate branches reading separate configs; a tenant on a PII drop policy leaked identically.
The lookup runs before the parent's audit and delete, so a lookup failure leaves everything intact and remediable rather than a dropped parent whose children were never found. Failures propagate rather than being swallowed — this module's contract is that a policy which could not be applied must not be quietly treated as applied, and the enclosing
tracked_tasksurfaces it.Each cascaded row gets its own audit row, carrying
cascaded_fromso a review can see why a row with no governance signals of its own was removed. Per child rather than one rolled-up entry: each is a separate soft-delete, and a log recording one deletion while N happened misstates the record in the direction that matters.The new storage query
The parent→child link lives in child metadata JSON and nothing could read it. Tenant-scoped, live rows only, no status or visibility filter — remediation must reach every derived row whatever state it is in, the same reasoning
memory_find_by_supersedes_idrecords for retraction.Why it is gated on
auto_chunkedThe query filters a JSON key with no supporting index, and a tenant configured
dropremediates constantly — an ungated version would tax every ordinary drop to serve the rare chunked one.auto_chunkedis safe to gate on for a reason worth stating: it is stamped unconditionally in the same function that builds the children, and it is already on the production rows this has to reach. A new marker would only appear on rows written after the deploy and would leave the existing leak in place.Tests
Five in core-api, four confirmed failing without the fix. The fifth is an over-refusal guard: an ordinary row must not run the lookup at all.
Four in core-storage-api against real Postgres, because the core-api side stubs storage entirely — without them the cascade could ship with a query that matches nothing and every test above it would still pass. The tenant-boundary one is probe-confirmed: removing the tenant predicate fails it with
2 == 1, which on a drop would mean deleting another tenant's rows.One test lives beside the write rather than the remediation. The gate depends on
auto_chunkedbeing stamped on the parent andparent_memory_idon each child; if either stops being written the cascade silently stops running with nothing failing near it. Asserting that in the remediation tests would be a stub asserting itself.Verification
core-storage-api/tests/: 336 passed, on its own scratch database.ruff checkandruff format --checkrun separately at CI's exact scopes — clean.mypyclean oncore-storage-api/src/;core-api/src/clean apart from 2 pre-existingtypes-python-dateutilstub errors in an untouched file.legacy_name_ratchet.py→ No new lines. ·do_not_touch_sentinel.py→ All 35 protected strings survive (the 39→35 change came from fix(clients): retire the MemClaw/MemClawError/MemClawAPIError class aliases #1284 on main, confirmed against a clean tree, not from this branch) ·tenant_scope_gate.py→ exit 0, no allowlist movement, and the new route is counted as tenant-bound.origin/main, rebased onto4c9cef2a.🤖 Generated with Claude Code