# Statement of Work — Automating the sneakernet Test Plan

**Status:** Approved scope, not yet built. **Author:** prepared with Claude Code, 2026-08-06. **Target host:** the test host. **Source plan:** `TESTING.md` (1,484 lines, 19 Parts, ~60 Steps, ~7 hours manually).

---

## 1. Purpose

`TESTING.md` is executed by hand. That pass has proven its worth — it found real defects in three consecutive releases this week (v1.10.9 through v1.10.11) — but it is slow, tiring, and inconsistent between runs. Steps depend on shell variables set several code blocks earlier, on a `cd` issued once in Step 0.6 and never repeated, and on ~25 configuration edits described only in English prose. A human running it at hour six does not reliably reproduce what they did at hour one.

This SOW specifies a **committed, reusable harness** that executes the plan deterministically, so that this pass and every future pass is push-button, auditable, and directly comparable to the last.

**The deliverable of this document is the specification only.** Building the harness and running it against the test host are separate, sequential approvals.

---

## 2. The governing risk

The test host is a **production backup server**. This is not a lab machine, and the sandbox is not on a separate pool:

```
storage  19.2T  ── storage/backup    6.53T   ← REAL PRODUCTION BACKUPS
                └─ storage/testing   8.81M   ← our sandbox
```

The test sandbox is a sibling of 6.53 TB of live backup data on the same pool. A mistyped `zfs destroy` does not fail safely — it destroys backups, and the recovery is the owner rebuilding the server over a period of days.

**Every design decision in this document resolves in favour of safety over speed, convenience, or fidelity to the letter of `TESTING.md`.** Where the plan as written is unsafe to automate, this SOW says so and leaves it manual.

---

## 3. Verified environment

All facts below were confirmed by read-only inspection of the test host on 2026-08-06. They are recorded because several contradict reasonable assumptions, and the design depends on them.

| Fact | Detail | Consequence |
|---|---|---|
| OS / Perl | FreeBSD 14.2-RELEASE-p1, perl 5.36.3 | — |
| Code under test | svn r165 at `/storage/testing/code`, byte-identical to the workstation for `sneakernet`, `ZFS_Utils.pm`, `TESTING.md` | No drift to reconcile before starting |
| Versions | `sneakernet` 1.10.11, `ZFS_Utils.pm` 1.7.1 | Meets the Step 0.2 floor |
| **Production cron IS ACTIVE** | `/usr/local/etc/cron.d/replicate` runs `replicate` daily at **04:03**; confirmed in `/var/log/cron` every day through Aug 5 | Hard guard window, §4 rule 5 |
| **`YAML::XS` absent** | Only `YAML::Tiny` 1.74. `ZFS_Utils::loadConfig` falls back to it | Config patcher must not round-trip, §7 |
| **`xxd` absent** | Perl `unpack("H*")` substitutes | Already fixed in `TESTING.md` Step 7.2c |
| Modules present | `JSON::PP` 4.07, `Digest::SHA` 6.02, `Cwd`, `File::Path`, `File::Copy`, `POSIX`, `Time::HiRes`, `Getopt::Long`, `Storable`, `Fcntl`, `Data::Dumper` | Sufficient; nothing to install |
| Modules absent | `Text::Diff`, `python3`, `flock` | Not needed — see §4 |
| SVN reachable | `svn info http://svn.dailydata.net/...` succeeds from the host | **Part 17 can genuinely run** |
| Mail | `/usr/sbin/sendmail` → `mailwrapper` | Part 13.3 can run |
| Production install | `/usr/local/opt/zfs_utils` exists and is what the cron runs | Strictly off-limits, §5.1 |

### 3.1 Three findings in the code that change the design

**(a) `ZFS_Utils::loadConfig` silently rewrites the whole config if it ever contains `<` or `>`.** `ZFS_Utils.pm:745` sets `$isDirty = ($configString =~ /<[^>]*>/)`, and the file is then re-dumped through `makeConfig`, destroying comments and key order behind the harness's back. This matters directly: Step 13.3 sets an email address, and the natural form `Rodo <rodo@dailydata.net>` would trigger it.

**(b) The plan's ~20 `rm -f serial.txt` commands are no-ops.** `sneakernet:2503` resolves `serialFile` against `transport.mountPoint`, not the working directory. The file that actually gets deleted is always the one removed by the accompanying `rm -rf /storage/testing/transport/*`. A harness that faithfully transcribed `rm -f serial.txt` would be reproducing a latent bug in the test plan rather than testing anything.

**(c) `sneakernet` locates its own config via `$FindBin::RealBin`,** not the working directory. The *program* is cwd-independent; only the plan's shell commands are cwd-sensitive.

---

## 4. Constraints

These are hard rules on the harness and on anyone operating it.

1. **Sandbox boundary.** The harness may read, create, modify, and destroy only within the ZFS namespace `storage/testing/…` and the filesystem namespace `/storage/testing/…`. Nothing else, ever.
2. **No software installation.** The harness uses only what §3 confirms present. `Text::Diff` is the sole gap and is not required — its one use (a bounded line-change count) is a few lines of pure Perl. **No `cpanm` invocation is part of this work.**
3. **No use of `/tmp`.** Every scratch artifact the plan places in `/tmp` relocates to a new dataset `storage/testing/tmp`. This is not cosmetic: it keeps all test residue inside the one namespace we are permitted to destroy.
4. **No production changes.** No new system users, no `sysctl` changes, no `zfs allow` delegation, no edits under `/usr/local/opt/zfs_utils`, no touching the cron configuration. (See §6.7 for a security measure explicitly rejected on these grounds.)
5. **Cron guard.** The harness refuses to start between **03:45 and 05:00** and warns if a run in progress approaches that window.
6. **Part 19 is never executed by the harness** and is made structurally impossible to express (§6.2). Teardown is manual, §11.
7. **The Part 17 artifact is never executed.** `buildUpgradeOneShot.pl` produces a script designed to overwrite a real sneakernet installation, and this host has one. It is syntax-checked with `perl -c` and nothing more; its basename sits on a hard execution deny-list.
8. **GELI and physical-media paths stay out of scope**, as `TESTING.md` already specifies, and are additionally blocked at the config containment gate (§6.5).
9. **Step 0.1 deploys code by `svn`, never by file copy.** See §9.3. A raw `rsync`/`cp` of a directory tree is its own category of accident (a misplaced trailing slash changes the destination), and it produces no record of *what* was deployed. `svn update` is atomic, versioned, and self-describing. File copy is permitted only as an explicit fallback if subversion is unavailable on the host — it is available here (`svn` 1.14.5).

---

## 5. Architecture overview

Three components, each specified below:

| Component | Role | Scope |
|---|---|---|
| **`Harness::Safe`** (§6) | The only module permitted to `fork`, `exec`, `unlink`, `rename`, or open a file for writing. Enforces the sandbox. | Test-suite specific — deliberately so (§6.3) |
| **`YAMLPatch.pm`** (§7) | Path-addressed, formatting-preserving YAML editor. Applies the ~25 prose config mutations. | **General-purpose, standalone** — reusable outside this project |
| **Step catalogue** (§8) | The 19 Parts expressed as JSON data rather than code. | Specific by definition — it *is* the test plan |

### 5.1 Where everything lives, and the round trip

The harness runs **on the test host**, not on the workstation. It is deployed there by `svn update` — the same mechanism used for code fixes — so there is no per-command SSH round-trip and no ad-hoc file copying.

| Artifact | Location | Notes |
|---|---|---|
| Harness source | repo `sneakernet/testing/` → host `/storage/testing/code/sneakernet/testing/` | Arrives via `svn update`, versioned and reviewable |
| **YAML patcher** | repo `YAMLPatch.pm` + `YAMLPatch.md` (top level) | Beside `ZFS_Utils.pm`, per existing convention; consumers use `use lib "$FindBin::Bin/.."` |
| **Patcher CLI** | repo `utilities/yamlpatch` | Shell-callable front end to the same module |
| Code under test | host `/storage/testing/code/` | An SVN working copy, currently r165 |
| Run artifacts | host `/storage/testing/harness/runs/<runid>/` | Audit JSONL, captured `sneakernet` output, config store |
| **Test results** | host `…/runs/<runid>/TESTING.log`, retrieved to repo `sneakernet/Documentation/TESTING.log` | The deliverable report |

The full loop:

```
  WORKSTATION                              TEST HOST
  ───────────                              ─────────
  1. build/fix harness
  2. svn commit          ──────────▶
                                     3. svn update /storage/testing/code
  4. ssh <test host> <harness> ────▶
                                     5. harness runs; writes artifacts to
                                        /storage/testing/harness/runs/<runid>/
  6. retrieve TESTING.log ◀────────
     → sneakernet/Documentation/TESTING.log
  7. review results; if fixes needed, back to 1
```

Two deliberate choices here:

**Run artifacts do not go into the SVN working copy.** `/storage/testing/code` is the thing under test and gets `svn update`d between runs; writing generated output into it creates untracked noise and risks conflicts. Artifacts live on the separate `storage/testing/harness` dataset, which is additionally absent from every destructive allow-list (§6.3), so no test step can delete its own audit trail.

**Only the final report is copied back.** After a run I retrieve `TESTING.log` to `sneakernet/Documentation/TESTING.log` on the workstation for review and commit. The full artifact set stays on the host, referenced by run id, and is available if a failure needs deeper investigation.

---

## 6. Component 1 — the safety guard

### 6.1 Structural, not a command scanner

