# Claims review — 0.2.36

- base: v0.2.35
- head: 7c4a424
- surface: sha256:4ddf21235110bec0c15f0a676a84aa21cbd406587a3856a869572871d616d72c
- verdict: pass
- reviewer: Claude (Opus 5)

Four surfaces changed. Three of them were sentences that had been true and
stopped being true; one is a threat-model paragraph rewritten twice in this
release, because the first rewrite described a lock that did not hold as tightly
as it said. The finding worth recording is not any single sentence but that two
of the four sat in files the claim gate could not see, and the reason it could
not see them was a rule about directory depth.

## Findings

### 1. src/audit.ts — the receipt sink had no lock, and the audit sink's lock could be taken twice

- Claim, as of 0.2.35: `docs/security/THREAT-MODEL.md` — *"Two OS processes
  writing the same audit/receipt sink have no file lock."* Half stale (the audit
  sink had one since 15 August), half true (the receipt sink did not).
- Mechanism: `acquireSinkLock` at construct time, against `this.sink` only, while
  receipts are appended to `this.receiptSink` — a different path.
- Why it fell short: two configurations write one receipt file with no shared
  lock — receipt-only installs (no audit sink, so no lock is taken at all) and
  two gateways with separate audit sinks sharing one receipt file. Both fork
  `seq` and `prevHash`, and a forked chain does not verify. Reproduced with two
  OS processes: `duplicate seq: 1,2,3,4,5,1,6,7,8,2,3,4,5,6,7,8`.
- Disposition: fixed. `log()` now takes every sink path it is about to write, in
  lexicographic path order — one fixed global order, so two locks cannot
  deadlock — and `writeReceipt` re-reads the receipt tail under that lock rather
  than trusting what the instance loaded at startup. A single digest over the
  pair was rejected and the reason is in the code: `audit-a+receipt` and
  `audit-b+receipt` hash to different lock files, which leaves the shared receipt
  file unserialised, which is the bug.
- Second finding, inside the first: with that fix in place the regression test
  still failed once in five runs. The lock file is created by `writeFileSync`
  with `wx` and the owner pid lands in the next syscall; a second process
  arriving in that gap saw the file exist with an empty body, could not read an
  owner, fell past the `pidAlive` check because there was no pid to check, and
  deleted a live process's lock. So the guard added on 15 August under the title
  *"reject a second OS process on the audit sink lock"* rejected a second process
  most of the time. Its test never landed in the window, and a test that has only
  ever been green proves nothing about the case it was written for.
- Disposition: an unreadable lock is now honoured, not stolen, until it is 30s
  old. `test/sink_lock_ownership.test.ts` states the window rather than racing
  for it — it plants the empty lock file — and pins the two behaviours the fix
  could have broken: a lock naming a dead pid is still reclaimed at once, and an
  unreadable lock past the threshold is still reclaimed, so a process that dies
  inside the gap does not wedge its sink forever. Red on all three before the
  fix, green after, and the two-process test went 20/20 green where it had been
  1-in-5 red.
- Residual, stated in the threat model rather than left for a reader to find: a
  process that dies inside that gap blocks its sink — and, being fail-closed,
  blocks access — for those 30s.

### 2. The claim gate could not see two of the four surfaces

- Claim: the release gate reviews the documents that carry promises.
- Mechanism: `test/denetci.mjs` — `SURFACES` is the reviewed list, and
  `unlistedDocs()` warns about a changed document on neither list.
- Why it fell short: `unlistedDocs()` matched top-level `.md`/`.html` and
  `docs/*.md` — one level. That reads as a rule about where promises live and is
  a fact about where the function looked. `docs/security/NPM-PROVENANCE.md` was
  reviewed because someone added it to `SURFACES` by hand;
  `docs/security/THREAT-MODEL.md` beside it was on neither list, and nothing was
  capable of saying so. `standards/README.md` was outside both patterns for the
  same reason. Those are exactly the two files that carried stale claims into
  0.2.35 and were found by an outside reader, not by the gate.
