sandbox: the S7 audit trail can outlive the process that wrote it #309

Merged
dcharlot merged 1 commit from fix/sandbox-s7-audit-trail-survives-restart into main 2026-10-18 07:47:45 -04:00
Owner

The defect

SPEC §2.2 grades tier S7 as "S6 + geographic pinning + immutable audit trail", and §7.7 states:

"audit_prev — the head of the host's append-only, hash-chained audit log before this run. … The chain is tamper-evident — reordering or removing any entry breaks it — so the sequence of sovereign runs is itself attestable."

sandbox-attest's audit module repeats it, and SPEC.md:352 grades the log Engineering — "the hash-chained, tamper-evident audit log are implemented and signed into the attestation; vectored" — disclosing nothing about durability.

The reference state was a Mutex<AuditLog> that Host::new always built fresh, with no constructor taking an existing log and audit_head() the only accessor. AuditLog derives Serialize/Deserialize, and nothing in the tree ever wrote or read one.

So the trail did not survive a restart — and nothing showed that it hadn't. AuditLog::verify recomputes the head from the entries it holds, so a freshly reset log verifies perfectly. After a restart the next sovereign run is sealed with audit_prev = BLAKE3("sandbox.audit/1"), the genesis head — byte-identical to what a host's very first sovereign run would carry.

A data-residency auditor given the post-restart host sees a short chain that verifies from genesis, while the runs before the restart have left the "immutable audit trail" without breaking anything. The one property §7.7 sells is the one that did not survive.

The fix

The pattern was already in the repo and unused here: sandbox-build::FileBuildLedger is a file-backed, verify-on-open JSONL ledger. This follows it.

  • AuditLog::load_jsonl / append_jsonl give the trail a file.
  • The file stores only the entry list; the head is replayed from it, never stored. So the file cannot assert a head it did not earn, and a loaded log verifies by construction.
  • Host::with_durable_audit(path) opens and replays the existing chain.
  • The append writes the line before the in-memory head advances. A crash between the two leaves the file ahead of memory and the next load replays to the head the attestation already committed to; the other order would seal a sovereign run against a head no file records.
  • A missing file is the first run. A file that does not parse is an error, not a shorter history — the runs whose lines are unreadable are exactly the ones the trail exists to hold. AttestError::Audit is fatal by design.

Host::new and every existing call site are unchanged: a host that does not ask for a durable trail behaves exactly as before, and that limit is now documented on with_durable_audit and pinned by a test.

What this does not claim

A log is not self-authenticating. A truncation that drops entries and leaves a consistent list still verifies on its own. What detects that is the audit_prev each S7 attestation carries — the attestations are the evidence and the log is the index into them, which is exactly why the index has to survive. Said plainly in the module doc rather than left to be assumed.

Tests

sandbox-attest:

  • a_durable_trail_replays_to_the_same_head — three entries, reloaded: same length, same head, verifies, and not the genesis head.
  • a_missing_trail_is_the_first_run.
  • a_trail_that_does_not_parse_is_an_error.

sandbox-runtime:

  • an_s7_audit_trail_survives_a_restart — two sovereign runs sealed, process dropped, host reopened: both runs still on the trail, head unchanged, and the next run's audit_prev is the real head. Plus the discriminating half: a host with no durable trail reports the genesis head, indistinguishably from its first run.

Falsification

mutation result
S1 with_durable_audit records the path but never loads the chain an_s7_audit_trail_survives_a_restart FAILED
S2 the durable append writes nothing an_s7_audit_trail_survives_a_restart FAILED
S3 a missing trail is an error instead of the first run only a_missing_trail_is_the_first_run FAILED
S4 unparseable lines are skipped instead of refused only a_trail_that_does_not_parse_is_an_error FAILED

S1 and S2 are the read and write halves of durability, and each is independently load-bearing. The pre-existing s7_run_binds_locality_and_advances_audit_chain and s7_requires_locality_and_audit_prev passed throughout.

Verification

On the build host (aarch64):

  • cargo check --workspace --all-targets (sandbox-rs) — exit 0
  • cargo test --workspace (sandbox-rs) — exit 0, 0 failed suites, 48 passing suites
  • cargo test -p sandbox-attest -p sandbox-runtime — 53 + 15 pass

