persistent-mount-coverage

Audit persistent mount coverage

Local implementation for hs-recovery-0145; repository completion is still open. The October 7 handoff records 45 passing scoped tests, passing web/Go typecheck, failed root lint and an unrun full test phase. No live Docker collection or recovery certification has been performed.

Run the offline audit on a saved evidence packet:

node scripts/node/persistent-mount-audit.mjs /path/to/evidence.json
node scripts/node/persistent-mount-audit.mjs /path/to/evidence.json --text

Exit 0 means every supplied runtime mount is accounted for and all expected containers were inspected. Exit 1 means missing coverage, inspection gaps or a conflicting policy. Exit 2 means invalid/unreadable input. The CLI reads at most 16 MiB, accepts regular files, and performs no Docker, network or backup operation.

This is a coverage audit, not a recovery certificate. Filesystem selections do not prove a completed backup, transactional database consistency or offsite recovery. Native receipts are supplied evidence, not independently authenticated artifacts. Every report sets recoveryVerified: false and every row restoreVerified: false.

Collect a packet from Docker

The optional collector reads the Docker context named in a policy file. It runs only docker --context <name> ps and inspect; it never stops a container, copies data, starts a backup or changes configuration. Choose the context deliberately: it may refer to a remote Docker engine. The collector lists running containers; stopped containers, unmounted volumes and writable container layers are outside its inventory.

{
  "schema": 1,
  "node": "node-identity-used-by-your-receipts",
  "dockerContext": "reviewed-context",
  "selections": [],
  "exclusions": [],
  "nativeReceipts": []
}

Populate those policy arrays using the contracts below. Empty arrays are valid: they make unprotected runtime mounts visible rather than inventing selections. The supplied node name is an operator label, not a verified hardware identity.

umask 077
node scripts/node/persistent-mount-collect.mjs /path/to/policy.json > packet.json
node scripts/node/persistent-mount-audit.mjs packet.json --text

Collector exit 0 means the inventory was collected completely; coverage can still be missing. Exit 1 writes a partial packet for inspection failures, inventory changes or collection deadlines. Keep and audit that packet. Exit 2 means invalid policy/input, with no valid packet. Do not chain the commands with && if you need the audit to show partial collection failures.

Collection lists full immutable container IDs before and after inspection. A missing inspect result keeps its ID. Added/removed IDs retain an inventory-changed issue. The collector projects only identity/mount fields, drops unrelated policy fields, and never returns Docker stderr. Native receipt fields must be nonempty bounded strings. A 30-second shared command budget, 5-second per-command deadline, 2 MiB command output limit and 1,000-container limit bound collection. Large or slow fleets produce incomplete evidence rather than a false success. The observed timestamp is the end of collection; a backwards wall-clock jump makes the packet incomplete.

These checks establish two-observation inventory stability, not a transactional snapshot of changing application data. Recorded collection issues are validated by the auditor, remain visible in reports, and prevent inventoryComplete and policyComplete from becoming true. Empty runtime is distinct from failed discovery.

Evidence packet

The packet uses Docker inspect's existing identity/mount fields. The abbreviated example below uses placeholders; supply full 64-character IDs and real evidence. Do not place credentials in evidence labels or exclusion rationale.

{
  "schema": 1,
  "node": "node-identity",
  "observedAt": "2026-10-07T12:00:00.000Z",
  "expectedContainerIds": ["<64-character container ID>"],
  "inspectionFailures": [],
  "containers": [{
    "Id": "<64-character container ID>",
    "Name": "/example-app",
    "Image": "sha256:<64-character image digest>",
    "Mounts": [{
      "Type": "volume",
      "Name": "example-data",
      "Source": "/var/lib/docker/volumes/example-data/_data",
      "Destination": "/data",
      "RW": true
    }]
  }],
  "selections": [{
    "source": "/var/lib/docker/volumes/example-data/_data",
    "scope": "tree",
    "method": "live-files",
    "evidence": "reviewed-full-backup-selection"
  }],
  "exclusions": [],
  "nativeReceipts": []
}

expectedContainerIds must come from the same observed runtime listing as the inspections. Do not derive it from the successful inspections alone: doing that would conceal failed inspections. Keep failed IDs in inspectionFailures and retain successful containers. Identity drift and unsupported mount types make the inventory incomplete. Duplicate identities, invalid paths and malformed mounts reject the packet. A volume shared by containers appears once per consumer.

selections describes actual backup selection policy, not an intended future policy. Exact scope matches only the mount's source; tree scope also matches descendants at directory boundaries. Supported methods are live-files and paused-files. Both remain filesystem-selection-only until stronger evidence exists. Bind sources may be files or directories; neither read-only mounts nor opaque/cache-looking volume names imply expendability. tmpfs is counted separately.

An exclusion requires an exact source, nonempty rationale and evidence. Overlapping exclusions and coverage produce a missing row with conflicting-coverage-policy. Review the conflict; the audit does not choose for you.

Native database and queue evidence

Each normalized native receipt needs node, containerId, immutable image, exact mount source and destination, method, status: "verified", capturedAt, artifactSha256 and evidence. Methods are postgres-logical, mariadb-logical, redis-rdb and queue-native. A successful native export must cover the declared data scope; configuration in other mounts needs its own selection.

The receipt's age is measured against observedAt, with a 36-hour default budget. This supports historical audits; it does not label old inventory as current health. The library API auditMountCoverage(packet, { maxReceiptAgeMs }) accepts a positive integer budget up to 366 days. Future, malformed, old and mismatched receipts cannot cover a mount. Of valid receipts, the newest is chosen deterministically. Contradictory digests for the same latest method/time produce a visible missing row, even when a filesystem selection exists; the audit will not silently choose evidence.

Existing Redis manifests contain containerId, image, sha256, capturedAt and, in newer versions, storage.source and storage.mountDestination. Normalize only after checking native verification and the node identity. Older manifests without storage binding cannot be joined by container name as though the binding were known. Do not claim queue replay safety merely because an RDB file exists.

The audit never emits Docker Config.Env, Cmd or labels. Its report does include container names, mount paths, image identities and your evidence labels; treat it as internal operator metadata. This tool is not an authorization or signature boundary.

Verification

node --test --test-concurrency=1 scripts/node/persistent-mount-*.test.mjs

Unmounted volumes, mutable container layers and application-level recovery remain outside this audit. Collector integration has executable fake-Docker proof; real fleet collection has not been run as part of this change. An empty explicitly observed runtime can have complete policy accounting without proving that an app is protected.