fix(retention): let a dead row at ANY stage ride the dead-letter window (BACKLOG #1188) - #869
Open
wshallwshall wants to merge 2 commits into
Open
fix(retention): let a dead row at ANY stage ride the dead-letter window (BACKLOG #1188)#869wshallwshall wants to merge 2 commits into
wshallwshall wants to merge 2 commits into
Conversation
…ow (BACKLOG #1188) Both body purges scoped every queue statement to Stage.OUTBOUND, so a dead ingress or routed row was reached by neither. Those rows carry the FULL raw body: a router raise dead-letters the ingress row and a handler raise the routed row (wiring_runner.py:4996, :5030). Because `dead` is neither pending nor inflight, purge_message_bodies still found the message eligible and blanked messages.raw -- so the message read as purged while a full raw PHI body would survive in queue.payload indefinitely, with no sweep able to reach it. The same defect over-retained more than the queue body. The attachment live-holder predicate is already stage-agnostic, so an unreachable dead ingress row also pinned the message's detached streaming document forever. Riding the dead-letter window is the symmetric answer, not a widening of convenience: replay() recovers "a dead-lettered ingress/routed row" by re-queueing it in place from its OWN payload, so such a row is replayable-until-purged in exactly the sense that justified giving dead outbound rows a later window than the body. The stage predicate is DROPPED rather than widened to a three-stage list, so a stage added later is covered by construction instead of silently re-opening this gap. Two things deliberately stay scoped: - SQLite's store-once body_ref release. body_ref is written only by _insert_outbound_deliveries, so a wider scope would match nothing extra. - The dead-letter VIEW (list_dead / replay_dead). What an operator can enumerate is a separate question from what retention must delete, and an unenumerable body is the stronger reason to bound it. Per-connection overrides key on destination_name, which is NULL at ingress/routed/response, so CASE NULL matches no arm and those rows take the ELSE -- always the global window. That is deliberate: dead_letter_days is declared on an OUTBOUND connection, so honouring an inbound-keyed override here would invent a semantic no configuration surface declares. Classification edits ride the same commit, per the item. This is HALF the tier, so PHI.md gets an eighth vocabulary form -- "dead-only", the same shape as the existing "orphan-only" -- rather than a plain window claim that would be false. The residual row is rewritten, not deleted: a permanently PENDING ingress row is still unbounded, and it is reachable by design, since three sites re-pend with max_attempts=None when the owning inbound is absent from the registry (wiring_runner.py:5057, :5296, :5480). Bounding that needs a rule telling "mid-reload" from "never coming back", so it stays an honest gap. The retention floor is untouched. Tests: two purge tests and one attachment test, each proven to fail first (assert 0 == 1 on unmodified code). Plus a source-level parity guard, since the Postgres and SQL Server purges run on hosted runners only -- mutation-tested by re-narrowing the Postgres statement, which reds it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tays open One of the builds #1188 names is done -- purge_dead_letters now reaches a DEAD row at every stage on all three backends. The item is NOT closed and the cell is NOT re-scored: it names several more builds, and the permanently-pending ingress row is still unbounded. The note records what landed with its evidence, what is explicitly still open so nobody re-scores on a grep, and the re-measured anchor drift. That drift was real: MIN_PHI_RETENTION_WINDOWS is at retention_classification.py:181 and not :173, the two purges are at store.py:8919 and :9252 and not :8384/:8659, and the dead-letter call sites are at wiring_runner.py:4996 and :5030 and not :4415/:4449. The dead anchors inside PHI.md were corrected in the code commit; the ones in this item's own research prose are left as written, so the record of what was measured when stays legible. No window was auto-bounded and the retention floor is untouched, so the 2026-07-30 no-auto-bound ruling still stands unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
One of the builds BACKLOG #1188 names, landed. The item stays open and the ASVS cell is not re-scored — #1188 names several more builds, and this closes one of them plus a second over-retention that rode the same defect.
I did not touch the vault scorecard.
The defect, re-measured at this branch point
Both body purges scoped every queue statement to
Stage.OUTBOUND, so a dead ingress or routed row was reached by neither. Those rows carry the full raw body:pipeline/wiring_runner.py:4996pipeline/wiring_runner.py:5030purge_message_bodiesstore/store.py:8919purge_dead_lettersstore/store.py:9252Because
deadis neitherpendingnorinflight,purge_message_bodiesstill found the message eligible and blankedmessages.raw. So the message read as purged while a full raw PHI body would survive inqueue.payloadindefinitely, with no sweep able to reach it. On a first deployment that would accumulate raw PHI bodies with no engine-managed deletion. The test asserts that hazard directly before asserting the fix.A second over-retention rode the same defect, and it is not in the item.
_attachment_still_referenced_sqlis already stage-agnostic — adeadrow with a non-blank payload counts as a live holder whatever its stage. So an unreachable dead ingress row also pinned the message's detached streaming document (the very large PHI payload, e.g. a base64 PDF) forever. Blanking the row makes the predicate go false and the decref fire, so it closes with the same change. There is a test for it.Why widening is honest here rather than convenient
replay()recovers "a dead-lettered ingress/routed row" by re-queueing it in place from its own payload. That is exactly the property that justified giving dead outbound rows a later window than the body, so the symmetry the research claimed holds in shipped code.The stage predicate is dropped, not widened to a three-stage list, so a stage added later is covered by construction instead of silently re-opening this gap.
Two things deliberately stay scoped, both with the reason in the code:
body_refrelease.body_refis written only by_insert_outbound_deliveries, so an ingress/routed/response row always has a NULLbody_ref. A wider scope would match nothing extra.list_dead,replay_dead). What an operator can enumerate is a separate question from what retention must delete — and an unenumerable body is the stronger reason to bound it, not a reason to leave it.Assumption I had to make, stated per the one-turn rule
Per-connection overrides key on
destination_name, which is NULL at ingress/routed/response, soCASE NULLmatches noWHENarm and those rows take theELSE— always the global window. I chose that over inventing an inbound-keyed override, becausedead_letter_daysis declared on anOutboundConnectionand no configuration surface declares an inbound-keyed one. Giving those stages their own override is a separate, unallocated follow-up. Verified as SQL semantics in all three dialects, and stated in every docstring.Classification edits, in the same commit as the item asks
This closes half the tier, so a plain window claim would be false.
docs/PHI.md§2 gains an eighth vocabulary form,dead-only, on the exact shape of the existingorphan-only— one state of a tier covered, the other not.__main__.pynow state the real scope.wiring_runner.py:4415/:4449were 581 and 581 lines off).The retention floor is untouched.
MIN_PHI_RETENTION_WINDOWSis still 9, and no window was auto-bounded — the 2026-07-30 no-auto-bound ruling stands unchanged.What is explicitly NOT done, so nobody re-scores on this
The permanently-pending ingress row is still unbounded, and it is now the tier's stated residual. Three sites re-pend with
RetryPolicy(max_attempts=None)when the owning inbound is absent from the registry (wiring_runner.py:5057,:5296,:5480) — by design, so an ACKed-but-never-attempted message is never dropped for outliving a reload. There is no ingress-stage counterpart to thedead_letter_missing_destinations/_handlersstartup sweeps, and adding one collides with that intent. Bounding it needs a rule telling "mid-reload" from "never coming back", which is a decision, not a patch.I originally wrote that those two sweeps re-pend. They dead-letter. I caught it by reading them rather than trusting my own summary, and corrected the row before committing.
Also untouched and still open under #1188: the declared-set reference purge, the spill-directory sweep, the IDE saved-collection classification, a non-write-time eviction key for transform state, the preset ruling re-put on corrected facts, and the enumeration of PHI bodies written outside the store.
Anchor drift, re-measured
Real, and in one case 581 lines.
retention_classification.py:173:181(value still 9)store/store.py:8384,:8659:8919,:9252wiring_runner.py:4415,:4449:4996,:5030Tests — each proven to fail first
Three behaviour tests, all failing with
assert 0 == 1on unmodified code before the fix:test_purge_dead_letters_reaches_a_dead_ingress_row— also asserts the hazard:messages.rawblanks while the ingress payload survives.test_purge_dead_letters_reaches_a_dead_routed_rowtest_dead_ingress_row_does_not_pin_a_streaming_attachment_foreverPlus
test_no_backend_scopes_the_dead_letter_purge_to_one_stage, a source-level parity guard, because the Postgres and SQL Server purges run on hosted runners only — a re-narrowing there would otherwise reachmainunseen. Mutation-tested: re-narrowing the Postgres statement reds it (assert 1 == 0).Synthetic HL7 only. No real PHI in code, tests, logs or messages.
Checks run
Interpreter resolved into this worktree, as required:
pytest tests/test_retention.py tests/test_retention_classification_drift.pypytestper-connection retention + attachment substrate + metadata bagpytestaccepts seam + reingresspytestPHI at-rest + logging inventory + claude citationspytestreference sets + redaction + refused-config-keysruff check ./ruff format --check .mypy messagefoundry(strict)backlog_status_check/backlog_citation_check/dangling_citation_checkpytestbacklog citation + hygiene + status + ledger guardsPostgres placeholder renumbering verified without a live database — both statements contiguous and complete at
$1..$N, with and without overrides.Not run: the full suite (BACKLOG #1014 port collision). Postgres and SQL Server store legs are hosted-runner only — this change touches store SQL on both, so CI is the authority there. Please read those legs plus
windows-service-smoke.gh pr checksrenders CANCELLED as "fail"; tally the check-runs API instead.Commits
BACKLOG.mdis in its own commit, last, as asked.bbee82064— the fix, tests, and classification edits6eed8177c— theBACKLOG.mdnote🤖 Generated with Claude Code