Skip to content

fix(phi-scan): scan the tracked corpus, reconcile it against git, and add the HL7 v2 structured pass (PHI-SCAN-WALK-ROOT-SCOPE) - #35

Merged
NSchatz merged 4 commits into
mainfrom
phi-scan-walk-root-scope
Aug 8, 2026
Merged

fix(phi-scan): scan the tracked corpus, reconcile it against git, and add the HL7 v2 structured pass (PHI-SCAN-WALK-ROOT-SCOPE)#35
NSchatz merged 4 commits into
mainfrom
phi-scan-walk-root-scope

Conversation

@NSchatz

@NSchatz NSchatz commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

What this closes

PHI-SCAN-WALK-ROOT-SCOPE, the transform leg. Both of the PHI gate's enumerating routes covered test/fixtures/ and src/ only, which was 31 of 102 tracked files. 71 were read by NEITHER route, 27 of them under test/, and 8 of those carried inline HL7 PID| literals with names, DOBs and MRNs.

The sharpest half is this repository's own, not a sibling's: test/fixtures/ HAS NEVER EXISTED ON ANY COMMIT. git log --all -- 'test/fixtures*' is empty. The walk's existsSync guard returned on its first line for that root on every run this scanner has ever made, and every one of those runs printed OK: no hits and exited 0.

The two halves, each in addition to the other

Enumeration. The walk now covers every tracked directory plus the repo-root regular files, and reconcileWithGit compares what it actually OPENED against git ls-files, refusing (exit 2) and naming every tracked path it did not open. A count cannot detect the failure this closes (it counts the roots that DID exist) and neither can an existence check (an EMPTIED root opens nothing). A declared root that is not a directory refuses before the walk: existsSync FOLLOWS, so a dangling root read false and printed clean over an off-disk corpus, and a symlinked root was followed.

Recognition. Enumerating more files buys the SSN/email floor and nothing else, and measured over the 8 PID| files the floor finds ZERO: no dashed SSN, no email. So an HL7 v2 structured pass ships with it, on all three routes, over a named set of PID / NK1 / GT1 / IN1 fields. It finds segment literals inline, because this package ships no standalone .hl7 file at all: every message in its corpus is a .ts string literal.

Figures, both denominators

tracked opened in neither of those under test/
base daf75c3 102 31 71 27
head 103 101 2 0

Both remaining files are declared literal-path exemptions (the vendored gzip tarballs). 70 files newly opened, of which 69 existed at base and were hand-read. A regular-file walk root exits 2 here, derived from this script's own contract, not ported.

The grid

14 paths x 4 payloads x 3 routes = 168 cells, each run against the base scanner and the head scanner: 37 base 1 still 1, 74 0 -> 1, 56 unchanged zeros, and ONE 1 -> 0.

That one cell, disclosed rather than found later: pnpm phi-scan package.json exited 1 on the npm publisher mailbox in its author field and now exits 0. It is declared with an EMAIL allow-list entry taking a path AND an address, the narrowest instrument here: the same address in any other file still reports, and any other address in that file still reports. An allow-list entry is still route-blind and that reach is pinned from both directions by tests. The alternatives were a bigger hole (exempting the whole file) or an unusable gate (every commit touching package.json blocked).

What was found in the corpus

Nothing patient-identifying, in any of the 69 hand-read files. The fixture placeholders are named in scripts/phi-allow-list.txt rather than scrubbed, because scrubbing destroys the evidence the audit happened: Jane Q. Public, Jane Doe, the suite mnemonics, MRN1/MRN2/MRN12345, an SSN-shaped 999887777 in area 999 which the SSA has never issued, placeholder streets, and two numbers in the reserved 555 range. The one org-traceable string is the publisher mailbox above, which is public and non-patient.

The gate

