# D2 — control receipt and integration mechanics

**Date:** 2026-09-13. **Author:** hertz (rig owner). **Gater:** doyle.
**Contract:** `docs/297-OUT-OF-BAND-CAPTURE-PLAN.md` @ `687ddd69` (todlando).
**Instrument spec:** `docs/297-CAPTURE-INSTRUMENT-SPEC.md` @ `8eeec9ee` (amendments pending, listed in §5).

**Authorization actually used:** the harmless-control authorization only. **No host query, no rule
creation, no elevation, no field execution.** Every case below is a synthetic in-memory object or a
static file read. These files are not in git — `.spt/` is gitignored — so they are **pinned by content
hash**, not by commit.

---

## 1. Pins (revision 2 -- supersedes revision 1 entirely; every hash below is current)

| artifact | sha256 |
| --- | --- |
| `d2_capture.ps1` | `bccd9be883d835e6346ff77bcd0e5de75a35dd8fdd362a17795a393f603c38c6` |
| `d2_wrapper.ps1` | `a429581165751ae68d65299a794519022bae5de8f2183198e4e99af3d5669e96` |
| `d2_serializer_check.ps1` | `87eced80aa04baded7b1f6ab951dafc17895cfb1291fe28e970da56600f17043` |
| `d2_document_check.ps1` | `18fab43a49175a6ebbf17e0b320666233ff31bf82c6787cd7e3a73a11b43d514` |
| `d2_stub_case.ps1` | `888128c9a89dbed792b671b7f317d497ffda829bee4a07c3f48f017bdc3ec437` |
| `d2_wrapper_check.ps1` | `fea017d05b2053610754663d7b21bff845d94c1f79dae98799c39344d901c8ac` |
| `receipt-serializer-wps51.txt` | `8821bec41611b511a2fe77afd36eaba794c98e1d7f1bac8a8b17de45e34f851a` |
| `receipt-serializer-pwsh7.txt` | `1b4a2bad5a6fcc551a0b9f392e344a1df1dae8d56632ecadf9cf1fcfefab0034` |
| `receipt-document-check-wps51.txt` | `4675c7c66b835715d4eaa0b025c56bcb82a897455ffff90378f91b818d737c24` |
| `receipt-wrapper-check-wps51.txt` | `8b6a093b6880ba0e7e9a5302a9bf12efbf72106ebdb0a8e91679f637a78a35cc` |
| `receipt-mutation-audit.txt` | `1e3a1ec0466d429789b39bb4fd046afa84b6f5b5fc82c80a3d2a63c6406132b1` |

`D2-MANIFEST.sha256` verifies (`sha256sum -c` exit 0). Path:
`.spt/preserved/hertz-fp-driver-review/d2/`. All instruments are ASCII-only (see section 4).

## 1a. Exercise results -- all four, native exits, nothing piped

| check | edition | totals | native exit |
| --- | --- | --- | --- |
| serializer (leaf value encoding) | Desktop 5.1.26100.8875 | pass=12 fail=0 | **0** |
| serializer (leaf value encoding) | Core pwsh 7 | pass=12 fail=0 | **0** |
| **document** (a real capture file, stubbed host) | Desktop 5.1 | **pass=41 fail=0** | **0** |
| **wrapper** (four scenarios, stubbed capture) | Desktop 5.1 | **pass=23 fail=0** | **0** |

Four red controls across the four checks, each failing as required. Every green above is worth
reading only because a deliberate failure was reported in the same run.

## 1b. doyle's three capture gaps -- closed, and what closing them cost

1. **Multiple matches.** `Read-RuleRecord` and `Read-FilterRecords` no longer select `items[0]`.
   Every returned object is preserved as its own entry under `matches[]` with a `match_index` and its
   own property map; `matched_count` is recorded (**filters were losing it entirely**) and `ambiguous`
   is set when more than one object answers to a name. Exercised by the `multi` case: two objects,
   both preserved, distinct `InstanceID`s, the second's singleton enforcement kept.