No conformance-vector impact. sandbox-conformance carries an AuditLog in its vectors, and this adds only methods — no field changed, so its serialization is byte-identical; the conformance suite is among the 48 green above. Every consumer of sandbox-attest/sandbox-runtime is inside the sandbox tree (14 manifests, none outside), so the gate above covers the whole blast radius.

🤖 Generated with Claude Code

## The defect SPEC §2.2 grades tier **S7** as "S6 + geographic pinning + **immutable audit trail**", and §7.7 states: > "`audit_prev` — the head of the host's append-only, hash-chained audit log **before** this run. … The chain is tamper-evident — reordering or removing any entry breaks it — **so the sequence of sovereign runs is itself attestable**." `sandbox-attest`'s audit module repeats it, and `SPEC.md:352` grades the log **Engineering** — "the hash-chained, tamper-evident audit log are implemented and signed into the attestation; vectored" — disclosing nothing about durability. The reference state was a `Mutex<AuditLog>` that `Host::new` always built fresh, with no constructor taking an existing log and `audit_head()` the only accessor. `AuditLog` derives `Serialize`/`Deserialize`, and **nothing in the tree ever wrote or read one**. So the trail did not survive a restart — and nothing showed that it hadn't. `AuditLog::verify` recomputes the head from the entries it holds, so a freshly reset log **verifies perfectly**. After a restart the next sovereign run is sealed with `audit_prev` = `BLAKE3("sandbox.audit/1")`, the genesis head — byte-identical to what a host's very first sovereign run would carry. A data-residency auditor given the post-restart host sees a short chain that verifies from genesis, while the runs before the restart have left the "immutable audit trail" **without breaking anything**. The one property §7.7 sells is the one that did not survive. ## The fix The pattern was already in the repo and unused here: `sandbox-build::FileBuildLedger` is a file-backed, verify-on-open JSONL ledger. This follows it. - `AuditLog::load_jsonl` / `append_jsonl` give the trail a file. - The file stores **only the entry list**; the head is replayed from it, never stored. So the file cannot assert a head it did not earn, and a loaded log verifies by construction. - `Host::with_durable_audit(path)` opens and replays the existing chain. - The append writes the line **before** the in-memory head advances. A crash between the two leaves the file ahead of memory and the next load replays to the head the attestation already committed to; the other order would seal a sovereign run against a head no file records. - A **missing** file is the first run. A file that **does not parse** is an error, not a shorter history — the runs whose lines are unreadable are exactly the ones the trail exists to hold. `AttestError::Audit` is fatal by design. `Host::new` and every existing call site are unchanged: a host that does not ask for a durable trail behaves exactly as before, and that limit is now documented on `with_durable_audit` and pinned by a test. ## What this does not claim **A log is not self-authenticating.** A truncation that drops entries *and* leaves a consistent list still verifies on its own. What detects that is the `audit_prev` each S7 attestation carries — the attestations are the evidence and the log is the index into them, which is exactly why the index has to survive. Said plainly in the module doc rather than left to be assumed. ## Tests `sandbox-attest`: - `a_durable_trail_replays_to_the_same_head` — three entries, reloaded: same length, same head, verifies, and **not** the genesis head. - `a_missing_trail_is_the_first_run`. - `a_trail_that_does_not_parse_is_an_error`. `sandbox-runtime`: - `an_s7_audit_trail_survives_a_restart` — two sovereign runs sealed, process dropped, host reopened: both runs still on the trail, head unchanged, and the next run's `audit_prev` is the real head. Plus the **discriminating half**: a host with no durable trail reports the genesis head, *indistinguishably from its first run*. ### Falsification | mutation | result | |---|---| | **S1** `with_durable_audit` records the path but never loads the chain | `an_s7_audit_trail_survives_a_restart` FAILED | | **S2** the durable append writes nothing | `an_s7_audit_trail_survives_a_restart` FAILED | | **S3** a missing trail is an error instead of the first run | **only** `a_missing_trail_is_the_first_run` FAILED | | **S4** unparseable lines are skipped instead of refused | **only** `a_trail_that_does_not_parse_is_an_error` FAILED | S1 and S2 are the read and write halves of durability, and each is independently load-bearing. The pre-existing `s7_run_binds_locality_and_advances_audit_chain` and `s7_requires_locality_and_audit_prev` passed throughout. ## Verification On the build host (aarch64): - `cargo check --workspace --all-targets` (`sandbox-rs`) — exit **0** - `cargo test --workspace` (`sandbox-rs`) — exit **0**, **0** failed suites, **48** passing suites - `cargo test -p sandbox-attest -p sandbox-runtime` — 53 + 15 pass **No conformance-vector impact.** `sandbox-conformance` carries an `AuditLog` in its vectors, and this adds only methods — no field changed, so its serialization is byte-identical; the conformance suite is among the 48 green above. Every consumer of `sandbox-attest`/`sandbox-runtime` is inside the sandbox tree (14 manifests, none outside), so the gate above covers the whole blast radius. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
sandbox: the S7 audit trail can outlive the process that wrote it
All checks were successful
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 17s
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 2m25s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 5m49s
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Successful in 1m24s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 1m34s
standards workspaces build + test / joulecontract-rs (joulecontract/joulecontract-rs, joulecontract-rs, linux-arm64, true) (pull_request) Successful in 1m2s
standards workspaces build + test / jcp-rs (jcp-conformance, jcp/jcp-rs, -p jcp-energy --features nvml, jcp-rs, linux) (pull_request) Successful in 1m59s
standards workspaces build + test / joule-ui-rs (joule-ui/joule-ui-rs, joule-ui-rs, linux) (pull_request) Successful in 1m11s
standards workspaces build + test / proof-rs (proof-conformance, proof/proof-rs, proof-rs, linux, true) (pull_request) Successful in 1m32s
standards workspaces build + test / sandbox-rs (sandbox-conformance, sandbox/sandbox-rs, sandbox-rs, linux) (pull_request) Successful in 2m25s
82d2ef9a0d
SPEC §2.2 grades tier S7 as "S6 + geographic pinning + **immutable
audit trail**", and §7.7 states: "`audit_prev` — the head of the host's
append-only, hash-chained audit log **before** this run ... The chain is
tamper-evident — reordering or removing any entry breaks it — so the
sequence of sovereign runs is itself attestable." `sandbox-attest`'s
audit module repeats it, and SPEC.md:352 grades the log Engineering
with "the hash-chained, tamper-evident audit log are implemented and
signed into the attestation; vectored" — disclosing nothing about
durability.

