REG-D32 — FIXED (AUD-28): terminal evidence handed off at teardown now survives
the process that handed it off — opt-in, content-free durable outbox.

Host: 4 CPU, CPython 3.11.15, uid 0, aegis_rust 5.0.1 installed. Base: 92120a2.

=== Premise re-verified before any code ===

Delegated to a wal-durability-engineer subagent (read-only); its load-bearing
citations re-checked here:
  - The handoff is in-process: aegis/proxy/streaming.py TerminalCommitHandoff,
    asyncio.Queue(maxsize=64); submit() drops and counts on QueueFull.
  - The live closures commit with request_bytes=raw_body
    (aegis/proxy/app.py, _commit_stream_terminal / _commit_anthropic_terminal),
    and raw_body is the ORIGINAL, pre-scrub request (app.py: "raw_body (used for
    audit) retains the original; body is the scrubbed copy").
  - commit_forensic_summary derives request_hash and a request preview from
    request_bytes (aegis/core/crypto_audit.py), and does not read _fault_state.
Consequence: replaying the live call unchanged would need customer plaintext on
disk — a new at-rest content surface the WAL itself does not hold unsealed, and
one crypto-shredding would not reach. That is the invariant-relevant decision
this row carried.

=== Decision (PD-R5): no invariant relaxed, so no owner decision required ===

  - OPT-IN, default off (AEGIS_TERMINAL_OUTBOX_ENABLED): the default path is
    unchanged; the REG-049 preflight set the precedent for a default-off control.
  - CONTENT-FREE spool: digests, sizes, counts, labels, flags. No body, no
    preview. Nothing new at rest.
  - Recovered nodes are distinguishable, in the signed payload:
    signature_meaning "stream-terminal-evidence-recovered"; evidence_status
    "recovered-terminal" (validated against a closed set of two).
  - Fail-closed replay: nothing is appended unless the ledger's fault state is
    "healthy", re-checked before every record; otherwise the spool is kept
    byte-identical for the next start.
  - The only new refusal is on the opt-in path: enabled + outbox cannot be
    opened -> startup fails.

=== Implementation ===

aegis/proxy/terminal_outbox.py (new)
  TerminalOutbox: JSON-lines spool, 0600, O_APPEND. record() (event-loop thread,
  stream teardown) is one write, no await, never raises; sync() fsyncs via
  to_thread; mark_done() appends a done marker; compact() rewrites the pending
  set by tmp + fsync + rename + directory fsync. Bounded: max_bytes and 4,096
  pending records, past which a handoff is in-memory only and counted.
  replay_pending(): healthy-only, per-record re-check; dedupe by state_id
  against terminal nodes in ledger.chain_snapshot(); failed replays stay pending.
aegis/core/crypto_audit.py commit_forensic_summary
  + request_digest=(sha256 hex, size) — exactly one of it or request_bytes;
    64 lowercase hex; size in [0, 1 MiB]; request preview empty.
  + evidence_status in {"durable-terminal", "recovered-terminal"}.
  The request_bytes path is unchanged: same hash, same size, same preview, same
  1 MiB error message.
aegis/proxy/streaming.py TerminalCommitHandoff
  attach_outbox(); for_commit(commit, replay=None); submit(..., replay=None)
  spools before enqueueing (so a full queue defers rather than loses); the
  worker syncs, commits, then marks done; a failed commit stays pending.
  Without an outbox or a replay context, behaviour is exactly as before.
aegis/proxy/app.py
  lifespan: open, replay, attach, bind the pending gauge — before the handoff
  worker starts and before traffic; shutdown: stop the handoff, then close.
  Both streaming handlers build a TerminalReplayContext only when the outbox is
  enabled (sha256 of raw_body computed only then).
aegis/config.py: terminal_outbox_enabled / _path / _max_bytes.
aegis/core/observability.py: aegis_terminal_outbox_pending (Gauge),
  _recovered_total, _errors_total (Counters), with no-op fallbacks.