The harness **never constructs a shell command string. There is no `/bin/sh` anywhere in its process tree.** Every side effect goes through a small catalogue of Perl primitives that accept already-validated, typed arguments and `exec` an argv list.

This is a deliberate rejection of the more obvious design — scanning command strings for dangerous patterns before running them — for reasons worth stating plainly:

- `rm -rf $DIR/*` is safe or catastrophic depending on the *value* of `$DIR`, which a scanner inspecting the string does not have.
- `zfs destroy storage/testing/src/ds1@$S2NAME` is only knowable after expansion, and validating after expansion means validating a string you are simultaneously handing to the shell.
- Command substitution (`$(zfs list … | grep … | sed …)`) makes the argument set unbounded by construction.
- A `for` loop turns one scanned string into N executions against changing state.
- Any scanner robust enough to handle the above is a shell parser. Writing a correct shell parser to protect 6.53 TB of backups is a worse bet than arranging not to need one.

Structural construction also produces the audit ledger for free: the arguments are already data, so recording exactly what was attempted requires no extra machinery.

### 6.2 Type constructors

Two functions gate every argument:

- **`ds($name)`** — rejects undef, empty, illegal characters, `..` components, empty path components, a trailing `@` (the exact shape an empty `$S2NAME` produces), and more than one `@`. It then requires a match on `^storage/testing/` — **note the trailing slash**. This makes the bare string `storage/testing` structurally impossible to pass as an argument, which means **Part 19's `zfs destroy -r storage/testing` cannot be expressed by the harness at all.**
- **`path($p)`** — requires an absolute path under `/storage/testing/`, rejects `..`, glob metacharacters, and whitespace, then resolves the deepest existing ancestor with `abs_path` and re-checks containment, catching symlink escapes.

A refusal writes a `REFUSED` record to the audit log *before* dying, and prints a banner naming the step and the offending value. A refusal must be impossible to miss in a long run's scrollback.

### 6.3 Per-primitive allow-lists

Type checks are generic; the real leverage is that the plan's destructive surface is small and fully enumerable. Every `zfs destroy` in all 1,484 lines matches one of two patterns:

```perl
our @DESTROY_ALLOW = (
    qr{^storage/testing/(?:src|dst)/ds[0-9]+(?:\@[A-Za-z0-9_.:-]+)?$},
    qr{^storage/testing/verify(?:/[A-Za-z0-9_.-]+)?$},
);
```

Additional rules:

- **Multi-argument destroy is unsupported.** The plan's `zfs destroy -r .../ds3 .../ds4` becomes two separate calls. Multi-argument destroy is where a partially-empty argument list becomes "destroy the first one and then whatever the shell left."
- **`zfs receive` gets no primitive.** The harness never issues one; every receive in the plan happens inside `sneakernet` or `checkSneakernetFile`. That is precisely why §6.5 exists.
- **No globs, ever.** `rm -rf dir/*` becomes `empty_dir()`, which `lstat`s every entry, never follows a symlink, and refuses if a child dataset is mounted beneath. `rm -f foo.*` becomes `rm_matching()` with an anchored regex applied to the *basename only*. Both are no-ops on zero matches, rather than the shell's habit of passing a literal `*` through.
- **`dd` does not exist as a primitive.** Step 16.2's corruption becomes `corrupt_file(path, offset, length)` using `sysseek`/`syswrite`, with an assertion that the write cannot extend the file. There is no `of=` to get wrong and no `/dev/*` to target by accident.
- **One real pipeline.** Of all the plan's pipelines, only Step 8.5's `zfs send | openssl enc > file` is irreducible; it gets a dedicated `fork`/`pipe` implementation with per-stage argv validation against an absolute-path binary allow-list. The rest dissolve: `| tee file` becomes captured output the harness writes itself, `grep` becomes an assertion over a buffer, `yes | head >` becomes a write loop.

### 6.4 Neutralising the plan's three structural hazards

**Relative paths / cwd.** Not mitigated — *definitionally absent*. No primitive accepts a relative path, because `path()` requires a leading `/`. A `getcwd` assertion around each primitive exists only to catch a future maintainer, and to catch `sneakernet` itself chdir-ing (it does not today, but Parts 14 and 15 make it `eval` arbitrary cleanup scripts).

**Empty-variable expansion.** Defended three times over: the `capture` action that produces `S2NAME` declares `expect_count: 1` and fails the step if the match count is anything else; `ds()` rejects undef and empty; and `ds()` separately rejects a trailing `@`. A zero-row capture means the test's premise is broken, and that is where the failure should surface — not in a degenerate destroy.

**Mount-namespace escape.** This is the guard's genuine blind spot: `path()` validates the *lexical* path, but if some dataset outside the sandbox were mounted at `/storage/testing/report`, a lexically valid delete would hit production. A preflight runs at startup and before every Part, asserting that every sandbox dataset mounts at its canonical location, that no foreign dataset mounts inside the sandbox, and that no non-ZFS mount is grafted in.

### 6.5 Config containment gate — the most important check here

**The harness's most dangerous operations are not the ones it issues.** `zfs receive -F`, and the destroy-and-retry path that Part 7.2b deliberately enables with `allowFullOverwrite: 1`, are issued *by sneakernet*, driven entirely by config values, with the harness holding no argv to validate. If `target.poolname` ever read `storage/backup`, sneakernet would receive over production and — with `allowFullOverwrite: 1` set — destroy an existing dataset to make room. That is the rebuild-the-server scenario, reached without the harness ever running a destructive command.

Therefore the config on disk is re-validated **immediately before every single `sneakernet` invocation** — not once per Part, because a preceding step may have mutated it and because Part 15 lets the program rewrite it. The gate refuses to exec if:

- any of `source.poolname`, `target.poolname`, every `datasets.*.source`/`.target`, or `transport.verifyFullSendDataset` falls outside `storage/testing/`;
- any of `logFile`, `statusFile`, `historyFile`, `cleanUpScriptsDir`, `oneShotCleanup`, `targetSnapshotList`, `stateFile`, or any `mountPoint` falls outside `/storage/testing/`;
- **`transport.label` or either `targetDrive.label` is non-empty** — sneakernet would go looking for a *physical* drive to mount;
- **a `target.geli` block is present** — reaches the GELI code the plan explicitly excludes;
- **`shutdownAfterReplication` is set** — would power off the backup server;
- the file contains `<` or `>` — `loadConfig` would silently rewrite it (§3.1a).

The last four are not in `TESTING.md`'s threat model at all. Each is one config typo away, and the config is about to be mutated ~25 times by an automated patcher.

### 6.6 Production tripwire

A fingerprint of `zfs list -H -o name -t all -r storage/backup` (plus cron state and `pgrep replicate`) is taken at startup and re-compared **after every Part**. Any change is a hard stop with a loud banner, no auto-continue and no auto-remediation — if production changed, a human looks at it before anything else runs. Fingerprinting the cron state separately means that if the replicate job is re-enabled or fires unexpectedly mid-run, we learn that immediately instead of spending an hour concluding the harness ate something.

### 6.7 Rejected: dropping root via `zfs allow`

A delegated unprivileged user (`pw useradd` + `zfs allow` + `vfs.usermount=1`) would move enforcement from Perl into the kernel, which is genuinely stronger. **It is rejected** because creating a system account and changing a `sysctl` on a production backup server is itself a production change, contrary to §4 rule 4. Recorded here so the tradeoff is visible: the Perl layer is consequently the only enforcement, which is exactly why §6.3's allow-lists are narrow regexes over a closed set rather than a general "is it under the sandbox" test.

### 6.8 Execution modes

| Mode | Behaviour |
|---|---|
| `--explain` | Static render of every primitive and its validator. Executes nothing, connects to nothing. Diffable across catalogue edits; read this before the first run. |
| `--dry-run` | Read-class primitives execute for real, so captures resolve and the rendered destructive commands are *exact* rather than placeholders. Mutating and destructive classes are simulated. |
| `--confirm-destructive` | Real execution, pausing before each destructive primitive with the exact argv and validator verdict. Intended for the first live pass of Parts 7, 9, and 18. |
| `--run` | Unattended. |

Every primitive carries a mandatory `class` (`readonly` / `mutating` / `destructive`); one without a class refuses to run. Selectors `--parts`, `--steps`, `--from` are available, and the runner refuses a selection that omits a declared dependency unless `--force-partial` is given.

### 6.9 Audit log

Append-only JSONL at `/storage/testing/harness/audit/<runid>.jsonl`, one record per primitive: timestamp, run id, step, sequence, class, primitive, arguments, verdict, matched rule, exit status, elapsed time, output digest. It lives on a dataset absent from every allow-list, so the destructive primitives cannot target it, and it is the source for the final summary sheet.

The 64-hex transport key is **redacted on write**, since Steps 7.2c and 8.5 pass it in argv. Step 13.2 — whose entire purpose is asserting the key is *absent* from `sneakernet.log` — reads raw under an explicit flag and, on failure, reports the line number **without echoing the line**.

---

## 7. Component 2 — `YAMLPatch.pm`, a general-purpose YAML editor

This is the one component built as a **standalone, reusable module** rather than as harness internals. It has no dependency on the harness, the test plan, sneakernet, or ZFS, and is intended for reuse in this repo and elsewhere.

It has a second consumer inside this project today: **`cleanupScripts/updateConfigKeys` currently rewrites `sneakernet.conf.yaml` via a full `YAML::Tiny` dump**, which re-sorts keys alphabetically, strips comments, and re-quotes every scalar — the damage Part 15.2 has to recover from (§7.4). That is shipped production code, not just a test artifact. Converting it to `YAMLPatch.pm` would make config rotation non-destructive, and is a natural follow-up once the module is proven. *(Worth confirming against the source before acting on it — noted here as an opportunity, not a committed change.)*