2. **Per-query intervals.** Every rule, filter and host query records `started_utc`, `ended_utc` and
   `elapsed_ms` of its own. The document's single `utc` is the document's instant and never stands for
   the observation time of a sequential query inside it.
3. **Numeric validation.** `Test-NumbersAreNumbers` named the eight integral CLR types explicitly
   instead of merely rejecting strings -- a boolean or a datetime would have passed the earlier form.
   Raw `type` evidence stays in the document regardless of the control's verdict.

Also removed: the `provider_creation` pointer to `Description`/`InstanceID`. **Neither is a creation
timestamp.** The setup interval is the creation record, and no instant is invented between its bounds.

## 2. Five defects the exercises caught -- four of them mine

Listed because each is the kind of thing that reads as a pass when unexercised.

1. **Array double-wrap.** `$rec['value'] = ,@($out.ToArray())` -- a comma guard against pipeline
   collapse. Assignment to a hashtable key does not unroll, so the guard double-wrapped and `[5,20]`
   serialized as `[[5,20]]`. Caught by the serializer check, run 1, native exit 1.
2. **A case-insensitive variable collision.** A local `$props` inside `Get-ObjectProps` silently
   overwrote the `[string[]]$Props` PARAMETER -- PowerShell variable names are case-insensitive -- so
   the loop iterated the map it was building. Renamed `$propMap`.
3. **The write-failure path could not report its own code.** `Write-Error` under
   `$ErrorActionPreference='Stop'` THROWS, so `exit 2` never ran and the capture exited 1 on the one
   path whose exit code is its entire report. Now writes stderr directly.
4. **`exit` in a dot-sourced script does not end the process.** The harness read 0 for a failed write
   and the failure path looked like a success. The stub now invokes the capture with `&` and
   propagates `$LASTEXITCODE` deliberately.
5. **Helpers returning their own output.** `Invoke-Case` and the wrapper's `Read-ReceiptExit` used
   `Write-Output` and therefore returned ARRAYS: a verified teardown reported `CLEANUP=PENDING` and an
   exit-code assertion silently stopped being about the exit code. The wrapper's `Add-Line` now writes
   to the console only, and the checkers refuse an array predicate as `MALFORMED_PREDICATE` rather
   than coercing a non-empty array to `$true`.

Two more, in the harnesses rather than the instrument, kept because they are the same traps:
a **statement-expression unrolled a singleton array** in the stub (`$enf = if (...) { [uint16[]]@(1) }`),
which is the very collapse the document check exists to detect -- it bit the harness first; and
**`powershell.exe -File` flattens arrays and drops quoting**, so a two-element `-RuleName` arrived as
two tokens ("A positional parameter cannot be found") and a residue-identity string landed in
`-TeardownReceipt`. Both call sites now use in-process `&` with a real splat.

## 3. Mutation audit — static, with a live positive control

`receipt-mutation-audit.txt`, 2026-09-13T00:30:37Z. Pattern: `New-/Set-/Remove-/Enable-/Disable-/
Rename-NetFirewallRule`, `Set-NetFirewallProfile`, `Stop-Process`, `Start-Process`, `netsh advfirewall`.

| arm | result |
| --- | --- |
| **positive control** — the product's own rule-writing source at the subject sha (`85f84d73:crates/spt-daemon/src/bootstrap_firewall/windows.rs`, extract rc 0, 107205 bytes) | **5 hits** (`New-NetFirewallRule`, `Remove-NetFirewallRule`) — the pattern is live |
| negative control — `ZZZ-NoSuchCmdlet` | 0 |
| all six instruments (`d2_capture`, `d2_wrapper`, and the four checkers) | **0 each** |

**A first attempt at this audit read a control path that does not exist on `main`** (the file lives
only on the candidate branch) and printed `control=` with an empty hit count beside two clean subject
zeros. That is the shape of a fabricated pass; it was caught because the control's own count was
printed rather than assumed. The control is now extracted from the subject sha by `git show`.

