> ## Documentation Index
> Fetch the complete documentation index at: https://sponson.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# ADR 0009: Single-point redaction

> Architecture decision record 0009. Status: Accepted. The redactor was rebuilt in v0.2.

Date: 2026-10-10

## Status

Accepted. The redactor was rebuilt in v0.2.

## Context

Sponson handles secrets on every run: `{ secret }` references, provider credentials, sensitive outputs such as a
database connection string. Its output goes to terminals, CI logs, pull request comments, MCP results and receipts
committed to a branch. A secret that leaks into any of them is effectively public.

In v0.1, redaction was applied in several places and each was incomplete. It matched only the raw bytes of a value,
while providers echo values JSON-escaped, URL-encoded or base64-encoded. It registered only values resolved inside
the apply loop, so a provider error during `plan` could print one. It ran on serialized JSON by string replacement,
which once turned `"ok": true` into `"ok": [REDACTED]` because a secret's value was `true`. And error text was
truncated before it was redacted, so the tail of a secret survived.

## Decision

One `Redactor` instance (`packages/core/src/redact.ts`) lives for the whole command. It is created in
`packages/cli/src/main.ts`, passed to the engine in `RunOptions`, and everything that leaves the process passes
through it.

* **Register first.** Before any output exists, the engine registers credential-looking environment variables, every
  secret the active lines reference (every command resolves them, `plan` included, so they can be masked), and every
  sensitive output, including the password inside a URL or DSN.
* **Every encoding.** A registered value is masked raw, JSON-escaped, URL-encoded, base64/base64url at any alignment,
  whitespace-flattened and line by line, and by its head or tail when a long value was cut off.
* **Redact, then truncate.** Adapters call `actx.redact` on provider text before shortening it.
* **Structured output is redacted field by field.** JSON is produced only by `serialize()`
  (`packages/cli/src/output.ts`): `redactDeep` rewrites string leaves, skipping enum fields (`status`, `kind`, `state`,
  `code`, ...), and only then serializes. Serialized JSON is never string-replaced.
* **Diffs carry data, not text.** A diff side whose value matches a registered secret becomes `{ state: "sensitive" }`.
* **Receipts never hold values**, only keyed hashes and non-sensitive outputs.
* Values shorter than four characters cannot be masked without destroying ordinary text; they are not masked, and
  the CLI warns, without echoing them.

## Consequences

* Property invariant I1 (no registered secret in any output, receipt or plan text) holds by construction, and the
  scenario runner checks it after every step; the secrets journeys include the receipts branch history and PR
  comments.
* Code that resolves a secret must never format output itself (CONTRIBUTING.md, Style).
* Adding an output channel means routing it through the redactor; there is no second mechanism to keep in sync.
* Over-masking is possible: ordinary text equal to a registered value is masked too.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.