- Disposition: working records are named as directories now instead of inferred
  from depth, and anything under `docs/` or `standards/` that is neither listed
  nor named trips the warning. Demonstrated failing first: a scratch file at
  `docs/security/` depth 2 is reported by the new rule and is invisible to the
  old one. Widening it immediately surfaced a third stale document nobody had
  looked at — `docs/security/best-practices-badge.md` still said *"Do not submit
  until they have been read once by a human"* four days after the badge was
  filed and awarded. It now points at project 14160 and says it is not updated
  when an answer changes there.
- Not fixed, recorded: `SURFACES` exists twice, in `test/denetci.mjs` (21 files)
  and `test/claim_discipline.mjs` (12 files), both hand-maintained. Two
  hand-written copies of one fact is the shape that produced most of this
  release. Deriving one from the other is a change for its own release, not a
  rider on this one.

### 3. docs/ARCHITECTURE.md — "transmits nothing outward on its own"

- Claim: *"the gateway runs in the customer's network and transmits nothing
  outward on its own."*
- Mechanism: `announceUpdate()` in `src/update-check.ts`, and the production
  profile in `src/config.ts`.
- Why it fell short: false as written, and *"on its own"* made it worse rather
  than hedging it — the version check is precisely the outbound call the operator
  did not ask for. The production profile also sets
  `CONARIUM_ANCHOR_SINK=opentimestamps` when the operator left it unset, which
  submits digests to public calendars.
- Disposition: rewritten to keep the claim that survives measurement — protected
  values do not leave — and to name both outbound connections with their source
  file, what they send, and the variable that disables each. Verified against the
  code rather than the diff: `CONARIUM_NO_UPDATE_CHECK` is read in
  `src/update-check.ts`, `CONARIUM_ANCHOR_SINK` defaults to `none` in
  `src/config.ts:111` and is set to `opentimestamps` at `:113`, and the calendar
  hosts are in `src/ots/calendar.ts`. The first rewrite named `src/index.ts` as
  the caller; `announceUpdate()` is also called from `src/http.ts:350`, and a
  reader auditing outbound traffic needs both, so both are named.

### 4. standards/README.md — draft status, and a warning that was deleted with it

- Claim: *"`-04` is being prepared here and has not been submitted."*
- Mechanism: the IETF Datatracker.
- Why it fell short: `-04` was posted on 18 August. Restating a status in a file
  that does not hold it means the file is a copy that ages, which is the third
  time in three releases the same shape has failed here.
- Disposition: the file now points at the Datatracker instead of restating it,
  and the individual-submission limit — not an IETF standard, not adopted by a
  working group — is kept. `test/datatracker_draft_status.mjs` enforces the
  shape: the README must carry the canonical tracker URL, an unreachable
  Datatracker is a printed SKIP rather than a silent green, and a reachable
  mismatch is red. All three states measured here, not taken from the report:
  red against a planted stale sentence, green against the current text, SKIP with
  the endpoint pointed at a closed port.
- Recorded because it was a deletion, not a rewrite: the rewrite removed a
  paragraph admitting this file had been wrong the same way about `-03`. Its
  function — send the reader to the tracker — is now carried by the structure of
  the file rather than by a warning, and the history is here. That is a
  disposition, not an oversight.
- Known limit of the new check: the mismatch half matches a phrasing
  (`-NN … has not been submitted`). A differently worded stale sentence slips
  past it. The half that does not depend on phrasing is the requirement that the
  tracker URL be present, and that is the half that makes restating the status
  unnecessary in the first place.

### 5. README.md and package.json — "a receipt of every access"

- Claim: the gateway *"hands you a signed, independently verifiable receipt of
  every access it mediates."*
- Mechanism: `writeReceipt`, reached when `receiptSink` is configured.
- Why it fell short: unconditional as written. `conarium-init` configures the
  sink, so the typical install does produce a receipt per access — the claim is
  true of the default layout and not true as a property of the gateway.
- Disposition: both now state the condition and the default in the same
  sentence. `package.json`'s `description` carried the same unconditional
  wording and is corrected here; it was outside the scope given to the reviewer
  who found the README instance, which is a scoping mistake worth naming — the
  same sentence in two files, and only one of them was assigned.

## What did not change

The policy gate, masking, the receipt format, the reconciliation tool and the
conformance vectors are untouched. The engine change is confined to when locks
are taken and to which paths. `npm run test:all`: 89 test files, 747 passed, 2
skipped, 52/52 checks.