=== Tests: tests/test_terminal_outbox.py ===
test_the_spool_holds_no_content_and_is_owner_only PASSED [  5%]
test_a_record_survives_sigkill_right_after_it_is_written PASSED [ 10%]
test_a_torn_tail_is_skipped_and_counted_not_fatal PASSED [ 15%]
test_replay_commits_a_pending_record_exactly_once_and_marks_it PASSED [ 21%]
test_a_crash_between_commit_and_done_marker_is_not_committed_twice PASSED [ 26%]
test_replay_refuses_a_ledger_that_is_not_healthy PASSED [ 31%]
test_a_recovered_node_matches_the_live_one_but_for_previews_and_markers PASSED [ 36%]
test_a_committed_handoff_leaves_nothing_pending PASSED [ 42%]
test_a_failed_commit_stays_pending_for_the_next_start PASSED [ 47%]
test_a_full_queue_defers_the_commit_to_the_next_start PASSED [ 52%]
test_a_full_spool_costs_durability_not_the_in_memory_commit PASSED [ 57%]
test_a_spool_write_failure_never_reaches_teardown PASSED [ 63%]
test_the_digest_form_is_validated[kwargs0-exactly one] PASSED [ 68%]
test_the_digest_form_is_validated[kwargs1-exactly one] PASSED [ 73%]
test_the_digest_form_is_validated[kwargs2-lowercase SHA-256] PASSED [ 78%]
test_the_digest_form_is_validated[kwargs3-size] PASSED [ 84%]
test_the_digest_form_is_validated[kwargs4-evidence_status] PASSED [ 89%]
test_the_app_replays_the_spool_before_serving PASSED [ 94%]
test_the_outbox_is_off_by_default PASSED  [100%]
============================== 19 passed in 2.31s ==============================

The SIGKILL test is real process death: a subprocess writes one record and
sends itself SIGKILL; the parent asserts returncode == -SIGKILL and reopens the
spool to find the record.

=== Controls — each mechanism disabled in turn, sources restored after ===
CONTROL dedupe disabled: 1 failed, 18 passed in 1.43s
   failed: test_a_crash_between_commit_and_done_marker_is_not_committed_twice
CONTROL fault-state guard removed: 1 failed, 18 passed in 1.46s
   failed: test_replay_refuses_a_ledger_that_is_not_healthy
CONTROL submit does not spool: 5 failed, 14 passed in 1.45s
   failed: test_a_committed_handoff_leaves_nothing_pending
   failed: test_a_failed_commit_stays_pending_for_the_next_start
   failed: test_a_full_queue_defers_the_commit_to_the_next_start
   failed: test_a_full_spool_costs_durability_not_the_in_memory_commit
   failed: test_a_spool_write_failure_never_reaches_teardown
CONTROL worker never marks done: 2 failed, 17 passed in 1.47s
   failed: test_a_committed_handoff_leaves_nothing_pending
   failed: test_a_full_queue_defers_the_commit_to_the_next_start
CONTROL record spools the response preview: 1 failed, 18 passed in 1.40s
   failed: test_the_spool_holds_no_content_and_is_owner_only
(sources compared with cmp after the run: byte-identical; 19 passed)

=== Regression and gates ===
Existing streaming tests with the new handoff code:
  tests/test_streaming_teardown.py tests/test_proxy_streaming.py -> 34 passed
Full suite, extension installed, HERMES_SANDBOX=true, -n auto:
2 failed, 7387 passed, 35 skipped in 91.38s (0:01:31)
  The two failures were docs/MODULE_INVENTORY.md not yet listing the new
  module; regenerated -> tests/test_module_inventory_current.py 5 passed.
mypy --strict aegis: Success, 208 source files.
ruff check / ruff format --check on every changed file: clean.
verify_import_reachability: PASS (225 discovered / 114 reached / 34 / 77).
tests/test_config_surface_inert_fields.py: passes (the three settings are read).

=== Residuals, published (CLM-106, UC-052, BOUNDARIES.md) ===
  - Off by default: the default path keeps the in-process residual.
  - Process death is covered once a record is written; power loss only after
    the worker's fsync, which runs before each commit.
  - Dedupe is exact only within the retained window (max_memory_nodes); a
    record whose node has left the window would be replayed as a second,
    recovered-marked node.
  - Only the teardown handoff is spooled; an inline completion commit is not.
  - A recovered node has empty previews and a replay-time timestamp.
  - Single-process; relies on the WAL's single-writer discipline.