conformance-refuter, three passes (the ADR 0016 cap), each on a frozen sha.

  • Pass 1 REFUTED (9c6bb95): the limits banner was asserted exhaustive and missed two reachable silent misses; IN1-17 was mapped as a telephone field when v2.5.1 defines it as Insured's Relationship To Patient; three figures were off by one. Both silent misses were fixed rather than disclosed (a name component outside ASCII, and a whole message in one literal with escaped \r separators, each reported CLEAN before).
  • Pass 2 REFUTED (689931e): the extended negative list was measured incomplete again, seven more fields. A list of that shape cannot be kept true, so the claim was inverted: the banner now enumerates exactly which fields are read and says anything not named is not checked. Provenance recorded, grid totals corrected to factor.
  • Pass 3 NOT REFUTED (e55adef), with four minor corrections landed here without another round, as that pass directed: the *.md skip figure is 11; the provenance sentence named which 13 of 28 fields are corroborated in-repo and which 15 are not (the whole GT1 row); the truncation disclosure's "the field it cuts in still reports" was false and is corrected; and the coverage case now enumerates all 28 named fields rather than eight, so both the widening and the narrowing direction red.

Residuals, disclosed and not closed

The reconciliation compares path sets, not the bytes git carries at them (no repo in this ecosystem has closed that; widening makes it narrower, since a decoy must now mirror 101 names rather than 7). It is vacuous on an empty index. The --staged route's prefix scope still misses a scan root's own path staged as a non-regular entry. An empty enumeration still reports clean. Untracked content under an undeclared top-level directory is invisible to both enumerating routes, and that gap is pinned by a test that asserts it, so closing it later reds rather than letting the disclosure outlive the defect. The enumerate-then-read race's precondition has now happened: .changeset/ is in scope and a release step creates and deletes files there; it exits 2, not clean.

Verify

scripts/verify.sh transform green. ran: typecheck, lint, format:check, phi-scan, check:no-emdash, check:no-internal-refs, test:coverage, build, attw, plus pnpm audit --prod and the licence allowlist. 26 test files, 427 tests. check:agent-notes is a gate this ladder does not know, so it was run by hand and is green.

NSchatz added 4 commits August 8, 2026 12:18
…plus an HL7 v2 structured pass (PHI-SCAN-WALK-ROOT-SCOPE)

Both enumerating routes covered `test/fixtures/` + `src/` only: 31 of 102 tracked
files. 71 were read by NEITHER route, 27 of those under `test/`, and 8 of those
carried inline HL7 `PID|` literals with names, DOBs and MRNs.

THE SHARPEST HALF IS THIS REPO'S OWN: `test/fixtures/` HAS NEVER EXISTED ON ANY
COMMIT (`git log --all` is empty for it). The walk's `existsSync` guard returned
on its first line for that root on every run this scanner has ever made, and
every one of those runs printed "OK: no hits" and exited 0.

A count does not detect that (it counts the roots that DID exist) and neither
does an existence check (an EMPTIED root opens nothing), so the remedy is
reconciliation: the walk now covers every tracked directory plus repo-root
files, and `reconcileWithGit` refuses (exit 2), naming every tracked path the
walk did not open. A non-directory root refuses before the walk too, because
`existsSync` FOLLOWS: a dangling root read false and printed clean over an
off-disk corpus, and a symlinked root was followed.

