Skip to content

8 min read

An enforceable control does not depend on the model reading it

On this page

The security engineer's incident ticket is still open. Taking her proposal apart established that 900 tokens of policy leaves a 1.5% residual on 800 opportunities a quarter, which is a fact about the world and not a plan. The question she asks next is the right one: what would you do instead, and how do you know it holds on the run where the policy did not?

The distinguishing property of the answer is that none of it depends on the model reading anything. A control that runs — in the logger, in CI, in the merge gate — produces the same outcome whether the agent was attentive or distracted, whether the config file was loaded or truncated, and whether the model was Opus or something the team switches to next quarter. That is what makes it an enforcement rather than an influence.

Three controls, and where each of them sits

Flowchart: Agent writes a log call → Lint rule in CI; Lint rule in CI → Build fails (known bad pattern); Lint rule in CI → CODEOWNERS review gate (passes); CODEOWNERS review gate → Human reviews (path touches customer data); CODEOWNERS review gate → Merged (other paths); Human reviews → Merged; Merged → Runtime: redacting serializer; Runtime: redacting serializer → Log sink (field on the allowlist); Runtime: redacting serializer → Dropped or hashed (everything else).

The three gates sit at different times and catch different things, which is why the answer is three controls and not one. The lint acts at authoring time and is free per occurrence but only knows the patterns it was taught. The review gate acts at merge time, catches novel cases the lint has never seen, and costs human minutes. The redactor acts at runtime, on every log line regardless of who wrote it, and is the only one of the three that holds against code the other two never inspected.

The redactor is where to start, because it is the one that converts the failure from a leak into a non-event.

ts
// packages/telemetry/src/redact.ts
const LOGGABLE_USER_FIELDS = new Set(["id", "tenantId", "plan", "createdAt"]);

export function redactUser(value: unknown): unknown {
  if (!isPlainObject(value)) return value;
  const out: Record<string, unknown> = {};
  for (const [key, v] of Object.entries(value)) {
    out[key] = LOGGABLE_USER_FIELDS.has(key) ? v : "[redacted]";
  }
  return out;
}

// Registered on the logger for anything typed as a customer record.
export const logger = pino({
  serializers: { user: redactUser, customer: redactUser, account: redactUser },
});

The important design choice is the allowlist. A denylist of sensitive field names — email, phone, address — fails the moment somebody adds contactEmail or billingAddr, which is precisely the second failure mode from the previous chapter. An allowlist fails in the other direction: a newly added safe field gets redacted until someone adds it, which produces a mildly annoying log line and no incident. Choosing the failure direction is most of the work in designing a deterministic control, and the direction that degrades toward silence is the one to pick when the alternative degrades toward disclosure.

The cost is small and worth measuring rather than assuming. Serializing a 30-field object through the filter adds about 15 microseconds per log call on this team's hardware; at their busiest instance's 4,000 log lines per second that is roughly 6% of one core, which is real and is not close to the threshold where anyone would trade it against PII exposure. It is worth knowing the number, because "we can't afford to redact at runtime" is an objection that gets raised and almost never survives measurement.

Each control buys determinism by giving up coverage

ControlWhen it actsCatchesMissesOngoing cost
Redacting serializerRuntime, every callAny customer object passed as a named field, from any authorFree-text interpolation; sinks that are not the logger, a sink being any destination data is shipped to and stored in~15 µs/call; allowlist upkeep
Semgrep ruleCI, every PRKnown-bad shapes: template strings, spreads into log callsDynamically computed keys; new sink APIsRule maintenance; suppression review
CODEOWNERS gateMerge timeNovel cases; intent; things no rule anticipatesWhatever the reviewer does not look atHuman latency, degrades with volume
Policy text in configEvery session, probabilisticallyShifts the distribution across all of the above~1.5% residual; higher under competing attentionTokens; drift across copies

The redactor's largest gap is the log call that never passes an object at all:

ts
logger.error(`checkout failed for ${user.email}: ${err.code}`);

There is no user field for a serializer to intercept; the address is already a substring of the message by the time the logger sees it. That is what the lint is for. The rule below is written for Semgrep, a static-analysis tool that matches patterns against the parsed structure of a program rather than against its text, so $U stands for any expression and a single pattern catches every spelling of the same shape. It is short enough to read in full:

yaml
# semgrep/no-pii-in-logs.yml
rules:
  - id: no-pii-in-log-message
    languages: [typescript]
    severity: ERROR
    message: >
      Customer fields must not be interpolated into log messages. Pass the
      record as a named field (user/customer/account) so the redacting
      serializer can filter it.
    pattern-either:
      - pattern: logger.$LEVEL(`...${$U.email}...`, ...)
      - pattern: logger.$LEVEL(`...${$U.phone}...`, ...)
      - pattern: logger.$LEVEL({..., ...$U, ...}, ...)

The lint is deterministic where it applies and blind everywhere else. It will not catch const addr = user.email; logger.error(\failed for ${addr}`)`, because the value has been laundered through a local variable, and chasing that properly means dataflow analysis — following a value through assignments and function calls instead of matching the shape of one expression — with a false-positive rate that will get the rule disabled within a month. That is the honest limit: a lint over syntax catches syntax.

The review gate covers what neither of the others anticipated, and it is one file. A CODEOWNERS file tells the code host which team's approval a pull request needs, decided by the paths the request touches, and three lines of it carry the gate:

text
# .github/CODEOWNERS
/packages/telemetry/                 @acme/security
/services/*/src/domain/customer/     @acme/security
/semgrep/                            @acme/security

Note the third line. A control that the agent can edit is not a control, and an agent asked to make CI pass has an entirely reasonable path to editing the rule that is failing. Putting the rules themselves behind the gate is what closes that loop, and it is the step teams most often forget.

The gate is also the control that degrades fastest under volume, which is worth pricing before relying on it. On this team the paths listed above appear in roughly thirty pull requests a month, and a security reviewer spends four to six minutes on each, so the gate costs about three hours a month of one person's time and adds a median of nine hours to the merge latency of the changes it covers. That is affordable at thirty PRs and it is not affordable at three hundred, and the failure at three hundred is not that reviews stop happening — it is that they become approvals, which looks identical in the audit log and catches nothing. Any argument for a human gate needs a stated volume at which it is expected to break, and a plan for what replaces it then; usually the plan is to turn whatever the reviewers keep catching into a lint rule and narrow the gate to the paths that still surprise them.

Applying the sorting exercise to 900 tokens

With those three in place, the security policy goes through the same classification as any other config diff. Most of it does not belong in a config file, and the 900 tokens decompose into four destinations rather than being trimmed to 300.

The parts that describe what the organisation promises customers, the legal basis, and the retention periods belong in the policy document, which is written for humans and auditors and does not change what an agent types. The parts that describe how PII must be handled at runtime become the redactor. The parts that describe forbidden shapes become the lint rule. The parts that describe who must approve changes to customer-data code become CODEOWNERS. What is left is a residue that genuinely belongs in the file, and it is four lines:

markdown
- Never pass a customer record to `console.log` or `logger.*` directly.
  Log `{ user }` and let `packages/telemetry` redact it.
- `pnpm lint:pii` runs the PII lint locally; CI runs it on every PR.
- Adding a field to the loggable allowlist requires review by @acme/security.

That is about 60 tokens against 900. Every line of it is executable or checkable in chapter 1's sense: the first names the API to use, the second names a command that either runs or does not, the third names an owner. And each line's purpose is to steer the agent toward the enforcing control, which is the shape config entries take once the enforcement lives elsewhere. The file stops being where the rule is stated and becomes an index into where the rule is enforced.

Name what the alternative fails to cover

The proposal that carries a review is the one that also states its own gaps, and this one has four worth saying out loud.

No control here covers a new sink. The day someone adds a metrics tag, an outbound webhook, or a Sentry breadcrumb — one of the trail of recent events an error-tracking service attaches to each report it stores — carrying a customer field, all three gates are looking at the logger and the leak leaves by a door nobody has instrumented. The mitigation is procedural: adding a new egress path is a CODEOWNERS-gated change, which converts an unknown-unknown into a design review.

No control here covers a field nobody classified. The allowlist is a statement about User, and a SupportTicket type with a reporterEmail field is outside every mechanism on the page until someone extends the serializer registration. Classification is human work that has to happen once per type, and its coverage is exactly as good as the last person's diligence.

The lint will accumulate suppressions. Within a quarter there will be // nosemgrep comments, some legitimate and some not, and unreviewed suppressions convert a deterministic control back into a probabilistic one. The countermeasure is a weekly report of new suppressions to the owning team, which costs somebody fifteen minutes and is the first thing to lapse.

And the residual does not go to zero, it moves. The measured 1.5% of log statements carrying a customer field becomes something much smaller and no longer measured, because the redactor makes the failure invisible in the artifact where it used to be visible. A control that hides its own failures needs its own telemetry — a counter on redaction events, alerting when a field starts being redacted at an unusual rate, which is the signal that a new type is flowing somewhere it should not.

Saying those four things is what makes the proposal credible to a security engineer, and it is what the 900-token version could not do, because a policy that claims to prevent a class of failure has no vocabulary for describing what it misses.

The file you are left with

Across this topic the config file has been reduced at every step, and the pattern in what survived is the useful thing to carry forward. What stayed is short, high-reach, and either executable or checkable: build and test commands, two architectural invariants, a hostname, a trailer format, four lines pointing at a redacting logger and a lint. What left went to a skill with a measured firing rate, to a script that fails loudly, to a lint rule, to a merge gate, or to nowhere at all.

The next time you open one of these files — your own, or one you have inherited with 180 lines and eleven authors — the productive first question is not which lines are good. It is which of these lines is the residue of a control that exists somewhere else, and which is a control someone hoped would work because it was written down. The first kind earns its tokens. The second kind is an unbuilt piece of engineering with a sentence standing in for it, and the sentence is doing the one job it cannot do.

What belongs in a config file — read the current version at https://daviddominguez.dev/learn/en/what-belongs-in-a-config-file/an-enforceable-control-does-not-depend-on-the-model-reading-it

Back to top