## 4. ASCII-only, and why it is a control rather than a style choice

The first exercise attempt failed to parse under `powershell.exe` with *"The string is missing the
terminator"*. Cause: the files were UTF-8 without BOM and **Windows PowerShell 5.1 reads an unmarked
`.ps1` as ANSI**, so em dashes arrived as mojibake inside string literals. Both instruments are now
**ASCII-only** (8 and 6 non-ASCII characters removed respectively, 0 remaining). The field leg's
interpreter is `powershell.exe`, so this is the binding edition, not a preference.

Also fixed in the same pass: `$PSScriptRoot` is **not bound during param evaluation** under 5.1
`-File`, so the default subject path is resolved in the body instead.

## 5. Integration — the executable wrapper (`d2_wrapper.ps1`), exercised

**This is a wrapper, not prose about one.** It sequences the unelevated capture points, keeps every
native exit in its own file, writes the timeline rows and the run ledger, and derives its two
verdicts from evidence on disk. It issues **no handoff, performs no elevation, creates and removes no
rule** — setup and teardown are elevated legs with their own nonces, and the wrapper CONSUMES their
receipts. A wrapper able to fake either could report a clean run without one having happened.

Its own exit is 0 when the sequence ran and the ledger was written — **never a statement that the run
was green**; the verdicts live in the ledger, exactly as a capture's property states never live in
its exit code.

Exercised in four scenarios (`receipt-wrapper-check-wps51.txt`, pass=23 fail=0, native exit 0):

| scenario | asserted |
| --- | --- |
| **A** complete run | both documents written, `d2-t2.exit`/`d2-t3.exit` each in its own file, four timeline rows, `CAPTURE_CONTRACT=SATISFIED`, `CLEANUP=VERIFIED`, and `setup=0 t2=0 t3=0 teardown=0` reported as four separate values |
| **B** t1 omitted from the seq-001 request | `D2_T1=UNAVAILABLE`, `CAPTURE_CONTRACT=NOT_SATISFIED`, unelevated points still ran, cleanup still judged independently — **red control: it must not report SATISFIED** |
| **C** teardown unverified | `CLEANUP=PENDING`, residue identity reported, the receipt's absence named — **red control: it must not report VERIFIED** |
| **D** the capture itself fails to write | `d2-t2.exit` records **2**, contract NOT_SATISFIED, and the wrapper still writes its ledger |

### The mechanics it implements

**Exit statuses stay separate, at every point.** The capture writes its native exit to its own file;
the setup command writes its own. Neither is inferred from, overwritten by, or substituted for the
other. `d2_capture.ps1` therefore exits **only** 0 (document written — a document full of
`DENIED`/`ERROR` states is still a capture), 2 (could not write), 3 (arguments rejected). **No property
state is ever an exit code.**

| step | who | artifact | exit recorded to |
| --- | --- | --- | --- |
| seq-001 setup (elevated, liam) | handoff | `001-setup-command.{out,err}` | `001-setup-receipt.txt` |
| **t1 capture, inside the same elevated leg** | handoff | `d2-t1.json`, `d2-t1.json.b64` | `d2-t1.exit` — **its own file** |
| t2 capture at the refusal, beside D1, **before any teardown** | rig | `d2-t2.json(.b64)` | `d2-t2.exit` |
| t3 capture immediately before the teardown request is written | rig | `d2-t3.json(.b64)` | `d2-t3.exit` |
| seq-002 teardown | handoff | `002-teardown-command.{out,err}` | `002-teardown-receipt.txt` |

Invocation, identical at all three points but for `-CapturePoint` and `-OutPath`:

```
powershell.exe -NoProfile -ExecutionPolicy Bypass -File <bin>\d2_capture.ps1 `
    -CapturePoint t2 -RuleName <name>,<name>-lan -Port <port> `
    -IsolatedHome <isolated-home> -RunId <run-id> -OutPath <run>\d2-t2.json `
    -ControlRuleName <the standing 5470 guard rule> `
    -PreSetupAbsence "<the measured pre-setup absence line>" `
    -SetupIntervalStart <utc> -SetupIntervalEnd <utc>
```

**Timeline rows**, on the same UTC clock as every other leg — closing r10's gap, where D1 had no
`START`/`END` at all and two observations of `[5,20]` were bounded only by artifact mtimes ~109 s
apart:

```
<utc> d2_t<N>_START
<utc> d2_t<N>_END rc=<native exit>
```

**t1 must be named in the seq-001 request text.** One nonce is one execution: an elevated read cannot
be added after the leg returns without a second handoff and a second authorization. **If it is
omitted, t1 is UNAVAILABLE — a labelled evidence gap, not a pass.** The rig then emits
`D2_T1=UNAVAILABLE` and `CAPTURE_CONTRACT=NOT_SATISFIED`, no downstream conclusion may rest on the
missing observation, and the run proceeds with unelevated points only.

**Preservation:** each document is written **binary, UTF-8 no BOM, LF**, with a `.b64` sidecar, and
hashed into `MANIFEST.sha256` **before teardown**. The instruments are hashed into
`INSTRUMENTS.sha256` beside r10's existing five. The r10 archive itself is not regenerated or
amended.

**Teardown and cleanup** follow the contract's §6 verbatim. Three mechanics are the rig's:
`CLEANUP=PENDING` is a reported terminal value beside verified-clean and is never earned by a clock;
a fresh pid-naming authorization is a **stop**, not a step (stop experimental activity, preserve
identities, report pending); residue is reported as pid + `ExecutablePath` + `CommandLine`.
`DAEMON_STOP_REFUSED` is expected (IR-122) and is reported, never worked around.

## 6. Amendments owed to the spec document

Two claims in `docs/297-CAPTURE-INSTRUMENT-SPEC.md` @ `8eeec9ee` are superseded and will be corrected
**by replacement, not annotation**, in its next commit:

1. **The four-state encoding (MEASURED / NULL / ABSENT / DENIED) is withdrawn.** doyle's encoding
   replaces it: `PRESENT` with type and cardinality preserved, `ABSENT`, `DENIED` (explicit
   access-denied error only), `ERROR` (any other query failure), plus rule-level `NO_RULE`. A present
   empty string, a present empty array and a present null are three different facts and none may
   collapse into another.
2. **The startup-dominance claim is withdrawn.** I wrote that r10's 1747–2253 ms query legs were
   dominated by process start and module load. **Nothing decomposed those figures** — I offered an
   explanation of a measurement as though it were the measurement. One process per capture point
   stands on the instant-and-cardinality argument alone, and **the contract's elapsed-time bounds
   remain binding**.

## 7. Still unexercised, and named as such

- **Every host-facing path has only ever met a STUB.** `Read-RuleRecord`, `Read-FilterRecords`,
  `Read-HostState`, `Read-EnumerationControl` and `Test-Elevated` have been exercised against
  synthetic objects that *I* wrote — so what is proven is that the capture reads, encodes, writes and
  reports correctly **given a provider that behaves as my stub does**. A real provider may differ in
  exactly the ways this investigation is about, and no stub result is evidence about the host.
- **No elevated capture has ever run**, and t1 is the only elevated read the design has.
- The **rule-level positive and negative controls** have never met a real firewall store.
- The **`DENIED` path has never been produced by an actual access denial** — only by a stub throwing
  a matching message. `Test-Denied` matches on error text, which is the weakest part of the
  instrument and is the thing a real unelevated probe would settle first.
- Prerequisites, all outstanding and separate: fresh elevation, fresh residual-process
  authorization, and admission of the complete setup/capture/teardown request plus the
  residual-cleanup path.