=== Independent review ===
A read-only ledger-commit-auditor review was commissioned before the REG-D32
commit. It returned after 0b68a31 and f60f45e had landed, so what it reviewed
is the committed code (the registry row said "before this commit"; that is
corrected in the row). It ran the outbox, crypto_audit and shredding tests
(78 passed), ruff (clean) and mypy --strict aegis (clean), and probed the real
code from the scratchpad. Verdict: no blockers, five SHOULD-FIX defects, four
of them reproduced by probes, plus four notes. Every SHOULD-FIX is fixed in
the follow-up commit; what was done with each note is stated below.

--- Findings and what was done ---
1. SHOULD-FIX — the spool was unauthenticated and replay signed whatever it
   held: write access to the WAL volume became the ability to have the gateway
   key sign an arbitrary record. Separately, a mistyped record (probe P6: the
   hash as a list) raised TypeError inside the ledger lock, latched
   signing_failed, stayed pending, and so 503'd every start.
   FIXED:
   - Every spool line carries an HMAC-SHA256 over its canonical JSON
     (sort_keys, compact separators, allow_nan=False) under
     HMAC(signing_key, b"aegis-terminal-outbox-mac-v1") (derive_mac_key).
     AEGIS_TERMINAL_OUTBOX_ENABLED now refuses to start without
     AEGIS_SIGNING_KEY.
   - A line that fails the MAC, the strict per-field typing (_context_from,
     _summary_from) or the record version is quarantined, never replayed.
   - commit_forensic_summary type-checks every string field, the digest pair
     (str, 64 lowercase hex; non-bool int in [0, 1 MiB]) and response_hash
     before the lock; a malformed value is a ValueError and the fault state
     stays "healthy" (asserted).
   Tests: test_a_forged_or_foreign_line_is_quarantined_never_replayed,
   test_an_authenticated_line_with_wrong_types_is_quarantined[4 params],
   test_the_digest_form_is_validated_before_the_lock[9 params],
   test_enabling_the_outbox_without_a_signing_key_refuses_startup.
2. SHOULD-FIX — after a torn tail the next record was lost (probe P1: spooled,
   then pending [] after reopen). FIXED: open() appends a newline when the file
   does not end with one, before anything else, and the torn fragment is
   quarantined. Tests: test_a_torn_tail_is_quarantined_and_the_next_record_is_not_lost,
   test_a_torn_tail_is_newline_isolated_even_if_compaction_fails.
3. SHOULD-FIX — compaction silently erased lines it could not parse, including
   another record version after a downgrade (probe P7). FIXED: rejected lines
   are appended to <spool>.quarantine (0600) on open, and only then may the
   spool be compacted. Test:
   test_another_record_version_survives_compaction_in_quarantine.