**Deliverables:** `YAMLPatch.pm` and `YAMLPatch.md` at repo top level beside `ZFS_Utils.pm`; `utilities/yamlpatch` as a shell-callable front end; and a standalone test suite.

**Dependencies:** core Perl only. `YAML::Tiny` is used *if present* for the optional cross-validation gate (§7.3 gate 2) and is not required for the module to function — so it drops cleanly into hosts that lack it.

### 7.1 Why not YAML::Tiny round-trip

Loading, modifying, and re-dumping through `YAML::Tiny` is disqualified on four counts:

1. **Comments are lost.** Not hypothetical — the live config carries `#maxDelta: 0.9` on ds1 and ds2 right now, and that is state the harness must see and manipulate.
2. **Keys are re-sorted alphabetically.** This is a *test-correctness* problem, not an aesthetic one: **Step 2.1 is `grep -A2 '^transport:' | head -3` and Step 3.1 is `grep -A3 'targetDrive:' | grep -A2 'label'`.** Both assert on physical adjacency of lines. Re-ordering the file makes them pass or fail for reasons unrelated to what they test.
3. **`'off'` is emitted bare as `off`,** which is a YAML 1.1 boolean. Harmless under YAML::Tiny, but the day anyone installs YAML::XS — which `loadConfig` *prefers* — `transport.compression.method` silently becomes false instead of `'off'`.
4. **Flow-style sequences become strings.** Round-tripping `helloWorld: [1,2,3]` yields the string `'[1,2,3]'`, which is the exact defect fixed in v1.10.9.

### 7.2 Design

A **line-oriented, indentation-walking editor**. It does not build a YAML document model and re-serialise it; it locates the one line owning a given path and rewrites just that line's value span. Everything it does not touch is preserved byte-for-byte — which is the entire point, and what no round-tripping parser can offer.

**Strict by default, with opt-in relaxation.** The parser hard-fails on tabs, indentation not a multiple of two, flow collections, block scalars, anchors/aliases/tags, multiple documents, duplicate sibling keys, or any line that is not blank / comment / `key:` / `key: value` / `- item`. Encountering one of these stops the operation; it never guesses.

Callers who need more can opt in per-construct (`allow_flow`, `allow_multidoc`, …) where the semantics remain unambiguous — flow collections, for instance, can be located and replaced wholesale even though their *contents* are not path-addressable. **The harness passes no relaxation flags**, so it gets the strict behaviour its safety argument depends on without having to ask for it, while general use elsewhere isn't boxed in.

This is tractable precisely because the scope is bounded. A general YAML *editor* is hard; a path-addressed line editor for block-style documents is a few hundred lines and is exhaustively testable — and it fails loudly at the boundary instead of silently mangling.

**All addressing is by full dotted path** — `transport.compression.method`, never a bare key. This is the complete answer to `mountPoint` appearing three times and `label`, `email`, `subject`, and `poolname` twice each. There is no `sed`, so there is nothing for a duplicate key name to fool.

**Comment lines are parsed and path-aware.** A commented-out `#maxDelta: 0.9` is *seen*, with the path it would have. Consequently `set()` on a path that exists only as a comment **dies** rather than appending a silent duplicate that YAML would resolve unpredictably and no human reading the file would expect. `comment_out()` / `uncomment()` implement Step 5.2's "delete or comment out the ds2 block" reversibly, which is both what the plan asks for and what produces a readable diff.

Operations: `get`, `set`, `delete`, `comment_out`, `uncomment`, `insert_kv`, `insert_block`, and a privileged `append_raw` (used here only by Step 15.3, and gated — §7.4).

**Quoting is explicit, never inferred.** `style => 'bare' | 'single' | 'double' | 'keep'`. This matters more than it looks: `off`, `on`, `yes`, `no`, `y`, `n` are YAML 1.1 booleans, so a patcher that "helpfully" emits `method: off` plants a bug that only detonates when someone installs a stricter parser. For the sneakernet config specifically: `single` for `compression.method` and `debug`; `bare` for `statusFileBackups` and `maxDelta`, whose assertions read them as numbers.

### 7.3 The commit gate

No mutation writes the target file directly. Each writes a temp file in the same directory and must clear every gate before an atomic `rename`. Gates 1–5 are built into the module; 6 and 7 are **caller-supplied hooks**, because a general-purpose module cannot know an application's semantics:

| # | Gate | Provided by |
|---|---|---|
| 1 | The module's own parser round-trips the result | module |
| 2 | An independent parser accepts it (`YAML::Tiny` if available — the parser the program under test actually uses) | module, optional |
| 3 | The semantic delta **equals** the declared intent | module |
| 4 | The textual line-delta is within a declared bound — catches indentation damage that still parses | module |
| 5 | No construct outside the permitted subset was introduced | module |
| 6 | Application-specific content validators | **caller hook** |
| 7 | Application-specific containment / safety validators | **caller hook** |

Gate 3 is worth dwelling on: the delta must *equal* the declared intent, not merely be a subset of it. A mutation that changes **fewer** things than declared is also a bug — for example a `set()` that silently resolved to a commented-out line and did nothing.

The harness registers three hooks: no `<` or `>` anywhere (§3.1a — `loadConfig` would rewrite the file behind us); block-style lists are still `ARRAY` refs after the edit; and **the safety guard's containment gate (§6.5) passes on the new file.**

That last hook is the load-bearing link between the two components: **the patcher cannot produce a config the safety guard would refuse.** A patcher bug that writes `target.poolname: storage/backup` never reaches disk. Expressing it as a hook rather than baked-in logic is what keeps the module reusable while leaving the harness's safety property fully intact.

### 7.3.1 Command-line front end

`utilities/yamlpatch` exposes the same operations to shell scripts and non-Perl callers:

```sh
yamlpatch get    transport.compression.method  file.yaml
yamlpatch set    transport.compression.method  "'xz'"  file.yaml
yamlpatch delete datasets.ds1.maxDelta                 file.yaml
yamlpatch comment-out datasets.ds2                     file.yaml
```

It exits non-zero on any refusal, supports `--dry-run` (prints a unified diff, writes nothing), `--backup`, and the `--allow-*` relaxation flags. Because it is the same code path as the API, behaviour cannot drift between the two.

### 7.4 Harness-side usage — snapshot, restore, and the two adversarial recoveries

*Everything above this point is the standalone module. What follows is how the harness **uses** it, and is not part of `YAMLPatch.pm` — the snapshot store, ZFS snapshots, and Part 15 recovery logic all live in the harness.*

Config states are stored content-addressed under `/storage/testing/harness/confstore/<sha256>` with an append-only ledger; nothing is ever deleted. A restore is a copy followed by a sha256 re-verify of the destination — a restore that does not verify is not a restore. Before each Part, `zfs snapshot storage/testing/code@harness_pre_partNN` gives cheap file-level recovery via `.zfs/snapshot/`. **`zfs rollback` is never used** — too blunt, and it would silently discard anything else written since.

Each step takes a pre-snapshot and restores in an unconditional `finally` (on assertion failure, exception, or `SIGINT`). Steps that deliberately carry config state forward declare `restore: none` — the 4.2→4.4 chain, 8.1→8.5, 10.1→10.4, and 6.1→6.2.

**Part 15.2 — the program rewrites the config.** `updateConfigKeys` performs its own full YAML::Tiny dump, leaving the file alphabetically sorted, comment-free, re-quoted, plus a `.bak.<ts>` sibling. The harness must not try to patch that back into shape. Sequence: assert the post-conditions (`debug` is `'7'`, exactly one backup exists and contains `debug: '0'`, no `.tmp.$$` remains); archive the rewritten text as evidence; **re-baseline wholesale** rather than attempting a structural merge; sweep the `.bak.*` files into the store. The pre-existing `sneakernet.conf.yaml.original` and `sneakernet.conf.yaml~` on the host are swept the same way, so they cannot confuse later `ls sneakernet.conf.yaml*` assertions.

**Part 15.3 — deliberate corruption.** `append_raw` is the one operation that bypasses the commit gate, and it refuses unless the step declares `expects_invalid_config: true`. The runner additionally refuses to schedule such a step unless the config was valid and gate-passing immediately beforehand, a verified snapshot exists, and the step's actions contain **no target-role `sneakernet` invocation** — the target role is the one that receives streams, and an unparseable config is the state in which one least wants the receive path executing. The plan accepts either outcome ("if the source run fails to start, that is acceptable"), so the assertion is a disjunction — but **both branches share a `config unchanged` clause verified by sha256**, which is the thing Part 15.3 actually tests and the clause a hand-written test would forget.

---

## 8. Component 3 — the step catalogue

The 19 Parts become **JSON data**, parsed with core `JSON::PP`. Deliberately *not* YAML: §7.1 establishes that the only YAML parser on this box is unreliable, and writing the test catalogue in that format would invite a harness that misparses its own definitions.

Per-step schema: `id`, `part`, `title`, `classification`, `depends_on`, `exports`/`imports`, `reset` profile, `config` (preconditions, mutations, restore policy, `expects_invalid_config`), `actions`, `assertions`, `cleanup`, `manual_note`.

`classification` drives behaviour: `assert` (normal), `record_only` (capture and mark RECORDED, never FAIL — for Step 4.4's observation and Step 18.1's "if it doesn't abort that's fine"), `optional` (skipped without `--include-optional`), `manual` (prints instructions, refuses to execute).

What this buys:

