# SESSION-0026.9 — flotilla audit finding L1 remediation

Parent: SESSION-0026.0-TRANSCRIPT-2026-05-28T13-46--INPROGRESS.md

Mode: REMEDIATION (operator authorized). Subsession of OHM session 0026.
Scope: fix flotilla audit finding L1 in
`ohm-rfc-app-flotilla/ohm_rfc_app_flotilla/deploy.py` + a test.
No commit, no push, no VERSION bump, no VM/deploy actions.

## Finding L1 (recap)

`_PhaseRunner.run` persisted each phase's `detail` (which can embed a
failed command's `stderr[:240]`) into the `deploys.phases` JSON column
via `deploy_log.append_phase` WITHOUT redaction. Secret redaction
(`_ResolvedSecrets.redact_live`) was applied only to the separate
`error_summary` field in `_finalize_failure`. A secret surfacing in a
command's stderr would land unredacted in flotilla state — a §3
invariant 1 / §10.2 leak.

## Design choice — how the redactor was threaded

- Added an OPTIONAL field `redactor: _ResolvedSecrets | None = None` to
  the `_PhaseRunner` dataclass, plus a small `_redact(text)` helper that
  calls `redactor.redact_live(text)` when the redactor is present and
  falls back to the raw text when it's None.
- Chose OPTIONAL over REQUIRED because it's the lower-churn option:
  it preserves the ability to construct a `_PhaseRunner` without a
  resolved-secrets context (none of the current tests do, but the
  nullable style matches the rest of the module's injected-dependency
  shape), and required no signature changes at any other call site.
- `run_deploy` constructs the only production runner and now passes
  `redactor=resolved`. Phases run inside the `_ResolvedSecrets`
  `with`/try scope, so the bytearrays are still live when `_redact`
  runs during the phase — redaction happens before `scrub()` zeroes
  them at scope exit, exactly as the finding requires.
- Both the success-path detail and the failure-path detail are now
  redacted before they reach the `PhaseRecord` (persisted JSON) AND
  before the `on_progress(...)` strings that interpolate the detail
  (those can reach operator terminals/logs).
- `_finalize_failure` still redacts the summary it builds from the
  last phase's detail; since that detail is now already redacted, the
  second pass is idempotent (no behavior change).

## Diff summary

deploy.py:
- `_PhaseRunner`: new `redactor` field + `_redact` helper; `run`
  computes `safe_detail = self._redact(...)` on both paths and uses it
  for the `PhaseRecord.detail`, the `on_progress` FAILED message, and
  the returned detail.
- `run_deploy`: `_PhaseRunner(...)` construction now passes
  `redactor=resolved`.

tests/test_deploy.py:
- Extended `test_run_deploy_redacts_secret_bytes_from_error_summary`
  to ALSO load the persisted row and assert the failing phase's
  `detail` contains `[REDACTED]` and not the stub secret, and that no
  phase detail anywhere carries the raw stub bytes.

## Test added / extended

Reused the existing redaction test (which drives a real `run_deploy`
with a `LeakyFail` ssh stub that vomits a fake secret into stderr) and
added the `deploys.phases` assertions. Stub value is the module's
`STUB_SECRET_BYTES = b"NEVER-LEAK-stub-secret-payload"` — a fake, never
a real secret.

## Test-run result

`.venv/bin/pytest tests/ -q` → 157 passed in 0.28s. Suite was green
before the change (full-suite run, not a known-broken baseline).

## Cut state

Code change + test in place. No commit, no push, no VERSION bump, no
VM/deploy action. Driver handles release.
