Changed question. The filter is now also an availability surface. Three new inputs to survive: very deep nesting, very large documents, and text that drives catastrophic backtracking in the detector.
Design conclusions, reasoned rather than measured.
One. Budget each resource at the layer that spends it, and enforce incrementally with early abort rather than checking after the work is done. Separate caps on raw input bytes before parsing, container depth, total node count, keys per object, decoded length of each string, numeric literal length, matcher steps, and one wall clock deadline as a last resort. Node count matters independently of bytes, because a small document can still expand into very many nodes.
Two. Counted budgets should be the real gate and wall clock only a backstop. Counted units are reproducible, so verdicts and tests stay deterministic; a design gated on elapsed time produces machine dependent and load dependent results.
Three. Compatibility normalization can expand a string, so a length cap measured before normalization does not bound the cost after it. One known worst case is a single Arabic ligature code point expanding to roughly eighteen characters. Cap the normalized length as well, or cap the expansion ratio.
Four. The earlier allowlist decision also removes the backtracking class, which was an unexpected convergence. Reducing detection to character class scans and narrow per-field shapes means a linear time matcher suffices, and a matcher with no backtracking has no catastrophic case to budget. A step limit is then only defense in depth for engines that still backtrack; on an engine offering no step limit, the remaining options are a watchdog in a separate thread or process, which is itself an argument for changing engine.
Five. Remove unbounded recursion from the filter entirely, using an explicit heap worklist with a depth counter. A depth cap must produce a clean rejection, because native stack exhaustion is fatal or barely recoverable depending on runtime, and a filter that dies may fail open if the caller error path skips it.
Six. The critical rule for failing closed: when any budget aborts, withhold the whole document, never the portion that was successfully scanned. Partial scanning is precisely the attacker goal, since burying an identifying value past the abort point converts a denial of service into a disclosure. Structure the filter so deny is the initial value and the only assignment to allow is the final statement after successful completion, making every early exit a deny. Sharing fails closed while the primary task continues, because the verdict gates only the optional sharing path.
Seven. Warning output needs its own budget. Emit a closed enumerated set of category counters plus which budget fired first, one aggregate record per pass rather than one per offending field, and cap distinct categories with a single truncation flag beyond that. Unbounded warning volume is itself a denial of service against the log pipeline and the on call responder. Since no input bytes enter the message, log injection through control characters disappears as a side benefit. Translate engine level errors into category codes, because some matcher limit errors include a fragment of the subject text.
Eight. Derive the numbers from the legitimate schema rather than from guesses about the attack: take a high percentile of real payload shape per dimension and allow a small multiple. Where the envelope is fixed, depth is statically known and the cap should be near that, not a large round number. Keep budgets configurable under a compiled ceiling that configuration cannot raise.