- **All 36 `/tmp/part*.log` files disappear as a category.** `run_sneakernet` captures both file descriptors and the harness owns log paths under `{TMP}`. No `/tmp` path can be typed anywhere in the catalogue, because `path()` rejects it.
- **Symbolic tokens** (`{TMP}`, `{CODE}`, `{SN}`, `{TRANSPORT}`, `{REPORT}`, `{ONESHOT}`, `{HARNESS}`) expand in exactly one place. This makes Steps 9.2 and 11.1 correct by construction — the inventory path is written *both* as a file and as the `source.targetSnapshotList` YAML value, and a single token means they cannot drift.
- **Cross-step shell variables become `exports`/`imports`** with enforced `depends_on`: `$F`/`$KEY` from 16.1 into 16.2, `BEFORE`/`AFTER` in 7.2a, `UNCOMPRESSED`/`COMPRESSED` in 8.3. Today, running Step 16.2 on its own silently tests `--file ""`; the harness refuses instead.
- **Reset profiles are declared once** rather than copy-pasted into 17 Parts — and delete the *real* `/storage/testing/transport/serial.txt` rather than encoding the plan's no-op (§3.1b).
- **Per-Part postconditions** catch a broken Part at its own boundary. Part 4, for instance, must leave `datasets.ds3`/`ds4` absent, `verifyStream: header`, and the `src/ds3`/`src/ds4` datasets destroyed — so Part 5's "one line for ds1 and one for ds2" cannot fail for Part 4's reasons. `TESTING.md` flags this hazard in prose and expects a human to remember it.
- **Part 10 declares `reset: none` and `depends_on: 9.3`,** making the plan's prose-only ordering constraint enforceable, including the subtlety that 9.4 invalidates 9.3's handoff state.

---

## 9. Baseline, and the `maxDelta` deviation

### 9.1 Establishing the baseline

The current host state has drifted: `maxDelta` commented out on both datasets, `compression.method: 'xz'` left over from Part 8, orphaned `src/ds3` and `src/ds4`, and 94 accumulated snapshots. The harness therefore performs a **full clean rebuild** before Part 1 and refuses to start otherwise: destroy and recreate the scratch datasets, and install a canonical config.

The canonical config is **generated from `TESTING.md` Step 0.5's literal YAML block** — the version-controlled, reviewable source of truth — with the key substituted from `/storage/testing/testkey.txt`. The `sneakernet.conf.yaml.original` file on the host is a hand-made convenience copy, not an authoritative artifact, and is swept aside rather than used.

### 9.2 `maxDelta` — one deliberate deviation

`maxDelta` caused spurious aborts during the manual pass. It needs to be fixed or set correctly, but not in this pass. The decision:

**Parts 1–17 run with `maxDelta` commented out on ds1 and ds2**, rather than the `maxDelta: 0.9` that Step 0.5 literally specifies. Rationale:

- A `maxDelta` breach calls `fatalError`, which **ends the entire source run**. One bad estimate can therefore fail a Part that has nothing to do with size validation, injecting noise across 17 Parts.
- Its only effect when absent is that `validateSizeEstimateHistory` is skipped and `history.tsv` is not written. Nothing in Parts 1–17 reads `history.tsv`; Part 18 is its only consumer.
- **Coverage is unchanged.** Part 18 sets the values it needs: 18.1 sets `0.01` to trigger the abort, 18.2 sets `0.9` and must *not* abort, 18.3 deletes the key entirely.

**Step 18.2 is the diagnostic for the reported bug.** Its expected result is "completes normally, no abort, two lines in `history.tsv`" — so if `maxDelta` is aborting spuriously, that is exactly where it surfaces. The harness captures full evidence there: the generated `zfs send` command, the estimate, the running average, and the history file, so this run yields a proper bug report.

Handling matches Step 8.5: **run it, record the failure with evidence, do not fix in this pass.**

The deviation is exposed as `--baseline-maxdelta=commented|0.9` (default `commented`), is printed in the `TESTING.log` header, and appears in `--explain` output, so no reader mistakes it for stock Step 0.5.

### 9.3 Step 0.1 — code deployment by SVN

`TESTING.md` Step 0.1 says to copy the working-copy tree onto the host. **This SOW replaces that with `svn`,** which is both safer and more informative: `svn update` is atomic, records exactly which revision is under test, and cannot silently deposit files in the wrong place the way a mistyped `rsync`/`cp` trailing slash can.

The host already satisfies this — `/storage/testing/code` is an SVN working copy at r165, and `svn` 1.14.5 is installed — so Step 0.1 becomes **automatable** rather than manual:

```sh
cd /storage/testing/code && svn update          # or: svn checkout <URL> /storage/testing/code
```

The harness performs this as part of preflight and then **verifies**, failing closed if any check does not hold:

- `svn status` reports no locally modified (`M`) or conflicted (`C`) files. Untracked (`?`) entries are expected and fine — they are the test runtime's own artifacts (`sneakernet.conf.yaml`, `sneakernet.log`, `history.tsv`, `sneakernet_target.status`, `states/`). Confirmed clean on the host at time of writing.
- The working-copy revision matches the revision the harness was told to test, so the report cannot misattribute results to the wrong code.
- `perl -c` is clean on `sneakernet` and `ZFS_Utils.pm`.
- `$VERSION` meets the Step 0.2 floor for both files.

`svn update` writes only inside `/storage/testing/code`, so it is within the sandbox and needs no special dispensation.

**Fallback.** If subversion were ever unavailable on a target host, a file copy is permitted instead — but it must then be followed by an explicit manifest verification (per-file SHA-256 against the workstation tree), because a copy provides none of the guarantees above. That fallback is not needed here.

### 9.4 Part 0 self-teardown — a second deliberate deviation

`TESTING.md` documents Part 0 as done "first and only once." The first live `--run` against the test host (§15) showed why that doesn't suit an automated, repeatable harness: any retry of Part 0 (or of the full catalogue) that doesn't happen to start from a hand-cleaned sandbox collides on its own leftover snapshot names from the previous attempt (`ds2@smoke1`, `ds1@part5a`, etc. "already exist"), and every later Part downstream of that collision fails for a reason that has nothing to do with its own logic.

**Implemented as part of the Phase 5 SOW (§16).** Step 0.1 now begins by destroying every numbered scratch dataset it knows about (`{SRC}/ds1`–`ds4`, `{DST}/ds1`–`ds4`, `{VERIFY}`, all `missing_ok`) and clearing `sneakernet_target.status*` and `sneakernet.conf.yaml.bak.*`, before `svn_update` runs. This is narrower than §11's manual `zfs destroy -r storage/testing/{src,dst}` (which destroys the parent containers outright): the harness's own `destroy_dataset` allow-list (§6.3) only ever permits destroying a numbered `ds<N>` child or `verify`, never a bare `src`/`dst` parent — by the same design that keeps every destroy in this harness narrowly, structurally scoped. That's sufficient here: destroying every `ds<N>` child wipes all snapshot history, and `{SRC}`/`{DST}` themselves are left in place for Step 0.3's `zfs create -p` (already idempotent) to no-op against. `{TRANSPORT}`/`{REPORT}`/`{ONESHOT}` are deliberately **not** included — they are plain folders under the folder-based-transport design (§9.3), not ZFS datasets, so `destroy_dataset` doesn't apply to them at all; their contents are already handled by `run-tests`' `{ONESHOT}` bootstrap, `sneakernet`'s own on-demand folder creation, and each later Part's own `standard` reset before that Part runs.

This is a deliberate divergence from `TESTING.md`'s literal wording, made for the same reason as §9.2's `maxDelta` deviation: the manual procedure's assumptions (run once, in strict order, by a human who remembers to clean up between attempts) don't transfer to unattended automation.

---

## 10. Disposition of every Part

| Part | Disposition | Notes |
|---|---|---|
| 0 | Automated as preflight | 0.1 is `svn update` + verification, not a file copy (§9.3). 0.2–0.6 automated. |
| 1–3 | Automated | — |
| 4 | Automated; **4.4 is a run-stopper** | On a leftover scratch child, the harness **halts the run and leaves the dataset in place** — the plan says to report it, not clean it up. The one exception to continue-on-fail. |
| 5–7 | Automated | 7.2b/7.2c exercise `allowFullOverwrite: 1`; containment gate (§6.5) is the safeguard. |
| 8 | Automated; **8.5 expected FAIL** | Known-broken. Run for evidence, record FAIL, **do not fix.** |
| 9 | Automated | 9.3's ending state feeds Part 10; enforced via `depends_on`. |
| 10 | Automated, `reset: none` | Refuses to run unless 9.3 ran and 9.4 did not follow it. |
| 11–14 | Automated | — |
| 15 | Automated | Includes both adversarial config recoveries (§7.4). 15.1 generates `rotateTest` from a template rather than regex-editing Perl source. |
| 16 | Automated | 16.2 imports `$F`/`$KEY` from 16.1. |
| 17 | Automated, optional | SVN is reachable. Generated `upgrade.pl` is `perl -c`'d and **never executed** (§4 rule 7). |
| 18 | Automated; **possible FAIL** | The `maxDelta` diagnostic, §9.2. Record evidence, do not fix. |
| 19 | **Never executed** | Structurally inexpressible (§6.2). Appears in the catalogue as `manual` so it holds a row in the summary. Manual teardown: §11. |

Optional steps requiring `--include-optional`: 8.5, 13.3, 17. Step 13.3 additionally requires `--email-to` on the command line rather than a catalogue value, so it cannot fire by accident during an unattended run — it sends mail *from a production backup server* with a subject an operator could mistake for a real alert. The harness restores **both** `target.report.email` and `target.report.subject`; `TESTING.md` only restores the former.

