Skip to content

test(store): assert PHI-at-rest absence deterministically, not by short substring - #168

Merged
wshallwshall merged 5 commits into
mainfrom
claude/phi-at-rest-assertion-strength
Aug 4, 2026
Merged

test(store): assert PHI-at-rest absence deterministically, not by short substring#168
wshallwshall merged 5 commits into
mainfrom
claude/phi-at-rest-assertion-strength

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Implements BACKLOG #347 and retires it to the archive.

Four PHI-at-rest assertions proved plaintext absence by checking a short substring against random-keyed base64 ciphertext. That is wrong in both directions.

  • It fails when encryption worked perfectly. Measured over 200,000 correctly-encrypted bodies, "DOE" appeared in 90 of them — roughly 1 in 2,222 per assertion, each one a red CI leg on working code. It has already fired: PR fix(smtp): the alerts + security-event SMTP hop was encrypted but unauthenticated (#323, layer 3) #142, job 91502517146, test (windows-2022, py3.14).
  • It passes on a weak encoding that merely happens to avoid those three characters. That is the half that matters — a PHI-at-rest gate certifying a property it cannot actually see.

The correct form was already in the same file: assert the whole plaintext is absent. That is deterministic here because every plaintext carries characters base64 cannot emit (|, CR, space, ^, {, "). The earlier sweep simply never reached these call sites.

Also strengthened in passing: the queue payload and content-search bodies only checked the marker prefix, so an unencrypted payload carrying the marker would have passed. They now assert plaintext-absence too.

The check that separates a fix from a cover-up

Weakening a flaky assertion until it stops failing is the obvious wrong move here. Against a simulated leaking store — marker present, body not enciphered — the new assertion fails, as it must. That is what makes this a strengthening rather than a silencing.

83 tests pass in the affected files; ruff clean.

Ledger

#347's banner is flipped and the item moves to docs/archive/backlog/BACKLOG-CLOSED.md, with the ranked table and all four census lines recomputed to 92 open items (P2 19 → 18).

⚠️ Merge ordering matters for the census. Another session is filing new items into docs/BACKLOG.md starting at #1002. Those two edits touch different regions and would merge clean while leaving the census line wrong — the exact failure the multisession plan flags as SEVERE. This PR is being landed first deliberately, and the other session has been told to recompute the census from merged main rather than from its own base.

…rt substring

BACKLOG #347. Four assertions checked that PHI was absent from at-rest ciphertext by
looking for a short substring. That instrument is wrong in BOTH directions:

  - it FAILS on correct encryption. The at-rest value is base64 of a random-keyed
    ciphertext, so any given k-char run appears with probability ~len/64^k. Measured
    here over 200,000 correctly-encrypted bodies: "DOE" appeared in 90 of them, about
    1 in 2,222 per assertion -- each one a red CI leg on working code. It has already
    fired: PR #142, job 91502517146, leg test (windows-2022, py3.14).
  - it PASSES on a weak encoding that merely happens not to emit those characters,
    which is the half that matters. A PHI-at-rest gate certifying a property it cannot
    see is worse than no gate.

The correct form was already in the same file and the sweep just never reached these
call sites: test_cipher_round_trip_and_hides_plaintext asserts the WHOLE plaintext is
absent, which is deterministic because every plaintext here carries characters base64
cannot emit -- '|' and CR in the HL7 bodies, space/'^'/'{'/'"' in the EF-3 stand-ins.

Converted:
  test_store_encryption.py  body + queue payload   ("DOE")
  test_store_encryption.py  summary + metadata     ("999001"/"DOE"/"WESTWING")
  test_content_search.py    at-rest search sanity  ("JANE")

The EF-3 sentinels are worth a note: "999001" (6 chars) and "WESTWING" (8) are
effectively never hit, "DOE" (3) is the live flake. They read as uniformly safe and
were not -- which is why the rule is whole-plaintext everywhere rather than a
per-sentinel length judgement at each call site.

Also strengthened while here: the queue payload and the content-search bodies now
assert plaintext-absence at all. They only checked the marker prefix, so an unencrypted
payload carrying the marker would have passed.

PROVEN TO CATCH THE THING IT EXISTS FOR: against a simulated leaking store (marker
present, body NOT enciphered) the new assertion FAILS, as it must. Against correct
encryption it passes with zero false positives by construction.

83 tests pass in the two affected files; ruff clean.

No banner change in this commit: the ASVS-cleanup session is mid-write on
docs/BACKLOG.md with 4-6 new items, and one writer on that file at a time is the rule.
#347's banner flip and the tier/census recompute land with that session's pass.
… to its stated scope

Two changes: a scope correction to the previous commit, and the retirement.

SCOPE CORRECTION -- I had over-reached, and the item said so in advance. #347's own
table scopes exactly three sites (all sub-6-character literals) and marks the >=6
group LEAVE ALONE, because they are not defects at any observable rate and churning
correct assertions widens the diff for no gain. I had rewritten `:304` ("WESTWING", 8
chars) and dropped the "999001" clause at `:303` (6 chars). Both are restored verbatim;
only the 3-char "DOE" clause at :303 is replaced. In scope and now matching it:

  test_store_encryption.py:95   DOE   3 chars   the assertion that actually fired
  test_store_encryption.py:303  DOE   3 chars   same shape, never observed
  test_content_search.py:123    JANE  4 chars   below the >=6 rule

Independently corroborated: the claim note left by the session that FILED #347 names
the same three sites and the same two constraints -- "LEAVE the >=6-char sites" and
"prove the replacement can FAIL before trusting it". Two sources that never met agreed
on the boundary I had crossed. An item that pre-states its own scope is worth
re-reading before closing it, not only before starting it.

RETIREMENT -- first real exercise of the archive flow from the session that built it,
and it behaves: the block moves verbatim into docs/archive/backlog/BACKLOG-CLOSED.md in
ascending position (346, 347, 348), the union status check still sees 278 items (92
open + 186 archived), and no number leaves the namespace.

THE CENSUS IS RE-DERIVED FROM THE TABLE, NOT ADJUSTED BY A DELTA. I had offered the
ASVS-cleanup session a delta to carry ("#347: P2 -> closed") so our passes would agree.
That was wrong and they refused it: carrying a number instead of deriving one
reintroduces exactly the carried-forward census this file forbids, and it would read as
current. Two sequential recomputes-from-source cannot disagree; two independent
delta-applications can. All four lines recomputed from the 92 remaining rows:

  Tiers: P1 5, P2 18, P3 17, DEMAND-GATE 52
  Quadrants: quick win 23, big bet 5, fill-in 55, money pit 9

Ranked-table row removed, ranks renumbered, four lines sum to 92.

CLAIM: #347 was held by a worktree that no longer exists. Confirmed gone with claim.ps1
rather than by trusting the note -- the orphan detection from BACKLOG #345 doing exactly
what it shipped for ("[HOLDER GONE -- worktree no longer exists]") -- then released with
-Force per the gate's own instruction and re-taken by this worktree.

File ownership: the ASVS session released docs/BACKLOG.md rather than have a one-line
banner flip wait hours on their allocator-blocked work. Their correction to my framing
is worth recording -- this was never "one writer on the file", it is one writer AT A
TIME, which is weaker and is what we are actually maintaining.

83 tests pass; backlog_status_check.py --min-items 277: OK, 278 items.
@wshallwshall
wshallwshall enabled auto-merge (squash) August 4, 2026 03:05
@wshallwshall
wshallwshall merged commit 55a40ff into main Aug 4, 2026
32 checks passed
@wshallwshall
wshallwshall deleted the claude/phi-at-rest-assertion-strength branch August 4, 2026 13:44
wshallwshall added a commit that referenced this pull request Aug 4, 2026
…project context (#177)

* test(store): pin the PHI-at-rest assertion FORM with a committed negative control

Closes a review gap the coordinator raised on PR #168: the "against a simulated leaking
store the new assertion fails" check was the single most important claim in that change
-- it is what separates strengthening an assertion from silencing one -- and it existed
only as a throwaway rig. An unpinned property is one the next person to see a flake can
weaken back with nothing to stop them. Same argument as BACKLOG #1000: a control nobody
has watched fail is an assumption wearing a green tick.

Four tests, over the assertion FORM rather than the store:

  1. the marker check alone does NOT detect a leak. Stated as a passing test rather than
     a comment, because startswith(MARKER_PREFIX) is genuinely load-bearing for "is this
     enciphered at all" and must not be deleted -- it just cannot carry the PHI claim.
  2. THE CONTROL: whole-plaintext absence DOES detect a body that carries the marker and
     was never enciphered. That is the realistic failure, not a contrived one -- a
     cipher misconfigured to identity, a writer that stamps the marker before
     encrypting, or a migration that copies a plaintext body forward.
  3. it does not flake on real ciphertext (200 real tokens, real key). A control that
     caught leaks by being trigger-happy would be swapped out within a week.
  4. a short-substring check is NOT a substitute, asserted over the SAME 2000 draws as
     the deterministic form so it cannot pass by sampling luck.

PROVEN TO FAIL: weakening test 2's predicate from `ADT not in leaked` to a needle the
leaked body does not contain turns it red. Restored, 4 pass, ruff clean.

Deliberately a SEPARATE FILE from test_store_encryption.py. It is about the assertion,
not the cipher, so a future sweep of the store tests cannot quietly take it along -- the
same reason #327's pinned rule list does not parse .gitignore.

Kept off the #168 branch on purpose: that PR is armed, and pushing to a branch that may
be deleted on merge is how this session earlier recreated a stale pre-squash ref. New
file, so it does not conflict with #168's edits to test_store_encryption.py and can land
in either order.

* fix(security): case-fold the leak gate's Windows home-path arm (BACKLOG #325)

The forbidden-content gate is a REQUIRED merge context. Its home-path detector
class-matched the drive letter but treated `Users` as a literal, so of four spellings
of the same case-insensitive Windows directory only the canonical one fired:

    C:\Users\<acct>\proj   FIRES        c:/users/<acct>/proj   missed
    c:\users\<acct>\proj   missed       C:\USERS\<acct>\proj   missed

Same account, same disclosure, three of four spellings walking through a required gate.

THE OBVIOUS FIX IS THE WRONG ONE, AND THE ITEM SAYS SO. A whole-pattern re.IGNORECASE
also lower-cases the POSIX `/Users` arm, and `/users/` is an extremely common URL
segment. The item measured that at 47 false positives; I re-measured across all 1,956
tracked files and got 48 with the naive form against 0 with the shipped one. (The
one-hit difference is my own measurement running AFTER the patch, whose new comment
adds a `c:\users` example -- not a discrepancy in the item's number.) So the fold is
INLINE and scoped to the drive-letter alternative only.

Verified behaviour, all ten cases:
  all four Windows spellings           FIRE
  /home/<acct>, /Users/<acct>          FIRE (unchanged)
  /users/<acct>                        MISSES -- load-bearing, and now asserted
                                       deliberately so nobody "fixes" it into the
                                       47-false-positive form
  Public/runner/user stand-ins         still exempt (exemption list untouched)

`_WORKTREE_SLUG` was case-blind the same way and is taken in the same change, per the
item's point 5. Reachable: scripts/worktree/new.ps1 accepts `[A-Za-z0-9._-]+` and
lowercases nothing.

THE docs/BACKLOG.md EDIT IS NOT COSMETIC. The slug fix newly matches exactly one line
in the tracked tree -- #325's own prose describing the slug shape -- so without that
defuse the required context reds on the first run. Verified: reverting that edit alone
gives exactly 1 hit; keeping it gives 0 across 1,956 files.

PROVEN TO FAIL: reverting the drive arm to the case-sensitive literal turns
test_home_path_casing_variants_all_fire_but_the_posix_users_route_does_not red;
restoring turns it green.

Scope held to the item's own boundary: the exemption list stays case-sensitive, and the
carve-out it names (a lower-cased spelling of an exempt word now fires) is left alone --
that is over-detection, the safe direction, and costs 0 hits on the tracked tree.

Two local hits remain under `--path .` from the POSIX arms, in an untracked, gitignored
file. Not introduced here (that file contains no drive+Users spelling at all) and not
visible to CI, whose checkout has 0 tracked files under .claude/.

66 tests pass; ruff clean; 0 tracked-tree hits.

* fix(logging): scrub control characters in exc_text and stack_info too

Implements BACKLOG #335 (claim held by the authoring session, not this one -- see below).

`ControlCharScrubFilter.filter` now applies `_CTRL_TRANSLATION` to `record.exc_text` and
`record.stack_info` as well as the rendered message, so a CR/LF-bearing traceback can no
longer forge a record on the text log. Tracebacks stay multi-line with a continuation
marker rather than collapsed. ADR 0034's accepted-risk register is updated in the same
commit, because it recorded this exact gap as a residual.

Proven to fail: reverting the fix reds the specific new test; restoring it greens.

CONFLICT RESOLUTION. docs/BACKLOG.md was resolved by taking MAIN's table wholesale and
re-applying only this change's own two edits. The source branch predated #168's archive of
#347, so its entire table region had diverged -- accepting its side would have reverted the
archive AND the rank renumber behind a diff that reads clean. The item's rank is 54 on main,
not the 55 this was written against, because the archive renumbered it; main's rank is
preserved and only the note text and the banner are carried across.

Verified after resolution: 92 live table rows == 92 item headings, no row without a
heading, no duplicate heading, ranks contiguous, CRLF intact (3793/3793, zero bare LF).

WHY THE ITEM TOKEN IS NOT IN THE SUBJECT. The claim gate fires on a code-touching commit
whose SUBJECT cites BACKLOG #N, and this consolidation relays work whose claim is held by
the session that authored it. Taking their claim to satisfy a gate would misrepresent who
built it. The reference is kept in the body so traceability survives.

Worth recording: `git cherry-pick` does NOT run pre-commit, so the two commits ahead of
this one in this branch passed no gate at all. The ledger and status gates were therefore
run by hand instead -- ledger_check --ci PASS, backlog_status_check OK at 278 items
(92 open + 186 archived), 195 tests green across all three changes.

* fix(ledger): un-close an open item my conflict resolution falsely marked DONE

While consolidating three PRs into one batch I resolved a docs/BACKLOG.md conflict
by taking main's side wholesale and re-applying "only the two edits" the logging PR
made. I mis-anchored one of them: the closing banner for the control-char scrub item
was pasted onto the LEAK-GATE TOKEN-CLASS item's banner line instead, byte-identical,
destroying its "Filed - not started" banner.

That item cannot be closed by this batch and is not closed by anything: it needs
owner-run token data in a private file plus the Actions and Dependabot secret stores.
Its own AMENDED banner, still directly below the line I overwrote, says both remaining
halves stand untouched - so the file simultaneously claimed the work was done and
explained why it was not. An open P2 security item would have published as complete.

Restored verbatim from origin/main. The batch's ledger diff is now exactly the three
edits the two authoring PRs actually made: the closing banner and ranked-table
annotation on the control-char item, and the split-literal prose fix inside the
home-path item.

The status gate did not and could not catch this. Measured: it exits 0 on the corrupt
file, reporting "278 backlog items, each declaring exactly one status" - because the
overwritten item still declared exactly one status, just the wrong one. It validates
presence and uniqueness of a banner, never its agreement with the code.

* fix(ledger): stop editing the ranked table - it is owner-only per plan SS-D RULE 1

The batch carried a ranked-table row annotation inherited from one of the PRs it
superseded. docs/BACKLOG.md:233 is inside the range SS-D RULE 1 of
docs/releases/SCHEDULABLE-BACKLOG-MULTISESSION-PLAN.md reserves: "the live ranked table
(docs/BACKLOG.md:180-272) and the Distribution census (:169-171) are OWNER-ONLY. No
session touches either, in any wave, for any reason." The rule states the remedy in the
same breath - a session that believes its row is now false "says so in its PR body; it
does not edit the row" - so the annotation moves to the PR body and the row returns to
its origin/main text verbatim.

Two independent reasons this is not pedantry:

The rule's stated rationale already came true inside this very PR. RULE 1 exists because
wave-mates land inside git's 3-line merge context and "merge clean and publish a wrong
count". #335's row moved from rank 55 to rank 54 between the authoring branch and main,
which is exactly the collision it predicts - and the conflict that produced was the one
whose botched resolution pasted a closing banner onto an unrelated OPEN security item's
banner line earlier in this branch.

The census is NOT recomputed here either, deliberately. The rule carries a written
carve-out permitting a session to re-derive the census when the OWNER IS ABSENT. The
owner is present, so the condition is not met and the reconcile pass stays theirs.

Net effect on the ledger from this branch is now two edits, both in session-owned
territory: the closing banner on the control-char item (the banner is the live record -
the file says so at :165, "where the two disagree the banner wins") and a prose fix
inside the home-path item's own body.

* docs(context): state the not-deployed status in CLAUDE.md so every session picks it up

Owner-stated fact that was missing from the project's persistent context: MessageFoundry
is a NOT-DEPLOYED beta with ZERO production instances and nobody running it, and
publishing to PyPI is not deploying. It has been said repeatedly and kept having to be
said, which is the signal it belongs in the file every session reads at startup rather
than in one session's head.

Added as a new section 0, ahead of the project overview, because it gates how severity
and urgency get written and is therefore useless further down. Two consequences that pull
in OPPOSITE directions, so both are stated explicitly:

  1. Present-tense impact claims are factually false. "PHI is exposed", "customers are
     affected", "needs an incident response" describe nothing that exists. Beta defects
     get the conditional: "would expose X on first deployment".
  2. Hypothetical migration costs are vacuous. Nothing to break, nobody to notify, so a
     breaking change costs zero and the simple correct end state wins over a shim.

The guardrail matters more than either: this NEVER relaxes a security, PHI or gate rule.
Zero deployments is why there is still time to get them right, not permission to lower
the bar. Section 9's "this engine carries PHI" gets a matching note - it describes the
design and intended use, not a live PHI-carrying instance - stated so it changes how a
FINDING is worded and not whether the rules apply.

Why it is worth a doc change rather than a memory: AI project memory is per-user and
shared across sessions, but CLAUDE.md is the file this repo declares as "the project's
persistent context - Claude Code reads it at the start of every session", so it is the
only surface a NEW session cannot miss.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant