Skip to content

Test-Enforced Redaction Claims — Seed the Secret, Assert Its Absence

Pattern

Any written guarantee of the form "we strip / redact / never emit X" must be backed by a test that (1) seeds a fixture containing X and (2) asserts X is absent from the output. A redaction guarantee expressed only in prose — or only in a denylist the tests never exercise — drifts from the code the moment either side changes, and nothing fails.

The positive-only test ("the numbers we do want are present") is not enough: it proves the happy path, not the guarantee. The guarantee is a negative property, so it needs a negative assertion against a fixture that would trip it.

When this bites

The trap fires whenever all three hold:

  1. A privacy/security claim promises some value class is removed (PII, secrets, identifying attributes, tokens, hostnames).
  2. The mechanism is a denylist (_IDENTIFYING = {...}, a regex set, a field allowlist) — easy to fall out of sync with the documented set.
  3. The tests assert only that wanted data survives, never that a seeded member of the redacted class is gone.

Example: SPEC-129 (H1 / SEC-1)

configs/monitoring/otel-receiver.py documented — in docs/operations/monitoring.md and the patch README — that identifying attributes including terminal.type were stripped before anything is written to metrics.jsonl. But the _IDENTIFYING denylist omitted terminal.type, so it flowed straight through normalize() into the persisted file. The OTLP fixture never contained terminal.type, and the sanitize test only grepped for the four keys that were in the set — so 19/19 tests passed while the written privacy guarantee was false.

Three reviewers converged on it (security-sentinel, kieran-python, document-quality) in a follow-up pass — exactly the "reviewer finds it manually after green tests" symptom.

The fix had two halves, and both are load-bearing:

  1. Close the code gap — add terminal.type to _IDENTIFYING.
  2. Make the guarantee testable — seed the fixture with terminal.type / iTerm.app and extend the sanitize assertion to prove it is absent from metrics.jsonl:
# fixture now carries the value the docs promise to strip
{"key": "terminal.type", "value": {"stringValue": "iTerm.app"}}

# assertion fails if ANY documented-stripped key survives
! grep -qE "user\.email|account_uuid|organization\.id|session\.id|terminal\.type|iTerm" "$metrics_out"

Half 1 alone fixes today's bug; half 2 is what stops it regressing when someone edits the denylist or the docs next.

How to apply

  • For every "we strip/redact/never emit X" sentence, ask: which test seeds an X and asserts it's gone? If none, that sentence is unverified.
  • Seed the fixture with a distinctive sentinel for the redacted value (iTerm.app, SECRETTOKEN_...) so a leak is unambiguous in output.
  • Keep the assertion's key list in lockstep with the documented set — when the docs list N identifying attributes, the negative grep should cover all N.
  • Prefer this negative test to live at the review gate, since redaction drift is a classic finding that survives happy-path suites (see structural-test-assertions.md for the sibling "presence ≠ correctness" trap).

Signal

A denylist named after a security/privacy property (_IDENTIFYING, _SECRETS, REDACT) with no fixture that contains a member of that set is a smell. The guarantee is only as strong as the seeded negative test behind it.