---

## 11. Manual teardown

The harness cannot execute any of this. Run it by hand when testing is finished.

**Scratch-only teardown** — removes test data, keeps the code checkout, tmp, and harness state so the environment stays re-runnable:

```sh
zfs destroy -r storage/testing/src
zfs destroy -r storage/testing/dst
zfs destroy -r storage/testing/verify
zfs destroy -r storage/testing/transport
zfs destroy -r storage/testing/report
zfs destroy -r storage/testing/oneshot
rm -f /storage/testing/code/sneakernet/sneakernet_target.status*
```

The status file line is not optional. `sneakernet_target.status` records the last-replicated snapshot *by name*, and every Part 0 rebuild recreates `src`/`dst` with the same snapshot names (`@smoke1`, etc.) - so a status file left over from a prior pass makes the source believe a brand-new, never-replicated dataset is already fully caught up (`doSourceReplication: Nothing to do for ds2`), purely by name collision. This is not a code bug (found and confirmed via a real run on 2026-08-07): sneakernet correctly implements resume-by-snapshot-name; the drift comes from destroying `src`/`dst` without also clearing the state that names them.

**Full teardown** — removes everything including the svn checkout, harness audit logs, and the config store. A subsequent run requires redoing Part 0 from scratch:

```sh
# Verify the target first. This destroys the code checkout and all audit history.
zfs list -r storage/testing
zfs destroy -r storage/testing
```

> Read the target of that last command twice before running it. `storage/testing` and `storage` differ by eight characters, and the second one is the production pool.

