## When this applies
Reach for this when you are writing or reviewing a component that inspects an outgoing structured document and blocks it if a string carries an identifier that must not leave: a filesystem path, an account name, a host name, a tenant name. It also applies after such a filter has missed something, or after untrusted content made the filter itself hang or die.
The budget half applies equally to inbound validation. The decoded leaf half is specific to egress, where the document is usually built by your own code and only some leaves are attacker influenced.
## The failure it prevents
Three silent failures, each leaving a green dashboard.
1. **The filter inspects a different string than the receiver reconstructs.** A pattern written for ordinary text is run against the serialized form. Escaping means the separator the pattern expects is not present in those bytes, so the filter passes a document whose decoded content carries the identifier.
2. **Parser ambiguity creates bypass.** Two parse passes with different parsers, one for budget enforcement and one for validation, risk different interpretation of structure. If parsers differ on how they handle escaping or malformed input, validation misses what the receiver reconstructs.
3. **The filter becomes the outage.** Hostile content drives unbounded work, unbounded memory, or stack exhaustion. A filter that kills the process is worse than no filter, because it converts a privacy control into an availability bug.
## Core principle
**Parse once to canonical form. Validate that exact representation. Emit exactly those validated bytes with no transformation.** This eliminates parser ambiguity, time-of-check-to-time-of-use gaps, and ensures the validated representation matches the emitted bytes exactly.
## Procedure
1. **Parse to canonical form with all budgets enforced.** Use a single parse pass that produces the canonical representation. Enforce size, depth, breadth, and work budgets during this parse. Never perform a separate streaming parse for budget checks followed by a canonical parse for validation.
2. **Validate decoded leaves, never the wire form.** Walk the parsed structure and check every decoded string leaf. Include object keys; a key can carry an identifier just as a value can.
3. **Guarantee immutability between validation and emission.** The validated object must not be mutated between validation and serialization. Use immutable structures, or serialize immediately after validation in the same call frame.
4. **Emit exactly the bytes you validated.** Never validate one serialization and transmit another produced by a different call, library or retry path. The serializer must produce byte-identical output from the validated canonical form.
5. **Put the check at the single component that performs the write.** Enforce it with a wrapper type the transport requires and only the validator can construct, so no emitter can route around it.
6. **Canonicalize in the correct order.** Normalize, apply full case folding, then normalize again. Folding does not preserve normalization form, so a single normalization followed by folding leaves the result off the fixed point and two strings that should match can fail to. A precomputed combined normalization and case folding mapping does the same job in one step. Use full case folding rather than locale sensitive lowercasing, or the dotted and dotless letter i diverge under some locales.
7. **Keep an explicit separator and confusable table.** Compatibility normalization maps the fullwidth solidus but leaves the division slash and big solidus untouched. Do not assume separator unification falls out of normalization.
8. **Make a per field allowlist grammar the primary control.** Enumerations, bounded character set identifiers, integers. Shape matching is a tripwire behind it, not the gate. The grammar is mandatory, not preferred, for string arrays and sibling string fields, because an identifier split across leaves passes every per leaf shape check and is rejoined by any consumer that concatenates.
9. **Refuse nested encoded documents, or give the inner parse its own full budget stack.** Re parsing a decoded leaf hands leaf level influence control over inner depth and width.
10. **Place every cap in the component that performs the allocation.** Byte count in a counting reader during read, not after buffering. Decompression ratio enforced mid inflate. Nesting depth inside the parser, because a recursive descent parser exhausts the stack before any walk over the result begins, and stack exhaustion is often not catchable. Node and leaf counts in the parser for breadth limits; a single level with massive key count exhausts memory without exceeding depth limit. Per leaf length inside the parser string scanner, aborting the token, for exactly the same reason as depth. Then decode pass count and expansion ratio, matcher steps, an aggregate work counter, a wall deadline on a monotonic clock, and a document rate limit per time window.
11. **Use one work currency.** Every stage must charge the same counter, otherwise the aggregate bounds nothing. Before shipping, multiply the per stage caps out and check the product against the aggregate; if the per leaf matcher allowance times the leaf cap exceeds the global counter, one of the two numbers is decorative. Per-field work budgets multiply across thousands of fields.
12. **Return a three state verdict rather than raising.** Pass, reject because a rule matched, indeterminate because a budget was exhausted. Both non pass states suppress sending, so sharing fails closed, while the caller treats any non pass as do not send and continues, so the primary work fails open. Map any unexpected internal error to indeterminate at the boundary. Keep the two non pass states separate in metrics: a rising indeterminate rate is an exhaustion signal, a rising reject rate is a content signal. Classify a decode budget miss as indeterminate, not as a rejection.
13. **Emit telemetry that cannot reconstruct the value.** Category, which limit was hit, and magnitude in coarse buckets only. Never the value, a leaf fragment, the matcher input, or a truncated preview, since a leading or trailing fragment is exactly the identifying part. Key counters on the schema path with map keys elided, never on an instance pointer, and flush summaries on an interval rather than writing one record per rejected document, which would amplify the attack into the logging path. Cap distinct metric keys with an overflow bucket, because unbounded key cardinality is its own exhaustion vector. If you keep a keyed digest for correlating repeat rejections, rotate the key; digest width and retained key count are independent limits, so a bounded map does not force a narrow digest. Filesystem paths have low entropy and are brute forceable even with keyed hashing, especially when combined with context from adjacent logged fields.
14. **Harden the error paths.** Catch parser and validator errors at the boundary and construct a genuinely new error carrying only an identifier, with no cause or context chain, because some libraries expose the offending input as an attribute rather than only in the message text. Disable per frame local variable capture in any error reporting agent.
## Limits and non goals
- A backtracking engine can go exponential on a few hundred bytes, so a per leaf length cap is not a substitute for engine choice. Use a linear time automaton engine, a step limit genuinely checked inside the match loop, or a separate process with a kill timer. A timeout on an observer thread usually cannot interrupt a match already running and reports a bound it does not enforce. A linear time engine needs its own cache memory cap.
- Rules derived from the actual secret turn the pass or reject outcome into a confirmation oracle for anyone who can inject a leaf and observe whether sending happened. Keep rules shape based.
- Free text fields cannot be given a narrow grammar, so the tripwire becomes the primary control exactly where risk is highest. Accept that residual risk explicitly rather than assuming the grammar covers it.
- All numeric caps need calibration against real traffic. A decompression ratio cap in the low tens is tight for this data format, where much higher ratios are ordinary, and will produce indeterminate verdicts on legitimate documents.
- Streaming validation bounds memory to depth plus the largest buffered leaf, not to depth alone.
## Verification status
Reasoned analysis only. None of this has been confirmed by executed tests. The experiments that would confirm it:
- Inputs one below, at, and one above every cap, asserting a verdict and no crash, with the deep nesting case run in a child process so a crash is observable rather than killing the harness.
- A deliberately pathological matcher input, asserting the bound holds as measured wall clock time rather than as documented behaviour.
- A flood, asserting a bounded number of log records and a bounded number of distinct metric keys.
- A synthetic marker fed through every rejection path, asserting it appears nowhere in captured output including exception text and metric labels.
- Encoding variants of one synthetic marker, asserting they all reduce to the same verdict, plus an assertion that the validated buffer is byte identical to the transmitted one.
- Parser ambiguity test: feed the same document to the budget-checking parser and validation parser separately, assert they produce equivalent structure for all valid inputs.