The reference state was a `Mutex<AuditLog>` that `Host::new` always
built fresh, with no constructor taking an existing log and
`audit_head()` the only accessor. `AuditLog` derives Serialize and
Deserialize, and nothing in the tree ever wrote or read one.

So the trail did not survive a restart, and nothing showed it had not.
`AuditLog::verify` recomputes the head from the entries it holds, so a
freshly reset log verifies perfectly: after a restart the next sovereign
run is sealed with `audit_prev` = BLAKE3("sandbox.audit/1"), the genesis
head — byte-identical to what a host's very first sovereign run would
carry. A data-residency auditor given the post-restart host sees a short
chain that verifies from genesis, while the runs before the restart have
left the "immutable audit trail" without breaking anything. The one
property §7.7 sells is the one that did not survive.

The pattern was already in the repo and unused here:
`sandbox-build::FileBuildLedger` is a file-backed, verify-on-open JSONL
ledger. This follows it.

- `AuditLog::load_jsonl` / `append_jsonl` give the trail a file.
- The file stores **only the entry list**; the head is replayed from it,
  never stored. So the file cannot assert a head it did not earn, and a
  loaded log verifies by construction.
- `Host::with_durable_audit(path)` opens and replays the existing chain.
- The append writes the line **before** the in-memory head advances. A
  crash between the two leaves the file ahead of memory and the next
  load replays to the head the attestation already committed to; the
  other order would seal a sovereign run against a head no file records.
- A missing file is the first run. A file that does not parse is an
  error, not a shorter history: the runs whose lines are unreadable are
  exactly the ones the trail exists to hold. `AttestError::Audit` is
  fatal by design.

What this does not claim: a log is not self-authenticating. A truncation
that drops entries *and* leaves a consistent list still verifies on its
own. What detects that is the `audit_prev` each S7 attestation carries —
the attestations are the evidence and the log is the index into them,
which is exactly why the index has to survive. Said plainly in the
module doc rather than left to be assumed.

`Host::new` and every existing call site are unchanged: a host that does
not ask for a durable trail behaves as before, and the limit is now
documented on `with_durable_audit` and pinned by a test.

Co-Authored-By: Claude Opus 5 <[email protected]>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Transaction-Science/open-standards!309
No description provided.