**Recreating after a full teardown.** The harness's own Part 0 preflight only goes as far as `TESTING.md`'s Step 0.3 onward (§9.3) - it `svn update`s an *existing* working copy (its `svn_update` primitive hard-fails if `/storage/testing/code` isn't already a checkout; it never runs `svn checkout`) and only ever creates the `ds<N>` children, never the `storage/testing`/`storage/testing/code` datasets themselves. After a full teardown, restore the container structure by hand before invoking the harness at all:

```sh
mkdir -p /storage/testing
zfs create storage/testing
zfs create storage/testing/code
svn checkout http://svn.dailydata.net/svn/zfs_utils/trunk /storage/testing/code
```

From there, running `sneakernet/testing/run-tests` (Part 0) does the rest automatically: `svn update` verification, the `ds1`/`ds2`/`dst` dataset tree, the test transport key, the canonical config, and the smoke test (`TESTING.md` Steps 0.1-0.6, §9.1/§9.3). No other manual step is needed.

---

## 12. Reporting

**`TESTING.log` is the test-results report** — the automated equivalent of the PASS/FAIL sheet a human fills in while working through `TESTING.md`. Despite the `.log` extension it is not a debug or trace log; the raw execution trace lives separately in the audit JSONL and the captured `sneakernet` output under the run directory (§5.1).

It is written by the harness to `/storage/testing/harness/runs/<runid>/TESTING.log` on the host, then retrieved to **`sneakernet/Documentation/TESTING.log`** on the workstation for review and commit.

Contents:

- **Header** — run id, start/end timestamps, host, SVN revision under test, `sneakernet` and `ZFS_Utils` versions, harness version, execution mode, and any active deviations (notably `--baseline-maxdelta`, §9.2).
- **Per-step record** — status (`PASS` / `FAIL` / `RECORDED` / `SKIP` / `N/A`), the primitives executed, the assertion evaluated, and evidence excerpts on failure.
- **Summary table** — mirrors `TESTING.md`'s own Pass/Fail sheet, so an automated run is directly comparable to a manual one and to previous runs.
- **Follow-up list** — items deliberately not fixed in this pass (currently Step 8.5 and `maxDelta`), so they are not lost between runs.

---

## 13. Bug-fix workflow

Failures are **batched to the end of the run** rather than fixed as they appear. On completion:

1. All failures are presented together, each with evidence and a proposed fix as a reviewable diff.
2. On approval, changes are made on the workstation.
3. `svn commit` from the workstation.
4. `svn update` in `/storage/testing/code` on the test host.
5. Affected Parts are re-run to confirm.

Version bumps, `CHANGELOG.md`, `sneakernet.datastructure`, and prose documentation are updated together with any code change, per this project's existing convention.

Known items already excluded from fixing in this pass: **Step 8.5** and **`maxDelta`** (§9.2). Both are to be characterised with evidence and deferred.

---

## 14. Phases and acceptance

| Phase | Deliverable | Status |
|---|---|---|
| **1** | This SOW | Done |
| **2** | **`YAMLPatch.pm` + `YAMLPatch.md` + `utilities/yamlpatch` + test suite**, standalone and independently proven | **Delivered 2026-08-06** |
| **3** | Harness built at `sneakernet/testing/`, reviewed via `--explain` and `--dry-run`, committed | **Delivered 2026-08-06** |
| **4** | Live run against the test host, `TESTING.log` produced | **Delivered 2026-08-08** (§15) |
| **5** | SOW for batched fixes (§16) | **Delivered 2026-08-08**, verified at r179 (§17) |
| **6** | Fixes for the r179 verification findings (§18) | **Delivered 2026-08-08**, verified at r180 (§19) |
| **7** | Fixes for the r180 verification findings (§19) | **Delivered 2026-08-08**; re-verification separate approval |

**Phase 2 is deliberately sequenced first.** The patcher is the harness's most intricate component and its only reusable one; proving it in isolation — against fixture files, with no ZFS and no production host anywhere near it — removes that risk from Phase 3 entirely. It is also independently useful the moment it exists.

**Phase 2 delivered** (not yet committed to SVN):

| File | Purpose |
|---|---|
| `YAMLPatch.pm` | The module. Core Perl only; `YAML::Tiny` used opportunistically if present. |
| `YAMLPatch.md` | Full API reference and design rationale. |
| `utilities/yamlpatch` | CLI front end - same code path as the API. |
| `testLibrary/test_YAMLPatch.pl` | 126 checks: parsing, every mutator, all four relaxation flags, all 7 commit gates, and a byte-identical round-trip sweep across every real `.yaml`/`.yml` file in this repository (not just synthetic fixtures). |
| `testLibrary/test_yamlpatch_cli.pl` | 18 checks proving the CLI and the API produce byte-identical results. |

Scope note: `insert_block` (whole-block insertion, e.g. adding a full `ds3:` dataset
definition) is API-only - the CLI exposes `get`/`exists`/`list`/`set`/`delete`/`comment-out`/
`uncomment`/`insert-kv`, since a multi-line block doesn't fit a single command-line argument
cleanly. The harness (Phase 3) uses the API directly, so this doesn't block it.

Three real bugs were found and fixed against real inputs during Phase 2, before Phase 3 could
ever have hit them on production data:
- A naive positional diff (`old[i]` vs `new[i]`) reported a single-line insertion as "every
  line after it changed," since they all shifted position by one. `diff()` and the gate-4
  line-delta count now use a proper LCS alignment, with diffstat-style grouping so a plain
  `set()` still counts as 1 changed line, not 2 (delete+add).
- Multi-document detection (`allow_multidoc`) failed to recognize a `---` as a genuine second
  document when the first document had no explicit leading `---` of its own - it always
  treated the *first* `---` encountered as benign. Fixed by tracking whether real (non-comment)
  content has been seen yet, not just whether a `---` has been seen yet.
- Anchor/alias detection was over-broad (`^&|^\*|^!`) and misfired on a real config value,
  `**redacted**` (a redaction placeholder, not a YAML alias), refusing to parse a file that has
  never used a YAML anchor. Tightened to require an identifier character immediately after the
  sigil, which is what actually distinguishes `*alias` from `**redacted**`.

Phase 2 is accepted when:

- ✅ Round-trip fidelity: parsing and re-emitting an unmodified file is **byte-identical**, verified across the sneakernet config and a corpus of other real-world YAML.
- ✅ A single `set` changes exactly one line; comments, key order, blank lines, and quoting elsewhere are untouched.
- ✅ Every operation (`get`/`set`/`delete`/`comment_out`/`uncomment`/`insert_kv`/`insert_block`) has tests, including the ambiguity cases: duplicate key *names* at different paths, set-on-commented-path, delete-with-subtree, and insertion anchored between siblings.
- ✅ Every out-of-subset construct is refused with a clear message naming the line — and accepted when the matching `--allow-*` flag is given.
- ✅ Caller hooks (gates 6–7) are exercised by a test proving a rejected hook aborts the write and leaves the original file byte-identical.
- ✅ The CLI and the API produce identical results for the same operation.
- ✅ Core Perl only; runs with `YAML::Tiny` absent (gate 2 skipped) and present (gate 2 enforced).

**Phase 3 delivered**, committed r165–r170:

| File | Purpose |
|---|---|
| `sneakernet/testing/Harness/Safe.pm` | The sandbox enforcement layer (§6): type constructors, per-primitive allow-lists, no-shell exec, four execution modes, audit logging, production tripwire. |
| `sneakernet/testing/Harness/Containment.pm` | The config containment gate (§6.5), wired as a `YAMLPatch` save hook. |
| `sneakernet/testing/Harness/Catalogue.pm` | Loads and validates the JSON step catalogue (§8): token expansion, dependency closure, reset profiles. |
| `sneakernet/testing/Harness/Runner.pm` | Executes a resolved step selection against the catalogue: config mutations, action dispatch, the assertion vocabulary, per-Part reset/tripwire, the `EXPLAINED` status for `--explain`. |
| `sneakernet/testing/Harness/Report.pm` | Renders `TESTING.log` — per-step results, per-Part summary, totals, deviation/follow-up notes. |
| `sneakernet/testing/run-tests` | CLI entry point: mode flags, `--parts`/`--steps`/`--from`/`--force-partial`, `--include-optional`, `--baseline-maxdelta`, cron-guard enforcement, production-tripwire arming. |
| `sneakernet/testing/catalogue/part00.json` – `part19.json`, `reset_profiles.json` | All 19 Parts of `TESTING.md`, transcribed as data — ~110 steps total. |
| `testLibrary/test_Harness_{Safe,Containment,Catalogue,Runner,Report}.pl`, `testLibrary/test_catalogue_coverage.pl`, `testLibrary/test_run_tests_cli.pl` | 206 checks: every primitive's allow-list/refusal boundary, the assertion vocabulary, `--explain`/`--dry-run` mode gating, report rendering, static coverage checks (every real config mutation, every dataset-bearing action, every optional/record_only/manual disposition, every `set`-to-a-YAML-unsafe-bare-value has an explicit style) against the finished catalogue, and the CLI's own argument parsing. Combined with Phase 2's 144, the full suite is 350 checks. |

Real bugs found and fixed while building the catalogue against the real harness engine (not just authored and assumed correct):

- **Two TESTING.md transcription errors, caught by tracing the real `sneakernet`/`ZFS_Utils.pm` source rather than trusting the prose alone**: Step 7.2c's hand-crafted garbage-stream filename (`storage.testing.dst.ds1`) does not match sneakernet's actual flat filename convention (the bare `dataset` key, confirmed via `dirnameToFileName`/`fullDatasetName` — Step 8.5's own hand-added file correctly uses the flat form) and would never have reached the decrypt/receive path it's meant to test; and Part 10's four `fullSendPolicy` steps, run back-to-back with no new snapshot between them as literally written, would not actually re-trigger the "no common base" condition after the first one, since the source records a filesystem's newest snapshot as sent regardless of receive success (the same behavior Part 7.2a already documents) — each step now re-establishes its own fresh no-common-base condition.
- **A capture-reduction default bug**: a `capture` with no `select` filter defaulted to `matches[0]` (the *first line* of output) instead of the full text, so every existing multi-line `regex_present` check built on such a capture (Parts 0, 2–8) was silently checking only the first line. Fixed by defaulting to the full joined text when no `select` is given, reserving `matches[0]` for the case that actually calls for one value (a `select`-extracted version string, etc).
- **An `--explain`-only infinite loop**: `path()`'s symlink-escape check walks up the path's ancestors with `s{/+[^/]+$}{}` until it finds one that exists; a path built from `"{TOKEN}/$VAR"` where `$VAR` resolves to an empty string under `--explain` (a legitimate placeholder, since no primitive's body actually runs in that mode) leaves a trailing slash, which that regex can never match — so the loop never terminates. Fixed by refusing an empty path component (a doubled or trailing slash) outright, in every mode, rather than relaxing the check itself — `path()` is a general safety primitive other code (including its own unit tests) calls directly and must behave identically regardless of the harness's execution mode. The one catalogue step that hit this (Step 15.2's dynamic backup-filename path) was rewritten against a new `read_file_matching` primitive instead, which never needs to interpolate a runtime value into a path string at all.
- **A misleading `--explain` summary**: a Part mixing `EXPLAINED` steps with a `SKIP`ped optional step (any Part containing an optional step, previewed without `--include-optional`) fell through `_summarize_part`'s status logic to a bare `PASS`, even though nothing in that Part actually ran.
- **Found only by actually running `--dry-run` against the test host's real config** (none of these were reachable from static review or `--explain`, since neither ever touches a real file): `{TMP}` (`/storage/testing/tmp`) had no bootstrap anywhere in the catalogue or `run-tests`, unlike `{HARNESS}`, which `run-tests` already `make_path()`s at startup — Parts 9/11/15/16/17 would have crashed on their first `write_file` under it. `--parts 0` / `--steps 0` was silently treated as "no selection given" and ran the entire catalogue, because Perl's string `"0"` is false and the option-presence checks used truthiness instead of `defined`. And four `set` mutations (Steps 2.3, 6.2b, 8.3a, 8.6) targeted a value YAMLPatch's own `_looks_safe_bare()` correctly refuses to emit unquoted (an empty string, a leading `-`, and the YAML 1.1 boolish word `off`) without specifying an explicit style — three of these were only accidentally safe because the field's baseline style happened to already be quoted. Fixed by adding explicit styles and by generalizing `test_catalogue_coverage.pl`'s check to call the real `_looks_safe_bare()` against every `set` mutation's value, rather than checking one value shape at a time.

Phase 3 is accepted when:

- ✅ Every one of the ~25 prose config mutations in `TESTING.md` maps to a declared, path-addressed patcher operation. (23 distinct edit instructions, 18 distinct keys — several instructions revisit the same key with a different value, e.g. `fullSendPolicy` across Steps 10.1–10.4 — verified by `test_catalogue_coverage.pl`.)
- ✅ Every `/tmp` path in `TESTING.md` maps to a `{TMP}` token, and no `/tmp` path is expressible.
- ✅ Every `zfs destroy` and `rm` maps to an allow-listed primitive — and `zfs destroy -r storage/testing` maps to none.
- ✅ Each optional or known-problem step is explicitly dispositioned per §10.
- ✅ Every tool named is confirmed present on the test host; nothing requires installation.
- ✅ `--explain` runs clean end-to-end across all 19 Parts, with and without `--include-optional`, on the test host itself (not just this workstation).
- ✅ `--dry-run` run clean, reviewed against production-fingerprint output showing no change.

**What "`--dry-run` run clean" means in practice** — worth being explicit about, since it is narrower than "every assertion passes": `_apply_config_mutations` deliberately never writes to disk under `--dry-run` (confirmed: the config file's SHA-256 was byte-identical before and after), so any Part whose later steps build on an earlier step's mutation within the same Part (Part 4's `ds3`→`ds4` chain, Part 5's disable/re-enable, Part 18's `maxDelta` sequence, etc.) cannot complete correctly — each step re-reads the unmodified baseline. `run_sneakernet` is also classified `mutating`, so under `--dry-run` it never actually invokes the real `sneakernet` binary (the same no-op as `--explain`) — no assertion checking its log output can pass. Both are structural consequences of the safety design, not bugs, and they are why a full-catalogue `--dry-run` reports mostly `FAIL` (26 PASS / 40 FAIL / 1 RECORDED / 1 N/A, in the r170 run). The actual acceptance run against the test host (r170, 2026-08-06) instead verified, directly: the engine completes across all 19 Parts with no hang or crash in a single ~11-second run (`--parts 0-3,5-19`, Part 4 held out because its Step 4.4 deliberately halts the whole run on *any* failure per §10 — including this structural one — and was verified separately, in isolation, to halt safely with no hang); the production tripwire armed and never tripped; `storage/backup`'s snapshot count was unchanged (1539 before and after); the config file's SHA-256 checksum was identical before and after; and the run's audit log shows zero `mutating`/`destructive`-class primitives with an `ALLOWED` verdict — all 443 were correctly `SIMULATED`, with only the 78 genuinely `readonly` primitives (`ALLOWED`) plus 1 clean `REFUSED` (an expected empty-match refusal, not a crash) actually touching the real filesystem.

Phase 3 is complete. Phase 4 (a live `--run` against the test host) is a separate approval.

---

## 15. Phase 4 — first full run

Between the Phase 3 `--dry-run` acceptance and this run, r172–r178 fixed several bugs findable only by real execution, discovered by the user driving the harness by hand with `--confirm-destructive`: `Runner.pm`'s restore-after-step not firing under `confirm_destructive`; `YAMLPatch::insert_block` double-nesting its declared-intent path on the actual wrapped-block usage the catalogue relies on; that same restore logic wrongly firing for steps with no `config.mutations` at all (reverting Step 0.5's own baseline write); `sneakernet`'s `checkSizeAgainstTransport` never recognizing a folder-based (non-dataset) `transport.mountPoint` as having space; `{ONESHOT}` never being bootstrapped anywhere, unlike `{TRANSPORT}`/`{REPORT}`; `updateTarget` crashing on a missing transport dataset directory; and two harness catalogue bugs of its own (Step 3.3 checking the wrong output stream for the OVERVIEW block, then a missed case-insensitivity flag in that same fix). Each is a real, run-confirmed bug — none reachable from `--explain`/`--dry-run` review, since neither mode ever calls `save()`, persists a mutation, or invokes the real `sneakernet` binary.

**The first full, unattended `--run` across all 19 Parts** happened on 2026-08-08 (run id `20260808_011022_52983`, r178, `--run --include-optional`, 86 seconds): **47 PASS, 25 FAIL, 1 N/A** (Part 19, never executed — §6.2). This is the first time the catalogue had ever been exercised past Part 4 for real, and the first time it had run as a single unattended pass rather than in the smaller `--confirm-destructive` slices used to find the r172–r178 bugs above.

A dedicated root-cause investigation (cross-referencing `audit.jsonl`, per-step `sneakernet` process logs, catalogue JSON, and the product source — not just re-reading the `TESTING.log` summary) classified all 25 failures. The disposition is §16.

One environmental note that cost real investigation time and is worth recording: `sneakernet`'s own `logMsg()` timestamps use the server's **local** time (the test host is UTC-5), while the harness's run IDs and `TESTING.log` header use `gmtime()`. A `partNN_X.log` line timestamped `20:11` on "the previous day" is frequently the *same run* as a `TESTING.log` header reading `01:11 UTC` the next day — do the arithmetic before concluding a log excerpt is stale.

---

## 16. Phase 5 SOW — fixes from the first full run

Two things broadened this SOW's scope beyond the 25 findings themselves:

1. One finding (Part 18, §16.3) traced back to `sneakernet/cleanupScripts/updateConfigKeys` doing a full `YAML::Tiny` dump-and-rewrite on every use — silently destroying comments and reformatting the whole file. This was already flagged as a known, shipped defect (§7.1's rationale for not round-tripping YAML::Tiny applies just as much to production code as to the harness), and now that `YAMLPatch` exists and is proven (Phase 2), it is the right tool to fix it with.
2. Part 16 (`checkSneakernetFile`) needs `Crypt::Cipher::AES`, absent on the test host at the time of the full run. Investigation confirmed this is **not a production dependency** — both `sneakernet` and `ZFS_Utils.pm` shell out to `openssl enc -aes-256-cbc` for all real encryption/decryption; only this standalone verification utility uses the Perl module directly. The module has since been installed on the test host; no production-server access was required for this SOW.

Two findings were reframed during planning, changing their disposition from the investigation's initial read:

- **Part 7's `zfs send -R` landed 28 snapshots on the target instead of the expected 1.** The project's disaster-recovery requirement is to *maximize* retained history on the target (ransomware/corruption rollback should be able to reach further back than the single most recent snapshot), so `-R` replicating the source's full available snapshot history during a full-overwrite is **correct, wanted behavior** — not a bug. §16.2's fix is to the catalogue's assertion (it should check that the target ends up with the same snapshot set the source currently has, not a hardcoded count), not to `sneakernet`.
- **The `YAMLPatch` migration is scoped to `updateConfigKeys` only** (§16.3). `ZFS_Utils::makeConfig`'s other two call sites — `loadConfig`'s interpolation-marker rewrite and `loadOrCreateConfig`'s from-`.datastructure`-defaults bootstrap — both start from an arbitrary in-memory hashref rather than editing existing YAML text, which doesn't fit `YAMLPatch`'s path-addressed-edit model. Out of scope for this pass.

### 16.1 Bucket A — sandbox hygiene, fixed at the source

`0.6, 5.1, 5.3, 8.5, 10.3` (+ cascade `5.2`) all failed on a snapshot name that already existed from an earlier session — not a bug in anything under test. Rather than documenting "run a manual teardown before retrying" as a standing operational note, the fix is structural: Part 0 stops assuming a clean environment and creates one itself. See §9.4 for the deviation this introduces and its rationale. This eliminates the category outright rather than mitigating it per run.

### 16.2 Bucket B — harness/catalogue bugs (the test is wrong, the product is fine)

| Finding | Fix |
|---|---|
| `6.1`, `6.2b` — backup-filename regex `^sneakernet_target\.status\.[0-9_]+$` never matches real names like `...status.2026-08-07_20.11.34` | Widened the regex in `part06.json`. The identical narrow character class turned out to be copy-pasted into every step's own `rm_matching` cleanup call across `part00.json`, `part07.json`–`part10.json`, `part12.json`, `part13.json`, `part18.json`, and `reset_profiles.json`'s `standard` profile — none of them were actually deleting old status backups before re-establishing a fresh no-common-base scenario, just silently no-oping on the suffixed form every time. Widened everywhere it appears, not just in `part06.json`. |
| `10.1` — capture pattern `^zfs send...` anchored to line-start, but every log line carries a `TIMESTAMP\t` prefix | Un-anchor the pattern in `part10.json`, matching `part05.json`'s already-correct `zfs send[^\n]*-[iI]` style |
| `13.1` — checks `run_sneakernet`'s console capture for the provenance line, but that line is written only into the report **file**, never logged. Worth noting for a future reader: `TESTING.md`'s own literal Step 13.1 (`... \| tee /tmp/part13a.log; grep -E "^Host: ..."`) makes the same assumption, so this isn't a transcription slip introduced by automation — the manual procedure itself was never actually run against this exact assertion, or `sneakernet` was expected to also print the provenance line at low verbosity and never was wired to. Not changed here; only the harness's own check is fixed, since `sneakernet`'s actual behavior (write to the report artifact, not the console) is reasonable on its own terms | Change step 13.1 to read the target's report file via `read_file_matching` (adding a `run_sneakernet` target-role call, since only target has a `report.targetDrive.mountPoint` configured in the baseline — source's `sendReport()` computes the same message but has nowhere configured to put it), the pattern Part 3.3 already uses correctly |
| `14.1` — checks for `=== CLEANUP SCRIPT ERRORS ===`, a heading that has never existed in the product; the real line is `(main): Cleanup scripts completed with errors:` | Correct the expected pattern in `part14.json` |
| `15.2-verify-backup` — expects exactly one `sneakernet.conf.yaml.bak.*`, finds two (one a leftover from an earlier session) | Add `sneakernet.conf.yaml.bak.*` cleanup to `reset_profiles.json`'s `standard` profile |
| `7.2c` — `openssl_encrypt_to_file` refuses because a transport file this same step's own earlier `run_sneakernet` call wrote is still there | Remove just the stale `ds1`/`ds1.IV` pair via `rm_matching({TRANSPORT}/datasets, ...)` before the encrypt-to-file call, not a full `empty_dir({TRANSPORT})` — that would also delete the `datasets/` subdirectory itself, which `openssl_encrypt_to_file` doesn't recreate (it only `O_CREAT`s the file, not missing parent directories) |
| `7.1`, `7.2b` — see the reframing above | Changed the assertion from a hardcoded snapshot count to comparing the target's and source's current snapshot sets for the dataset (a new `sorted_join` capture reduce in `Runner.pm`, since `zfs list`'s ordering isn't guaranteed to match between two different datasets — a plain `join` would make `values_equal` spuriously fail even on identical sets); no product change |
| `9.1`, `9.2`, `11.1`, `12.3` — catalogue snapshot names (`S1_9_1`, `S2_11_1`, etc.) carry no embedded date, but `ZFS_Utils::parseSnapshotDateTime`/`findCommonBaseSnapshot` require one before attempting confirmed/inferred base-selection at all — the exact mechanism these Parts exist to test structurally never engages | See below — the highest-priority item in this SOW |
| `15.3` — the harness's own pre-flight config-containment gate (`Harness::Containment`, run before every `run_sneakernet` call) does a full strict-subset parse of the config and crashes on the deliberately-corrupted file, instead of gracefully refusing | Make the containment check skip its pre-flight parse for a step declaring `expects_invalid_config` — the point of 15.3 is proving `sneakernet` itself refuses safely, not the harness's own gate |

**Steps 9/11/12 fix, in detail.** `TESTING.md`'s own manual steps build snapshot names as `@S1_$(date +%Y-%m-%d_%H.%M.%S)` — a real embedded date, which is what `findCommonBaseSnapshot`'s confirmed/inferred base-selection logic requires to have anything to infer from (Part 9 is documented in `TESTING.md` as "the single most important thing to test — it is the fix for the original production incident"). The catalogue transcribed these as bare literals (`S1_9_1`) instead, so the fallback this Part exists to prove can never trigger — a transcription gap in the same family as the two already caught and fixed during Phase 3 (§14). The fix adds one new harness primitive (mirroring the existing `random_hex`/`store_as` pattern in `Harness::Safe`/`Runner.pm`) that produces a real timestamp suffix, and updates `part09.json`, `part11.json`, and `part12.json` to build their snapshot names from it.

### 16.3 `updateConfigKeys` → `YAMLPatch` migration

Current shape of `sneakernet/cleanupScripts/updateConfigKeys`: copy the config to `.bak.<ts>` → `YAML::Tiny->read` the whole file into a hash → walk/mutate an `@updates` array of dot-path edits directly on that hash → `YAML::Tiny->new($data)->write()` a full dump to a temp file → re-load and verify the temp file contains every declared change → atomic `rename()` into place. The dump step re-sorts keys alphabetically, strips comments, and re-quotes every scalar — confirmed by this SOW's Part 18 finding, where Part 0.5's `#maxDelta: 0.9` comment was simply gone by the time Part 18 tried to `uncomment` it.

New shape, same safety posture (backup-first, verify-before-commit, atomic rename — only the edit mechanism changes):

- `YAMLPatch->load_file($configFile)` in place of the `$LoadFile`/`$DumpFile` closures.
- `YAMLPatch`'s `set`/`delete` per `@updates` entry, in place of walking and mutating the hash directly — its dot-path addressing already matches this script's own `key=value`/`key=DELETE` convention.
- `YAMLPatch`'s own `save()` in place of the dump-then-manually-reload-and-verify step — gate 3 ("the semantic delta must equal the declared intent exactly," §7.3) is a strictly stronger version of the check this script already does by hand, so that loop is deleted, not adapted.
- The `.bak.<ts>` backup step is unchanged.

This is shipped production code, so the version/CHANGELOG/`sneakernet.datastructure`/documentation convention (§13) applies, with a new `testLibrary/test_updateConfigKeys.pl` (extract-and-stub style, per this project's convention) covering: comments survive an update, `@updates`'s set/delete semantics are unchanged, and the backup-then-atomic-rename behavior is unchanged. Fixing this is expected to resolve Part 18 (§16.2's cascade note) without any catalogue change at all.

### 16.4 Verification

1. ✅ **Done.** Local: `perl -c` on every touched file; full `testLibrary/test_*.pl` suite — 25 files, 0 failures (up from 22 at the end of Phase 4, three new test files: `test_Harness_Runner.pl`'s new `expects_invalid_config` regression case plus `test_updateConfigKeys.pl`, 18 checks covering update/delete/create/backup/comment-preservation).
2. ✅ **Done.** Committed r179; `svn update` on the test host.
3. ✅ **Done.** A full sequential `--run` across all 19 Parts, unattended, from a clean environment. Outcome: §17.
4. ✅ **Done.** `Crypt::Cipher::AES` confirmed installed; Part 16 passed.
5. See §17 for the outcome and §18 for the follow-up fixes it produced.

---

## 17. r179 verification run

A fresh unattended `--run` on the test host at r179 (run id `20260808_030605_21647`): **64 PASS, 7 FAIL, 1 RECORDED (Step 8.5, already known-broken and deliberately unfixed), 1 N/A** — up from 47/25 before Phase 5. All 25 findings from §16 confirmed fixed. The 7 remaining failures were found by cross-referencing `audit.jsonl`'s per-primitive output directly (not just `TESTING.log`'s summary) against the product source; disposition is §18.

One of the seven led to a genuine, non-obvious finding worth recording on its own: **`zfs receive -F` can silently destroy an already-delivered snapshot.** Confirmed via `audit.jsonl` for Step 9.1: S1 and S2 are each cleanly received (`generateComparisonReport` shows `ADDED`/`1 snapshot added` for both), but the third receive - built from an *inferred* base of S1, because S2 was deliberately destroyed on the source to simulate the real production incident this Part exists to test the fix for - force-rolls-back the target to S1 before applying the incremental to S3, destroying the already-delivered S2 in the process. `generateComparisonReport` didn't flag this; it just reported "current: S3 (1 added)," identical to an ordinary clean increment. Decided with the user: the receive behavior itself is correct and should not change - in most real cases this is exactly the desired outcome (an intermediate snapshot aging out under retention/TTL rules), not data loss - but it was happening silently. Fixed by making the *report* say so; see §18.

---

## 18. Fixes for the r179 findings

### 18.1 `snapShotReport`/`formatReportLine` now notes a rolled-back original snapshot

`ZFS_Utils.pm` v1.7.2. The "changed" report line (`ZFS_Utils.pm:2341` area) counted snapshots added between the original and current reference points, but never checked whether the *original* reference snapshot was still present. It now does: if `$orig_snap`'s bare name no longer appears in the target's current snapshot list for that filesystem, the line appends a note explaining why (a forced receive rolled back to an older base) and that this is frequently the correct outcome, not a bug. No behavior change to replication itself - `receiveTransportStream` still always uses `-F`, unchanged, per the decision in §17. New tests in `testLibrary/test_snapShotReport.pl`.

**Found while adding that test: three unrelated `testLibrary/*.pl` files were silently crashing on load, invisible for an unknown period.** `test_snapShotReport.pl`, `test_getLatestSnapshots.pl`, and `test_zfs_utils.pl` all imported package variables (`$verboseLoggingLevel`, `$logFileName`, `$displayLogsOnConsole`) from `ZFS_Utils` that stopped being exported by default some time ago, per the module's own changelog - none of these files depend on anything from this SOW to explain the breakage, they were just never re-verified since that export change. `test_cleanSnaps.pl` was separately broken: it resolved the standalone `cleanSnaps` tool's path assuming it lived next to the test file in `testLibrary/`, but it actually lives in the top-level `cleanSnaps/` directory. All four fixed (qualify the variable references as `$ZFS_Utils::...` instead of importing them; correct the path). See §18.5 for how the previous verification method missed these for so long.

### 18.2 Step 9.1's assertions corrected to match actual (and now correctly reported) behavior

`part09.json`. The assertion checking that the target retains S2 is now `regex_absent`, not `regex_present`, with a message explaining why (matches §17's finding) - and a new assertion confirms the target's own comparison report explicitly notes the rollback (`"original snapshot no longer exists on the target"`), by capturing the previously-uncaptured second `run_sneakernet test-target` call in that step.

### 18.3 Structural fix: 9.2 and 11.1 split into `-setup` + confirmed-check steps

`part09.json`, `part11.json`. `source.targetSnapshotList` was set via the step's own `config.mutations`, active for every action in the step - including the first two "just deliver S1, then S2 normally" `run_sneakernet` calls, which should run on the ordinary status-file path. Confirmed via `audit.jsonl`: both of those source runs logged `Using operator-supplied target snapshot list ... instead of the status file`, reading a stale inventory file left over from a previous session (the correct, freshly-written inventory is only produced later in the same step). Since a step's `config.mutations` apply once, before all of that step's actions, and can't be scoped to a single action, each is now split into a `9.2-setup`/`11.1-setup` step (no `config` block - delivers S1 and S2 normally, creates S3, destroys S2 on the source, writes the fresh inventory file) followed by the original step (now `depends_on` the new setup step, `config.mutations` unchanged), whose actions shrink to just the confirmed-check run. `9.2-cleanup`/`11.1-cleanup` are unaffected.

### 18.4 Three ordinary bugs

| Finding | Fix |
|---|---|
| `7.2c` — destroys `{DST}/ds1` but never clears `sneakernet_target.status`, so the source's next send is computed as an incremental against a target with no matching base, refused before the step's own garbage-stream test even begins | Added the same `rm_matching` status-clear used everywhere else in the catalogue, right after the destroy |
| `10.3` — traced to **Step 5.3** (`part05.json`): `uncomment(datasets.ds2)` had no `restore: none`, so the default auto-restore reverted ds2 back to disabled immediately after 5.3 finished - ds2 stayed silently disabled for every Part from 6 onward | Added `"restore": "none"` to 5.3's `config` block |
| `10.1` — my own mistake from §16: capturing via `read_file` on the harness's per-step log file, which accumulates across every `run-tests` invocation ever (never cleared) - 3 historical matches, not 1 | Capture directly on the `run_sneakernet` action instead, matching every other step |
| `18.2` — checked for `serial.txt`'s presence *after* the target run, but sneakernet correctly deletes it once consumed | Moved the check to immediately after the source run, before target consumes it |

### 18.5 Verification

Local: `perl -c` on every touched file; full `testLibrary/test_*.pl` suite checked by **exit code**, not by grepping for the string `FAIL` in captured output - the latter is how all four files in §18.1's finding went unnoticed for an unknown period: a crash before any `ok()` call prints nothing matching `FAIL` either, so a suite runner that only greps for that string reports a false "0 failures" on a file that never actually ran. All 25 files now genuinely pass (exit 0). Commit (ask first), user runs `svn update` + a fresh `--run` on the test host. Expect 0 `FAIL` outside the two permanently-deferred items (Step 8.5, `maxDelta`).

---

## 19. r180 verification run

A fresh unattended `--run` on the test host at r180 (run id `20260808_042736_70533`): **72 PASS, 2 FAIL, 1 N/A** — up from 64/7 at r179 (§17). All 7 findings from §18 confirmed fixed, including Step 9.1 now genuinely passing (not just no-longer-failing) on its corrected assertions. The 2 remaining failures were both the same class of regression, introduced by §18.4's own `10.3`/Step-5.3 fix: once ds2 correctly stays enabled from Part 6 onward (as it always should have), two earlier-written assertions that had implicitly relied on ds2 being silently disabled started failing, because they counted transport-drive files without scoping to a specific dataset.

- **`7.2c`** ("an unrelated failure must NOT destroy anything") checks that its deliberately-corrupted `ds1` garbage stream doesn't trigger a destroy-and-retry, and that its own error isn't misclassified as the "destination has snapshots" family. `LOG72C` captures the *entire* target run's output, which — with ds2 now enabled and already holding target snapshots from earlier Parts — legitimately goes through its own, unrelated `target.allowFullOverwrite` destroy-and-retry cycle in the same run, tripping both assertions on ds2's noise. Fixed by disabling ds2 for the duration of this one step only (`config.mutations: [{"op": "comment_out", "path": "datasets.ds2"}]`, default `restore: auto` re-enables it immediately after) — this step is specifically testing ds1 isolation, so scoping it to ds1 alone is correct, not a workaround.
- **`8.1`** ("Enable compression and confirm round-trip") asserted exactly one `.xz` file and one `.xz.IV` file appear in the transport drive's `datasets/` folder. With ds2 enabled, the source now produces both `ds1.xz` and `ds2.xz` there — correct behavior, not a bug. Fixed by anchoring both `list_matching` patterns to `^ds1\.xz$`/`^ds1\.xz\.IV$`, matching the precedent already set by Step 8.5's `^ds1\.xz$` pattern one step below.

**Lesson for future catalogue changes:** a step's assertions can encode an implicit assumption about which datasets are enabled at that point in the Part sequence, without saying so anywhere. Enabling/re-enabling a dataset earlier in the sequence (as `10.3`'s own fix correctly did) can silently break unrelated later steps whose counts weren't dataset-scoped. When writing a `count_equals`/`values_equal` assertion over `{TRANSPORT}/datasets` contents, prefer anchoring the pattern to a specific dataset name (`^ds1\.xz$`) over an unscoped suffix match (`\.xz$`) unless the step is deliberately asserting something about *all* enabled datasets together.

Verification: `perl -MJSON::PP` validates both touched catalogue files. No production code, no harness engine code, no `testLibrary/*.pl` changes this round — catalogue-JSON-only, so the local Perl test suite is unaffected. Commit (ask first), user runs `svn update` + a fresh `--run` on the test host.