Exit code for a regular-file root is 2 HERE, derived from this script's own
contract (`ENOTDIR` into `walk`'s catch), not ported from a sibling.

WIDENING IS TWO-SIDED. Enumerating buys the SSN/email floor and nothing else,
and measured over the 8 `PID|` files the floor finds ZERO: no dashed SSN, no
email. So an HL7 v2 structured pass ships with it, in addition to the floor and
never instead: PID/NK1/GT1/IN1 by field and component, checking names, DOB,
MRN/member-id/SSN, address and phone against the allow-list. It finds segment
literals INLINE, because this package ships no standalone `.hl7` file at all:
every message in its corpus is a `.ts` string literal.

THE GRID, PROVED RATHER THAN ARGUED: 169 base-vs-head cells over 15 paths, 4
payloads and all 3 routes. 37 base `1` cells still `1`, 74 cells `0 -> 1`, and
ONE `1 -> 0`.

THAT ONE CELL, DISCLOSED: `phi-scan package.json` exited 1 on the npm publisher
mailbox in its `author` field and now exits 0. Declared with the new `EMAIL`
tag, which takes a path AND an address, so the same address elsewhere and any
other address there both still report. An allow-list entry is still ROUTE-BLIND
and that reach is pinned from both directions by tests.

69 files hand-read. Nothing patient-identifying. The fixture placeholders are
NAMED in `scripts/phi-allow-list.txt` rather than scrubbed: `Jane Q. Public`,
`Jane Doe`, the suite mnemonics, `MRN1`/`MRN2`/`MRN12345`, an SSN-shaped
`999887777` in area 999 (never issued), placeholder streets, and two numbers in
the reserved 555 range. The one org-traceable string is the publisher mailbox.

The scanner's own test file is now inside the corpus, so its violator payloads
are assembled from parts at runtime. Allow-listing them would blind the floor
globally and route-blind; exempting the file would leave the largest
violator-bearing file unscanned.

Also: the walk's regular-`*.md` skip is removed (purely additive, since the
`<path>` route always read those bytes, so the two routes disagreed); `.git` is
a regular FILE in a submodule and is skipped by literal name; the all-route
exemption list is two literal paths (the vendored gzip tarballs, whose bytes are
not the text they carry) and never reaches a blocking route.

Four residuals stay disclosed, not closed, including that the reconciliation
compares path SETS and not the bytes git carries at them, and that the
enumerate-then-read race's precondition has now happened (`.changeset/` is in
scope; it exits 2, not clean).
…IN1-17 and the published figures (PHI-SCAN-WALK-ROOT-SCOPE)

Pass 1 REFUTED with three INTRODUCED findings. Two of them were reachable
silent misses reported CLEAN, so they are FIXED rather than disclosed; the
third was a wrong spec clause. Every figure is re-derived after the changeset
existed, which is what made the first set off by one.

1. A NAME COMPONENT OUTSIDE ASCII reported clean. `looksLikeNameToken` was
   `[A-Za-z]`, so an unaccented name hit while the same name with its accent
   exited 0, as did every name in a non-Latin script: a gate blind to exactly
   the names least likely to be synthetic. It is a Unicode letter class now,
   which still excludes digits so a coded value stays out. Purely additive.

2. A WHOLE MESSAGE IN ONE LITERAL WITH ESCAPED SEPARATORS was never located,
   because the character before `PID|` is the letter `r` of the escape and the
   boundary class rejected it. Measured before: an ADT carrying a name, a DOB,
   an MRN, an address and two phones scanned clean at exit 0, while the same
   message one segment per array element produced 8 hits. That is the other way
   a `.ts` file carries a message here, and the shape `parseHL7(raw)` consumes.
   The escaped separator is now a boundary AND a terminator, and the terminator
   half is load-bearing: without it the message is read as ONE segment and the
   next-of-kin's relationship code is reported as the patient's PID-11 address.