4. SHOULD-FIX — terminal_outbox_max_bytes below 1 MiB switched the outbox off
   for good (probe P2: 103 accepted, then every one refused, across restarts).
   FIXED: compaction triggers at max(1, min(1 MiB, max_bytes // 2)), at open,
   in mark_done, and in record() before the size check. Test:
   test_a_small_spool_keeps_accepting_records.
5. SHOULD-FIX — a failure after the ledger commit (e.g. reservation.finalize())
   left a landed node pending, so a restart past the retained window could
   commit it twice (probes P3/P4). FIXED: on failure, the worker asks the
   ledger (has_terminal_node, wired as attach_outbox(..., landed=...)) and
   marks the record done when its node is already there. Test:
   test_a_node_that_landed_is_done_even_if_a_later_step_fails.
6. NOTE — the "recovered" label was caller-chosen and unbound. DONE in code:
   the evidence_status parameter is gone; the ledger derives it from the
   request form, and RECOVERED_TERMINAL_MEANING is reserved to the digest form
   (and required by it). DOCUMENTED as a residual in CLM-106: evidence_status
   sits in sampling_params, outside the signature; the signed marker is
   signature_meaning, which AuditNodeOut does not expose. Exposing it changes
   the /audit JSON contract and is left to its owner.
7. NOTE — the module docstring's shredding sentence was wrong. CORRECTED: the
   docstring, CLM-106 and BOUNDARIES.md now say the spool keeps plain request
   and response digests, tenant labels and signer names until compaction, and
   that crypto_shred does not reach it. The reviewer's related pre-existing
   point — stream-terminal nodes keep a plain SHA-256 under shredding (P5) —
   is outside REG-D32 and is registered separately as REG-D44.
8. NOTE — event-loop and shutdown behaviour. The shutdown race is FIXED: sync()
   fsyncs a dup()'d descriptor owned and closed by the worker thread, so
   close() cannot close the descriptor under an in-flight fsync or let its
   number be reused. RESIDUAL, recorded here: compact() still performs two
   synchronous fsyncs (temp file, directory) on the event-loop thread, once
   per compaction threshold, and _load's quarantine append is synchronous at
   startup (before traffic).
9. NOTE — what the tests did not prove. ADDED:
   - test_the_real_app_spools_exactly_what_its_live_commit_signs[openai|anthropic]
     drives the real app to a teardown handoff (raw ASGI send failure, then
     the async-generator finalizer) and checks that the spooled record, replayed,
     matches the node the live closure committed, for both providers (this
     also pins the Anthropic handler's append_stream_window_method=False).
   - The torn-tail, corrupt-through-compaction, small max_bytes and
     non-string digest cases are the tests named under 1-4.
   - "Off by default" now also asserts, inside the running app, that no outbox
     was opened (app.state.aegis.terminal_outbox is None), not just that no
     file exists. Separately, the pending gauge's help text and
     MONITORING_ALERTING.md now say it reads 0 when disabled (the reviewer
     found "absent otherwise" while it exported 0); that wording is not tested.
   Not added: duplicates beyond the retained window (the documented residual,
   not a defect) and replay with shredding on (REG-D44's scope).

--- Controls after the fixes (scratchpad d32_controls_v2.txt) ---
Each control mutates one mechanism in a copy of the source, runs
tests/test_terminal_outbox.py, and restores the file (cmp byte-identical).
CONTROL dedupe disabled: 1 failed, 33 passed
   failed: test_a_crash_between_commit_and_done_marker_is_not_committed_twice
CONTROL fault-state guard removed: 1 failed, 33 passed
   failed: test_replay_refuses_a_ledger_that_is_not_healthy
CONTROL submit does not spool: 7 failed, 27 passed
   (5 handoff tests + the real-app parity test for both providers)
CONTROL worker never marks done: 2 failed, 32 passed
CONTROL MAC not verified: 1 failed, 33 passed
   failed: test_a_forged_or_foreign_line_is_quarantined_never_replayed
CONTROL rejected lines not quarantined: 3 failed, 31 passed
CONTROL landed check ignored: 1 failed, 33 passed
   failed: test_a_node_that_landed_is_done_even_if_a_later_step_fails
CONTROL ledger string check removed: 1 failed, 33 passed
   failed: test_the_digest_form_is_validated_before_the_lock[kwargs8-signer_name]
CONTROL recovered meaning not reserved: 1 failed, 33 passed
   failed: test_the_digest_form_is_validated_before_the_lock[kwargs7-reserved]
CONTROL torn tail not isolated (with the newline-guard test): 1 failed, 34 passed
   failed: test_a_torn_tail_is_newline_isolated_even_if_compaction_fails
CONTROL record-time compaction removed AND threshold fixed at 1 MiB:
   1 failed, 34 passed — failed: test_a_small_spool_keeps_accepting_records
Honest negatives, kept on the record:
  - "torn tail not isolated" alone, against the 34-test file, did not fail:
    open-time compaction rewrote the file and masked the missing newline guard.
    That is why the newline-guard test (compaction disabled) was added; with it
    the control fails, as shown above.
  - "record-time compaction removed" alone (35 passed) and "threshold fixed at
    1 MiB" alone (34 passed) do not fail: each guard covers the other's case.
    Only removing both makes the small-spool test fail. Both stay, as
    defence in depth; neither is claimed as independently tested.

--- Regression after the fixes ---
tests/test_terminal_outbox.py: 35 passed.
tests/test_terminal_outbox.py + test_crypto_audit*.py + test_streaming_teardown.py
+ test_proxy_streaming.py: 121 passed in 34.42s.
mypy --strict aegis and ruff on the changed files: clean (the full battery for
the follow-up commit is recorded in the registry session log).
