ADR-0003: The value-free error, log, and telemetry boundary (Critical Rule 1)

Copy Markdown View Source

Status: Accepted Date: 2026-08-12 Deciders: replicant maintainer (1.0 hardening D8; records the Plan-1 decision that shipped in 0.1.0)

Context

Every row value that flows through this library must be assumed to be PII or a secret. Two facts make naive error handling dangerous:

  1. The vendored pgoutput byte parser (walex/MIT, credited in NOTICE) raises on malformed WAL and its exception messages embed the raw bytes it could not parse — i.e. row values.
  2. A sink call is a user-code boundary: a raise/throw/exit from sink code (e.g. a GenServer.call timeout) can embed the transaction's row values in its reason term.

If any of these reached an OTP crash log, a Logger call, a telemetry event, or a surfaced %Error{}, the library would become a PII-exfiltration path. The error/log/telemetry surface is exactly where operators route to centralized (and often replicated) observability systems.

Decision

A single value-free boundary wraps every path that can surface a value. The boundary is enforced at four mechanically distinct points:

  • Decode boundaryReplicant.Decoder.decode/1 runs the vendored parser inside a rescue and catch, scrubbing any fault into a %Replicant.Error{reason: :decode_failure, shape: inspect(mod)} that keeps only a structural module name, never bytes or values. (lib/replicant/decoder/decoder.ex.)
  • Assembler boundaryReplicant.Assembler.handle_message/2 wraps the per-message work in rescue/catch, AND every sink-call site (deliver_now, deliver_message, flush_sink_batch, apply_chunk, commit_txn checkpoint write, checkpoint/0 read) independently scrubs raise/throw/exit to a value-free :sink_failed / :checkpoint_store_failed / :snapshot_failed reason (a spill-Reader fault raised while the sink forces a lazy changes enumeration is distinguished as :spill_io_failed, still value-free). This includes the batch-flush and snapshot-chunk paths that run OUTSIDE handle_message/2's rescue. (lib/replicant/assembler.ex, lib/replicant/assembler/batch.ex, lib/replicant/assembler_server.ex, lib/replicant/snapshotter.ex.)
  • Telemetry allowlistReplicant.Telemetry defines @allowed_meta_keys (LSNs, table names, counts, durations, error-class atoms, slot_name, attempt, transactional) and validate!/1 RAISES on any off-allowlist key. Telemetry metadata is the only external observable channel besides the halt itself. (lib/replicant/telemetry.ex.)
  • Halt reason discardReplicant.Supervisor.halt/2 accepts a reason for symmetry but DISCARDS it (_reason); the halt tears the pipeline down from an unlinked spawned process (to avoid the self-termination deadlock), and the only outward signal is the value-free telemetry [:replicant, :*, :halted]. (lib/replicant/supervisor.ex.)

There is no Logger usage and no IO.* usage anywhere in lib/ — the library's only observable channels are telemetry and the halt.

Options Considered

Option A: Log, then redact

Cons: Logging infrastructure (backends, formatters, structured-log shippers) is a wide and version-varying surface; a redaction layer that runs after a value is formatted can miss a new backend or a %Inspect path. Rejected — the value must never be materialized in a loggable form.

Option B: Trust callers (sinks, operators) not to log values

Cons: The library cannot enforce caller behavior, and the most dangerous values are the ones embedded in sink exit reasons and vendored-parser raises — neither of which a caller controls. Rejected.

Option C: Value-free boundary at every surfacing path (adopted)

DimensionAssessment
ComplexityLow — one scrapping discipline applied at each surfacing site
SecurityHigh — no value can reach an error, log, telemetry event, or crash dump by construction
DiagnosabilityTrade: errors carry a structural shape + fixed atom reason, never the offending bytes

Trade-off Analysis

The boundary trades diagnostic detail (an operator sees :decode_failure + shape: "Postgrex.Error", never the bad bytes) for a hard guarantee that the error/log/telemetry surface is not a PII path. That trade is load-bearing for a CDC consumer whose entire payload is user data. The structural shape field is enough to triage (which subsystem faulted) without the bytes; deeper diagnosis is done against the source WAL with direct DB access, not via library emissions.

Consequences

  • Easier: Operators can route Replicant telemetry and errors to any centralized observability system without a redaction layer; a malformed WAL or a crashing sink cannot leak values through the library's own surfaces.
  • Harder: Every new code path that can surface a value (a new sink-call site, a new fault class) must add the same rescue/catch + scrub. A future edit that drops the catch on a sink-call path is a Critical-Rule-1 regression; the closeout security lens exists to catch it.
  • Revisit if: the library ever ships a Logger-based diagnostic mode — that would need its own value-scrubbing layer and is currently a non-goal (no Logger in lib/).

Verification

grep -rn 'Logger\|IO\.\|IO\.inspect' lib/ → zero matches. Every sink-call site lists an explicit value-free catch (corroborated by the security audit at the 1.0 readiness review, 2026-08-12). The decode boundary's scrub is exercised by the conformance suite's malformed-byte fixtures.