3. IN1-17 WAS MAPPED AS A TELEPHONE FIELD. HL7 v2.5.1 defines it as Insured's
   Relationship To Patient, so a SNOMED relationship code was reported as a
   phone number and the remedy it steered a developer toward was a global
   `PHONE` clearance of that digit string. IN1 carries no insured telephone at
   all (IN1-7 is the payer's), so IN1 is now absent from `PHONE_FIELDS`
   deliberately. Every field number now cites its v2.5.1 chapter and clause,
   because an uncited table is what produced this one.

4. THE BANNER'S "STILL NOT DETECTED" LIST WAS ASSERTED EXHAUSTIVE AND WAS NOT,
   and two other files defer to it as the authoritative statement of limits. It
   now also names the non-default component separator, the one-character name
   component, the unmapped PID fields, and binary or compressed targets.

5. THE PUBLISHED FIGURES WERE OFF BY ONE, self-referentially: writing the
   changeset added a tracked file the changeset then counted. Both denominators
   are now stated. Base `daf75c3`: 102 tracked / 31 opened / 71 in neither / 27
   of those under `test/`. Head: 103 tracked / 101 opened / 2 in neither / 0
   under `test/`. 70 newly opened, of which 69 existed at base and were hand-
   read. The removed `*.md` walk skip dropped 14 files at base, not 16.

Also: `refuseNonDirectoryRoots` swallowed every `lstat` error as "absent" and
now excuses only `ENOENT`; the reconciliation's remedy text steered toward
widening the exemption list for a deleted-but-tracked file and now names that
cause first; and the untracked-outside-every-root residual the refuter named is
disclosed AND pinned by a test that asserts the gap, so closing it later reds
that case rather than letting the disclosure outlive the defect.

THE GRID WAS RE-RUN AGAINST THE CORRECTED HEAD: 37 base `1` cells still `1`, 74
cells `0 -> 1`, and the same single disclosed `1 -> 0` on the publisher mailbox.
The two recogniser widenings added detections and subtracted none.

The new escaped-separator case is the one test whose payload is itself a live
segment literal, so every value in it is assembled at runtime. The gate caught
it as a literal first, which is the evidence the widening works.
… last two figures (PHI-SCAN-WALK-ROOT-SCOPE)

Pass 2 REFUTED. Every finding was about the accuracy of a claim rather than the
behaviour of the gate, and the major one is the same shape twice.

1. THE EXHAUSTIVE NEGATIVE LIST WAS MEASURED INCOMPLETE A SECOND TIME, AFTER
   BEING EXTENDED IN ANSWER TO THE FIRST PASS. Seven PHI-bearing v2.5.1 fields
   reported clean while the list called itself the authoritative statement of
   this gate's limits: NK1-26, NK1-31, NK1-32, NK1-37, GT1-2, GT1-4 and IN1-49.
   Two of those matter beyond the count. NK1-37 is a social security number
   field, and an undashed one there is invisible to the floor as well, so both
   passes miss it at once. IN1-49 is the member id, while three files told a
   reader the pass covers "member id".

   A LIST OF THAT SHAPE CANNOT BE KEPT TRUE: every clause of every segment of
   the standard would have to appear on it, and two passes proved it. So the
   claim is inverted. The banner now enumerates EXACTLY the fields that are
   read and says anything not named is not checked. That is checkable against
   the code; the other shape never was. `phi-scan-overrides.md` and `ci.yml`,
   which defer to the banner, drop their unqualified "member id" wording and
   point at the named set instead. NEVER RESTORE THE NEGATIVE SHAPE.

   The table is NOT grown in answer to this. Growing it is what produced the
   IN1-17 defect: more uncited field numbers from the same memory.

2. PROVENANCE, WHOSE ABSENCE WAS THE ROOT CAUSE AND IS NOW SAID PLAINLY. The
   field numbers are asserted from HL7 v2.5.1 and were cross-corroborated
   IN-REPO ONLY, against `src/messages/related-person.ts` and the vendored
   `@cosyte/hl7` type surface. They were NOT checked against a published copy of
   the standard, and one of them was wrong on the way here. That is why the
   table is deliberately narrow and why widening it means citing a source.

3. A FOURTH RECOGNISER LIMIT, DISCLOSED RATHER THAN GUESSED AT: a literal
   backslash followed by `r` or `n` inside a field value ends the segment early,
   because the escaped separator is also the terminator. Measured, a Windows
   path in PID-11 truncates there and PID-13/14 go unread. It can only SHORTEN a
   segment, never renumber one, and the field it cuts in still reports. Not
   decidable from static text.

4. THE `*.md` SKIP FIGURE WAS WRONG IN BOTH DIRECTIONS AND IS NOW ZERO. Under
   the OLD roots the skip dropped no files at all: `src/` holds no markdown and
   `test/fixtures/` never existed, so it was dead code. Under the widened roots
   it would drop 15, which is the only reason removing it matters. 16 and 14
   were both counts of something else.

5. THE GRID TOTALS NOW FACTOR AND THE PUBLISHED FIGURE DID NOT. It is 14 paths
   x 4 payloads x 3 routes = 168 cells: 37 base `1` still `1`, 74 `0 -> 1`, 1
   `1 -> 0`, and 56 unchanged zeros, which sums to 168. "169 over 15 paths"
   factored to 180 and left 57 cells unaccounted for. It is recorded as a
   MEASUREMENT, not as a fixture: nothing pins the base half, because a
   head-only test structurally cannot, and saying otherwise was the overclaim.

The new suite case asserts the coverage boundary from BOTH sides in one run:
eight fields inside the named set must report, and eight outside it must be
clean, so each zero is a declared gap rather than a detector failure. A field
added to the table without being added to the banner reds there.

The grid was re-run against this head: 37 / 74 / 1 / 56, unchanged, and the same
single disclosed `1 -> 0` on the publisher mailbox.
…pin all 28 named fields (PHI-SCAN-WALK-ROOT-SCOPE)

Pass 3 NOT REFUTED, with four minor claim-accuracy corrections to land without
another round (the ADR 0016 cap is spent) and one stale citation. All four were
in the flattering direction, inside the commit whose purpose was to fix
claim-accuracy defects, and one of them was the same figure wrong a third time.

1. THE `*.md` SKIP FIGURE IS 11, NOT 15, AND THAT IS THE FOURTH VALUE PUBLISHED
   FOR IT. Measured by restoring the skip byte for byte and running the scanner:
   11 tracked files are opened by removing it. The other four tracked markdown
   files sit at the repository ROOT and are enumerated by `walkTopLevel`, a
   function that never carried the skip, so they were never the skip's to drop.
   16, 14 and 15 each counted something else (tracked markdown at head, at base,
   at head again). Count what the SKIP drops, not what matches `*.md`.

2. THE PROVENANCE SENTENCE OVERCLAIMED ITS OWN CORROBORATION, so it is now
   field by field. 13 of the 28 numbers are corroborated in-repo
   (PID-3/5/7/11/13/14, NK1-2/4/5/6/16, IN1-16/36). 15 are corroborated by
   nothing here at all, and that is where the residual risk sits: the WHOLE GT1
   row, which no in-repo source mentions even once, plus PID-6/9/19/20,
   NK1-30/33 and IN1-18/19. Saying "cross-corroborated in-repo" without naming
   which concealed exactly the numbers most likely to be the next IN1-17.

3. THE TRUNCATION DISCLOSURE CARRIED A FALSE SUB-CLAUSE. "The field it cuts in
   still reports" holds only when the surviving prefix clears a recogniser
   floor. Measured: a cut inside a family name silences that field AND
   everything after it, leaving only the fields before the cut. The "can only
   shorten, never renumber" half is true and stays. A second probe pins the
   corrected half.

4. THE NEW CASE'S DURABILITY CLAIM WAS FALSE, so the claim is made TRUE rather
   than narrowed. It named eight fields and said a field added to the table
   without being added to the banner would red there; adding two left the suite
   green. The NARROWING direction was worse: 15 of the 28 named fields fired in
   no test at all, so the code dropping a field while the banner kept promising
   it also shipped green. The case now enumerates ALL 28 positives beside the
   ten declared gaps, in one run, so both directions red.

5. A stale citation: the field-table header cited PV1 Chapter 3 beside segments
   PV1 does not appear in, while the banner says PV1 is not read at all.

No behaviour changed in this commit. The gate reads exactly what the banner
says it reads, in both directions, verified mechanically: named-not-read is
empty and read-not-named is empty, 28 each.
@NSchatz
NSchatz merged commit ad5f2f2 into main Aug 8, 2026
9 checks passed
@NSchatz
NSchatz deleted the phi-scan-walk-root-scope branch August 8, 2026 13:48
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