Transform-parameter bounds
This document specifies the bounds on the transform.params values of a linkage-terms document, with their constant values and enforcement points: the one declared type each value takes, refused where the document is decoded when it declares another, and the bounds on the values whose size drives unbounded per-row work, with the ceilings on the transformed value, the assembled keys and the per-row transform work. It is the implementation-level complement to the Channel security overview in SECURITY_DESIGN.md, which covers what each control protects against and why the design is sufficient. It does not cover the count bounds on a params record (see PARSED_INPUT_BOUNDS.md) or the regular-expression dialect and the pattern size cap (see TRANSFORM_REGEX_DIALECT.md). Intended readers are security auditors and implementors.
Every net-new control specified here is subject to the explicit pre-release security review required by CONTRIBUTING.
Transform-parameter declared types
Every transform.params value a standardization function reads has one declared type, and a document declaring another type for it is refused where the document is decoded. The table is packages/core/src/config/transformParamTypes.ts, keyed by function name and then by the camelCase param name (camelizeKeys runs before validation, so a document's include_original is includeOriginal there). The types are the ones the reference states: text for every literal, pattern, and format (replacement, char, algorithm, value, pattern, delimiter, inputFormat, outputFormat, default), a list of text for null_if's values, whole numbers for substring's start and length and pad_left's length, and true/false for split_on's includeOriginal. A param no function reads is not typed at all, so a param name this build does not implement keeps whatever the document wrote and is bounded only by the entry count and string content bounds. An ABSENT param is how a step takes the function's documented default; null is a wrong type like any other, so "declared absent" has exactly one spelling. The four raw-pattern functions are the exception, having no default to take: a replace_regex, extract_regex, filter_regex, or split_on step declaring no pattern is refused at the same decode, since the engine would otherwise compile whatever an absent value renders as.
Enforcement is a per-step check everywhere a schema admits steps: the per-audience transformStepSchema(options) factory (packages/core/src/config/linkageTermsSchema.ts), instantiated as LinkageTermsSchema for a partner token -- covering every path that parses partner terms: parseLinkageTerms, the invitation-token decode (InvitationTokenSchema), ExchangeSpecSchema, and the signed receipt's carried partnerTerms -- and as LinkageTermsSchemaForItsAuthor for the document the operator wrote, read through safeParseLinkageTermsTheReaderWrote -- and StandardizationStepSchema (packages/core/src/config/standardizationSchema.ts), which covers the operator's own cleaning pipeline. The message names the function, the param in the snake_case spelling the document writes it in (snakeizeKey, packages/core/src/utils/camelizeKeys.ts), and the type it got, and the issue path locates the offending value; no partner value is echoed into it. The spelling matters because the path is cut at params wherever it is rendered to a reader -- that record's keys are the author's own, and two on-disk spellings camelize alike -- so the message is the only part that names the key the reader can find in the file. A text param's refusal also names the remedy -- quote the value, or leave the key out -- where the reading party wrote the document: the CLI's config-file reader (readConfigLinkageSource, apps/cli/src/config/loaders.ts), which both the operator's own standardization block and its linkage_terms block pass through. A partner token keeps the bare type statement: an acceptor reading a refusal of a partner's invitation, or of partner terms off the wire, has no document to edit. One path crosses that line: alcove verify-receipt --partner-terms reads the partner's own terms file through that same config-file reader, so a partner-authored file compared there carries the remedy too, although the operator did not write it. That flag is the override rather than the route the command takes by default: the partner's terms travel in the signed receipt's unsigned envelope, which parseDualSignedRecord reads through safeParseLinkageTerms -- the same capped camelize pre-pass and LinkageTermsSchema a partner's terms off the wire take, so the width and depth bounds hold over the carried document too -- with its type-statement refusal, so a run verifying a receipt on that default route reads no partner-authored file through the config reader at all. A run passing --partner-terms does read one there, which is what earns that file the remedy. The factories read the same table and throw at compile for a caller that builds steps without a decode, which is a safety check rather than the rule. Refusal, not substitution, is what the two parties need from it: a param the function replaced with its default would make the exchange match on something neither the invitation's consent surface nor the operator's own document states, and the likelier author of a wrong type is an operator whose YAML left a literal unquoted, for whom a silent default changes the matching result with nothing said. A partner's crafted value meets the same refusal, at accept rather than mid-run, so an invitation that decodes and is accepted does not fail on this class of content afterward.
What the refusal buys the two parties is a display that states what runs: the acceptor's consent surface and the CLI's invitation display show a transform param as the document declares it, and the pipeline compiles that same value. One transformation survives between the two as a difference in what the value means. The compile NFC-normalizes the literal a step injects into or compares against a value -- replace_regex's replacement, null_if's value and values, pad_left's char, and coalesce's default -- so a param authored in another normal form runs as its canonical equivalent (the NFC-comparison contract on STANDARDIZING_FUNCTIONS, packages/core/src/standardization.ts). That one is measured against the compiled pipeline rather than asserted (packages/core/test/consent/invitationSummary.test.ts), which drives a step rebuilt from the displayed lines over the same rows as the decoded step. A regex step declaring no pattern was the second such difference, compiling the string undefined and matching the nine characters of that word while no displayed line stated a pattern at all; the required-param refusal above closes it, so no summary of such a step is composed.
Beside those two, a displayed param is the declared value as the display sanitizer renders it, under the key snakeizeKey produces: a summary row spells its param in snake_case, and a refusal that names a param states it in that same spelling. The issue path shown beside such a message is where a second spelling could stand, and the CLI's config-file renderer (apps/cli/src/config/loaders.ts) and the web's linkage-terms renderer (apps/web/src/psi/linkageTermsIO.ts) cut that path at params. Core's decode-error renderer prints it whole instead -- rawDecodeErrorDescription (packages/core/src/utils/describeDecodeError.ts), read on an invitation decode and on the terms exchange (packages/core/src/protocolSetup.ts) -- so a failure there shows the camelized key in the path beside the snake_case one the message names, a stated limit of those paths. A key holding a private-key marker is the one kept in the spelling it was authored in, since that rewrite lower-cases a marker and the redaction below matches an upper-case one. Each key: value entry takes redactAndSanitizeForDisplay (packages/core/src/utils/sanitizeErrorForDisplay.ts), which replaces a private-key block with [redacted private key], escapes what remains (the format is documented at Display sanitization escape format rather than restated here), and cuts the entry at DEFAULT_MAX_DISPLAY_LENGTH (256) with DISPLAY_TRUNCATION_MARKER (...[truncated]) in place of the rest; over those entries the consent summary shows at most MAX_DISPLAYED_PARAMS (16) and states the rest as a count line. What that leaves between the display and the run is the length cut alone: a value is admitted up to MAX_TRANSFORM_PARAM_LENGTH (1000), so a literal longer than the display budget runs in full while the line showing it ends at the truncation marker. A displayed line is therefore read as that rendering of the declared value rather than as the bytes the step injects.
The other two renderings would state something other than what the step applies, so the input reaching them is refused where the document is decoded rather than rendered: a param whose entry the redaction would replace, and a step declaring more params than the summary shows. Two more key-material refusals sit beside the first, on the other two texts the summary paints for a step and the same redaction would replace - the step's function name and a param's NAME. A further refusal sits with them all although the display is not what it turns on - a null_if step declaring both value and values, of which nullIfFactory (packages/core/src/standardization.ts) applies the list alone while the summary states both. Each is a per-step check in packages/core/src/config/transformParamDisplay.ts, read by the transformStepSchema(options) factory and by StandardizationStepSchema, so each covers every path that parses partner terms and the operator's own cleaning pipeline alike, exactly as the declared-type check above does. Each message is a fixed literal and the issue path locates the offending text, since a function name, a param name and a param value are all partner free text; the three key-material refusals state which of the three the material sits in, so a reader correcting a document knows which text to change. The key-material refusals run the display's own redaction and compare (holdsPrivateKeyMaterial), so a marker shape one of the two recognized alone cannot put them out of step, and MAX_DISPLAYED_PARAMS is one constant, read by the refusal and by the summary over one shared count of a step's declared params (declaredParamEntries, which counts an own key holding undefined because the summary paints a row for it), so the count refused and the count shown cannot part company. An over-count step raises no per-param issue, which is what keeps the issue count a step produces bounded by the cap plus its function name rather than by the width of the record. The key-material scan of a param's VALUE is skipped for a string the calling schema already refuses for its length - MAX_TRANSFORM_PARAM_LENGTH on the two terms audiences, nothing on the operator-local standardization schema, which scans every value it admits - so an over-long value costs the length compare rather than a rendered copy of itself and a redaction pass over it, the bound holding the same per-step cost the count cap holds. The param NAME is scanned apart from the line it is displayed in, so that skip cannot take the name with it. The skip reads that bound as the refine applying it does, a string value of the record and no string nested in a list, so key material inside a list entry is scanned however long the entry is. The checks behind all of it are packages/core/test/consent/invitationSummary.test.ts, which drives a step per function in the type table so a displayed line and the compiled factory are measured against each other for each of them, and packages/core/test/config/transformParamDisplayRefusals.test.ts, which drives each refusal on both schemas and pins what the summary would otherwise have stated.
Unbounded transform-parameter rejection
A third control against the same mutually-distrusting counterparty bounds the magnitude of the partner-supplied transform-param values whose size drives unbounded per-row work - the transform.params values whose entry count the count gates bound and whose declared type the section above fixes, but whose content neither reaches into. It has five members: a per-function cap on each of the two params whose magnitude drives work in a shape a string length does not describe. Beneath them is a uniform content bound on every string-valued param, and beside all three is a ceiling on the value a row produces, since a bound on what the partner may write is not a bound on what a row derives from it. The fifth is a budget on the work a row's transforms may spend, since a bound on what a row produces is not a bound on what producing it cost. The first is the length of a pad_left linkage-key element transform. pad_left runs per row over the acceptor's full dataset (packages/core/src/standardization.ts, applyElementTransform driven by buildKeyStrings) and allocates String.prototype.padStart(length, char) for each row, so an unbounded length - a crafted 1e9 - makes every row allocate a ~1 GB string and exhausts memory, hanging or crashing the acceptor (the web acceptor's browser tab, the CLI acceptor's process). It is the memory-allocation analogue of the ReDoS regex: the same fail-open denial of service reachable from the authenticated counterparty, in the same partner-influenced-terms-executed-at-the-local-party model, driven by an unbounded numeric param rather than a pattern. The value cannot be rewritten - PSI requires byte-identical keys and the terms are canonically hashed into the exchange-agreement receipt, so a unilateral clamp both breaks matching and is reported as a terms mismatch - so the control rejects an out-of-range value at validation, before any row runs.
Enforcement is a per-step refine on TransformStepBoundsSchema (packages/core/src/config/linkageTermsSchema.ts), the schema both LinkageTermsSchema (partner token) and LinkageTermsSchemaForItsAuthor build on, so - like the dialect-conformance refine - it covers every path that parses partner terms at once: parseLinkageTerms, the invitation-token decode (InvitationTokenSchema), and ExchangeSpecSchema. The cap must live on this wire schema rather than on the editor-facing STANDARDIZATION_FUNCTION_DESCRIPTORS, which an attacker-authored token never passes through: the wire schema gates the partner-supplied transform.params values by their entry count, their declared type, and their string content (below), not by the editor's per-function shape. A pad_left step whose length is a positive integer above MAX_PAD_LEFT_LENGTH (256 - far above any legitimate left-pad, which is tens of characters for a zero-padded SSN or phone, and far below an allocation that matters) is rejected. Only a positive-integer length ever reaches padStart - the declared-type refusal above takes every non-integer on both schemas, and padLeftFactory throws on a non-positive one before allocating - so bounding positive integers above the ceiling closes the whole allocation vector. A non-positive value is left to that runtime check, whose clean-abort path is unchanged. A legitimate pad_left parses and runs unchanged, preserving the cross-party receipt hash.
The second is parse_date's inputFormat and outputFormat. parse_date builds a regex from inputFormat (compiled once per distinct transform array, memoized like the other steps) and assembles its output from outputFormat once per matched row. The size of those values still drives cost: a long inputFormat compiles a larger regex, and a long outputFormat allocates a larger output per matched row, so a per-step refine on TransformStepBoundsSchema caps each format string at MAX_DATE_FORMAT_LENGTH (256 - far above any real date layout like MM/DD/YYYY or YYYY-MM-DD, yet small enough that the compile and per-row output stay cheap). Only a string value drives this cost, and only a string value is admitted: a format declared as any other type is refused by the declared-type control above. The format's shape - the adjacent (\d{1,2}) groups its MM/DD tokens expand into, which catastrophically backtrack on new RegExp at a length well under this cap - is not a separate control: parse_date compiles its regex under the linear-time engine (see Transform-regex linear-time dialect), which bounds the shape by construction. parse_date is not screened by the dialect gate (its library-generated regex is always in-dialect); its safety is the linear-time engine plus the 256-character format cap, which keeps the generated regex small and low-ambiguity by construction (only bounded \d quantifiers, never an unbounded quantifier over a sub-expression), so its per-row cost stays sub-millisecond on realistic input. This length cap is therefore a pure work-size bound, not a safety check against a backtracking blow-up.
The third is uniform rather than per-function: every string-valued transform param, whatever function and param name it sits under, is capped at MAX_TRANSFORM_PARAM_LENGTH (1000), the same threshold as the raw pattern beside it. So no string param in a params record is more generous than the pattern it accompanies, and far above any legitimate authoring, which runs to a few characters or a few hundred for a replacement literal, a coalesce default, or a null_if value. A string param's length is per-row work whichever function reads it, because it sizes what the rest of the element pipeline holds: replace_regex's replacement is substituted into the operator's own cell at every match, and every later step of that element takes the result, which the generateFuzzyComparisons expansion replicates across the row's candidates. Enforcement is the params record's value stage rather than a per-step refine, so it holds for a param no per-function refine covers and for a function this build does not implement - a per-amplifier bound would be dodged by routing content through a param no measurement covered. The offending step and param are located by the issue path (linkageKeys[i].elements[j].transform[k].params.<name>, fitted per segment where the description is composed and escaped once at the sink that shows it) rather than echoed into the message, as with the referential-integrity and dialect refusals. An over-bound param is refused, never truncated: a truncated param silently changes the derived keys and so breaks matching, the same reason the pad_left length is refused rather than clamped. The bound reaches a string value of the record, not a string nested inside an array- or object-valued param, because nothing derives a per-row value from a nested string. null_if compares its values entries against the cell and emits the cell or null, and an array in a regex step's pattern position is refused by the declared-type control above rather than measured as a compile source: the pattern-length bound and the dialect gate each read the declared string and skip a value of any other type. What the bound removes is the partner's ability to size a row's value independently of the operator's data: a crafted megabyte constant over a ten-character cell is refused at validation rather than assembled per row.
What that bound does not deliver by itself is a ceiling on the transformed value, which is the fourth member's job. It caps a param's content length, not the size of what a row derives from it, and three mechanisms amplify past the naive "cell plus one capped replacement per position" figure a 1000-character replacement suggests. First, a replacement is a substitution template rather than a literal: the $` and $' sequences re-insert the match's leading and trailing context (packages/core/src/utils/linearRegex.ts, CompiledLinearRegex.replaceAll), and an in-dialect pattern matching the empty string matches between every character, so the transformed value grows quadratically in the operator's own cell. A replacement of $' repeated 500 times is exactly 1000 characters and parses; against a* it takes a 10-character cell to 27,510 characters, a 200-character cell to 10,050,200, and a 500-character cell to 62,625,500 (~125 MB UTF-16, ~125x the naive figure). $& re-inserts only the match itself and stays under the naive figure; $` and $' are the amplifying pair. Second, steps compose: an element has up to MAX_TRANSFORM_STEPS steps and each is fed the previous step's output, so a two-step element with both params inside the bound reaches 3,630,120 characters from that same 10-character cell. Third, replaceRegexFactory NFC-normalizes the replacement before substituting it, and normalization can lengthen it - 1000 U+0344 code units normalize to 2000, which takes the 10-character cell to 22,010 with no substitution sequence involved at all. Each of those three shapes is pinned as bounded in standardization.test.ts, with the schema half - an amplifying replacement parsing at the content bound, its sequences keeping their wire meaning - in linkageTermsSchema.test.ts.
The ceiling that bounds them is MAX_TRANSFORMED_VALUE_LENGTH (4096 UTF-16 code units - the unit the engine allocates in, and far above any legitimate key element, which is a name, a date, an identifier, or an address: tens of characters, a few hundred at the outside). It is one magnitude invariant enforced at three points on the partner-authored path (packages/core/src/standardization.ts), which is the element transform the agreed terms state and not the operator's own local standardization pipeline, whose config and data are both the operator's. Inside applyElementTransform it is checked on the value the element reads - ahead of the no-steps early return, which is what bounds a zero-transform element with a huge raw cell - and on every step's output, which is what keeps every step's input bounded so a composed pipeline cannot compound past it. In buildKeyStrings it is checked again on the row's projected assembled key-string length, MAX_ASSEMBLED_KEY_LENGTH_PER_ROW (4,194,304 - MAX_KEY_STRINGS_PER_ROW times the per-value ceiling, so a row may hold the equivalent of one ceiling-sized element replicated across the full width the count cap allows). Every combination of the cross-product concatenates one candidate per element, so the assembled total is computed exactly from the candidate counts and lengths already in hand, before cartesianProduct materializes anything - the same projection discipline the count limb uses, and the limb that also bounds the fuzzy expansion's replicated bytes recorded below, which get no byte cap of their own.
The third point is that same MAX_ASSEMBLED_KEY_LENGTH_PER_ROW total, measured as the candidates accumulate rather than on the finished projection - because a projection is reachable only once every candidate exists. A step runs element-wise over an expanded candidate set (applyStep), so it allocates one output per candidate and the per-value ceiling reads the finished set: a transient N times that ceiling, with N unbounded until assembly. A running total of the characters each candidate retains - a duplicate the set collapses retains none, because a candidate the set does not keep is included in no key string - is therefore kept at both places candidates accumulate, a step's output set in applyStep and the row's candidates in buildKeyStrings. Once a total crosses, the row is settled before the next candidate is produced: dropped where a declared fan-out producer accounts for the crossing, and the exchange refused otherwise (the fate rule below).
Charging what is retained is what keeps the bound on the row's live bytes rather than on the work that produced them. A partner-authored transform that collapses a multi-value cell onto one candidate (a replace_regex whose pattern matches everything, over a cell of 2,200 values) makes the element retain one 2,000-character candidate whatever the cell's width, and a total charging each realized candidate would refuse at 4,400,000 characters a row whose key string is 2,008. The buildKeyStrings total is the row's, held across the key's elements rather than restarted at each. The elements' candidate sets are all live together when the cross-product is projected, so a per-element total would bound the row at the cap times the element count (MAX_KEY_ELEMENTS, 256) - measured at 42 elements binding one 1,000-value cell, 172,032,000 characters retained where the cap is 4,194,304, and 1,048,576,000 at the full 256. What is left live when a total crosses is three charges, each held to MAX_ASSEMBLED_KEY_LENGTH_PER_ROW by a check of its own. The three are the row's retained candidates across the elements already built and the earlier values of the element in progress; the set the crossing step is reading, which is the previous step's output under that step's own charge (or, for an element's first step, its incoming value under the per-value ceiling); and the set that step is filling. The bound on a row's live retained bytes is therefore three times MAX_ASSEMBLED_KEY_LENGTH_PER_ROW - 12,582,912 UTF-16 code units - plus the value in flight when the total crosses, for the row as a whole and not for each element of it. That figure is a code-unit count and not a memory bound. The short-candidate shape this limb exists for has the same per-string overhead the split_on measurement below records (12-17x for short tokens), so at the cap in short candidates the heap cost runs roughly 5-10x what a code-unit reading suggests. One charge of 1,048,576 distinct four-character candidates (exactly 4,194,304 charged code units) measures 46.1 MB heapUsed / about 89.5 MB RSS, putting three charges near 140 MB heap / 270 MB RSS. Size an acceptor against the heap figure rather than the code-unit one, the 256 MiB browser-tab memory envelope (MAX_WEBRTC_FRAME_BYTES) included. The figure is measured on the non-fuzzy path, and it was re-measured on 2026-09-29 under Node v26.10.0: a script run with node --expose-gc adds 1,048,576 distinct four-character strings to a Set, and the figures are the process.memoryUsage() differences before and after two forced collections (six runs, 46.1 MB heapUsed each; RSS 89.4 to 93.7 MB). The heap and RSS depend on the Node version, so read them as an order of magnitude.
A generateFuzzyComparisons element is charged on the same footing, since it retains its expanded list in place of the candidates the accumulation already charged:
buildKeyStringscorrects the row's total to the expanded list and reads the cap again before the next element is read. A total left at the pre-expansion figure would let a row sitting at the cap retain whatever the expansion multiplied the elements already built into - a factor the assembled projection above bounds exactly, but only once every element exists.- The expansion runs one value at a time rather than over the whole candidate list at once. Expanding the list in a single call allocates the element's entire expansion before any total is read, which is the allocation the charge exists to refuse: measured on this build, a
split_onelement whose 93,206 distinct 45-character candidates sit just under the cap is dropped once 95 of them have been expanded (the fate rule below gives a declared fan-out producer the drop; an element no such producer accounts for is refused at the same crossing), the wholebuildKeyStringscall taking 286 ms and 46 MB of heap growth, where the single-call expansion of that same list exhausts a 1,500 MB heap after 37 seconds. - A value the declared kind cannot expand at all - above
MAX_FUZZY_EXPANSION_INPUT_LENGTH, or not a canonicalYYYYMMDDdate underadjacent_years,adjacent_daysorday_month_swaps- is refused over the element's whole pre-expansion list before any of it is expanded (assertFuzzyExpansionAccepts, the one place the expansion's own input conditions live). The value-at-a-time loop can settle the row at the crossing partway through the list, so without that pass the refusal would depend on where in the element's candidate order the offending value sits, and a row whose crossing came first would take the accumulation fate instead - naming a cause the operator cannot act on. The list is already charged and bounded by the row cap, so the pass is linear in charged code units and allocates nothing. The pass is element-local: a row whose pre-expansion accumulation crosses the cap in this or an earlier element is settled at that limb before the pass runs and never reaches it, while every other row carrying the same value still refuses. - What is live at a fuzzy crossing is two of the three charges above - the row's retained candidates across the elements already built plus the element's own pre-expansion list, and the expanded list replacing it - plus one value's expansion, at most 1,040,512 code units (
transpositionsatMAX_FUZZY_EXPANSION_INPUT_LENGTH) and bounded without reference to the row. The fuzzy site therefore stays inside the three-charge figure. - A kind emitting candidates NARROWER than the value expanded retains more of them per charged code unit, so what bounds the charge there is the retained COUNT rather than the candidate width.
edit_distancesis that kind: it emits one deletion per code point, each a code point shorter than the value (fuzzyCandidateCeiling, the width plus the value), so a two-character value retains three candidates for four charged code units where a four-character one retains five for sixteen. The count is bounded all the same. One-character values cannot fill the cap at all - the element's pre-expansion candidates are a set of distinct strings, and only 65,536 strings of one UTF-16 code unit exist, so they retain at most 131,072 candidates whatever the row holds - and two-character values maximize the ratio among the widths that can fill it, which holds every mix below 3.3 million retained candidates atMAX_ASSEMBLED_KEY_LENGTH_PER_ROW. Measured on this build: 1,048,577 two-character values retain 3,145,731 candidates (4,194,308 charged code units) at 33.7 MB heapUsed / 54.3 MB RSS, three times the four-character count above for the same charge, and 63,455 one-character values retain 126,910 (63,455 charged) at 1.3 MB / 6.7 MB. Three times the count is not three times the heap, because the retained list holds repeated references to a bounded set of narrow strings rather than fresh ones: a one-character deletion is one of those 65,536, and the value itself is already in the pre-expansion list the expansion reads. Wider candidates cost less at the same charge rather than more - 93,207 of 45 characters (4,194,315) measure 6.4 MB / 30.8 MB, and 524,288 of 8 characters (4,194,304) measure 16.0 MB / 56.3 MB.
Where the two limbs meet: a row whose retained candidates alone cross the cap never reaches the assembled projection, so the accumulation limb determines its fate - the drop where a declared fan-out producer accounts for the crossing, the refusal otherwise (the fate rule below). The assembled byte limb keeps the far larger class it was set for - a row whose bytes appear only in the cross-product's replication of in-bound candidates - and drops it where the provenance says to.
The assembled projection is an upper bound on what a row materializes rather than the exact figure. It is computed pre-join, while the key strings are NFC-normalized and deduplicated on assembly, so a combining mark composing across an element boundary and two combinations concatenating to the same string both count more into the projection than the row retains: measured, 16 projected against 12 materialized on a two-element key whose second element's candidates open with a combining acute. The direction is fail-closed, and it is the direction the count limb's own projection takes too, for the same reason: neither collapse is measurable without the allocation the limbs exist to prevent.
An over-ceiling value is refused, never truncated: both parties must derive byte-identical keys, so a unilateral clamp would break matching and be reported as a terms mismatch against the canonically hashed agreement - the same reason the pad_left length and the param content are refused rather than trimmed. Where a declared fan-out producer accounts for the row's multiplicity the fate is the drop instead, which is that producer's normative exceedance behavior (PROTOCOL.md): the transforms are partner-authored while the values expanded are this party's own rows, so a refusal there would let a partner end an exchange by authoring a delimiter that shatters one local value. Both assembled limbs take that fate from the row's realized provenance, which is complete where they run - after every element exists - so one row cannot take two different fates depending on which limb of one projection fired.
The accumulation limb runs while the row is still being built, so it decides its fate per (record, key) before the key's first element runs, from the compiled steps of the key's elements and of the fields feeding them (keyAccumulationFate), and applies that one decision at both of its charges. A raw cell is one string and a compiled step is fixed for the run, so every producer that can expand the record is knowable there; the drop binds to whether the key declares one, not to what the elements read so far have realized. The classification is drop when every producer that can expand the key is in FAN_OUT_FUNCTION_NAMES - covering both authoring surfaces, an element transform's own step and a fan-out in the standardization of a field the key reads - and refuse otherwise. A producer outside that list is outside the width bound's rules and stays fail-closed at every width, wherever in the key it sits and whether or not the crossing reaches it, and a key no producer can expand at all names none to bind the drop to, so it takes that same default. The no-producer arm is the classification's fail-closed default rather than a fate a row reaches through this limb: nothing expands a value there, so what holds such a key is the per-value ceiling applied to each element it holds, and the aggregate a row can accumulate under it is that ceiling times the key's element count (MAX_KEY_ELEMENTS). The invariant this buys, pinned by tests driving permuted element orders and crossings forced at the first element and the last: for one record and one key the accumulation fate is identical wherever the crossing lands and whichever of the two charges it lands at. The runtime provenance is retained beside it as a fail-closed safety check only - a step that expands a value while unlisted under a drop classification is a producer that classification did not see, which is a fault in the classification rather than a fate the record earned, and the crossing refuses. That is what a Set-returning standardizing function added without an entry in the multi-value classification produces where its expansion lands before the crossing. An expansion a resolved drop never reaches is invisible to the safety check, so the safety check narrows the window such an addition opens rather than closing it, and the entry in the classification is what states the guarantee.
Classifying a key compiles every element's transform before the first element is read, so terms whose element transform names a function this build does not recognize end the exchange as that key's first row is read - whether or not any row realizes a value for the element declaring it, and whether or not the accumulating bound is ever charged. That reach is the deliberate posture: an element the agreed terms declare and this build cannot run is a fault in the terms rather than of the row that happened to reach it, and a refusal waiting for a row to realize a value for that element would let one dataset complete an exchange the next aborts. The function a partner may author there is a bounded string rather than one of the names this build knows, so that refusal narrows the name to a literal this build recognizes before interpolating it, as the step refusal below narrows its own.
The listed producer that reaches the expansion charge is one whose expansion retains more than it was handed: splitting itself never grows a total, since the parts hold no more than the value they came from, so the charge crosses on split_on keeping the original beside its parts (include_original) or on the NFC normalization it applies before splitting. A listed producer added without either property leaves that charge unreached rather than fails open - the crossing lands on the row's aggregate instead and takes the same key fate. A row dropped at any of the four limbs contributes no candidate for that key round, is warned to the operator, and stays eligible for later keys, exactly as an absent realization is; the expansion stops at the crossing either way, since finishing a row already known to be unassemblable is the allocation the bound exists to prevent.
The fifth member bounds the work the limbs above do not charge. They charge what a row RETAINS, so a step that expands its candidates and a later step that collapses them leave the row holding almost nothing while the engine allocated and read the whole expansion, once per step and once per element, on every row of the dataset. MAX_TRANSFORM_WORK_PER_ROW (33,554,432 UTF-16 code units) is the ceiling on what one (record, key) may spend. Every step invocation is charged the code units it READS plus the code units it PRODUCES, gross and monotone: no collapse, dedupe, filter or fuzzy replacement credits any of it back. The one exception is coalesce: a pass-through charges nothing, since it reads whether a value is present rather than what it holds and does constant work, while a substitution charges only the default it produces. It is eight times MAX_ASSEMBLED_KEY_LENGTH_PER_ROW, so the two move from one number, and the multiple is what leaves the byte limb reachable (the first measurement below). The charge sites are the byte limb's own (packages/core/src/standardization.ts): the per-candidate charge inside applyStep, the row's read of each cell in buildKeyStrings, and the fuzzy expansion's own loop, so one meter also covers the work a fuzzy expansion replicates per candidate. The regex steps' searches charge the same meter from inside the engine, by what they span and the pattern's weighted size, which turns on the engine build as the weighted size does and not on the machine (Transform-regex linear-time dialect, the regex search bound). Neither an operation count nor a wall-clock reading enters it: both would make a row's fate depend on the machine that read it, where two parties must reach the same one.
A crossing takes the fate the key already has. keyAccumulationFate decides the (record, key) fate before the key's first element runs, and the work limb reads that one decision at each of its charges: the warned drop where every producer that can expand the key is a declared fan-out producer, the refusal otherwise. There is no second fate rule, so a row's outcome does not turn on which limb, which element, or which step the crossing landed at, and both fates stop the row's work at the crossing rather than finishing a row that contributes nothing. The refusal and the drop have wording of their own -- what the row spent, the budget, and the remedy, which is to remove or narrow the expanding step or to declare fewer steps on the key's elements -- and echo no value, like the limbs beside them. No value is trimmed: a row inside the budget derives exactly the keys it derives without one.
The consent header's measurement runs the same compiled pipeline over four probe dates (parsedDateRunReadings and the grading pass in packages/core/src/linkageSatisfiability.ts), so an amplifying pipeline spends there too, and that pass opens a meter of its own scoped to the grading of one element. A crossing there never throws to the caller and never refuses: the probe it was carrying goes unread, exactly as a step that throws leaves one, and an unread probe resolves upward to the broader breadth word. The header therefore always renders, and a crossing can only make it more cautious.
The multiple is set from two measurements on this build rather than asserted, as the limbs above are. The first is what a pipeline spends, and every figure of it is a reading the tests take by running the shape (packages/core/test/standardization.test.ts) rather than the charge rule applied by hand. A pipeline at real authoring counts -- 32 elements, 16 steps each, over 256-code-unit values -- spends 270,336 code units, under one percent of the budget. An honest pipeline reaches the budget only at the schema's own maxima (MAX_KEY_ELEMENTS x MAX_TRANSFORM_STEPS, 256 elements of 256 steps, each step length-preserving) over values long enough to matter: 8,404,992 units over 64-code-unit values and 16,809,984 over 128-unit values are inside it, and 256-unit values cross it, refused at 33,554,688 spent. transformWorkSpentDerivingKey reads these pinned figures sender-side: a receiver-only fuzzy kind (transpositions, adjacent_years, adjacent_days, day_month_swaps) spends nothing in that reading, and a key declaring swap assembles a different order there than the receiver assembles. The budget binds identically at run time on the receiver; these figures do not cover those expansions. What sets the multiple's floor is the byte limb beside it: the row that limb's own boundary is pinned on, which retains exactly MAX_ASSEMBLED_KEY_LENGTH_PER_ROW, spends 25,357,744 units reaching it -- about six times what it keeps, since every retained character was produced, read and searched by a following step, and produced again -- so a smaller multiple would make that limb's own boundary unreachable rather than bounding anything new.
The second measurement is what a charged unit costs, because volume is a proxy for CPU whose fidelity varies by function. It is a timing rather than a count, so no test pins it. Running every shipped step function over a 4096-code-unit value under the longest in-dialect param (MAX_TRANSFORM_PARAM_LENGTH, 1000 characters), the cost per charged unit on this build's Node starts at 0.7 ns (to_upper_case, and every function that scans the value once). Measured on 2026-10-01 by packages/core/test/stress/findAllScanBudget.stress.test.ts (Node v26.10.0, re2js 2.8.6, over two runs beside other work in the container, which accounts for the spread), the regex steps' searches are charged as the regex search bound in Transform-regex linear-time dialect states, at which a single pass under an at-cap pattern costs about 8 to 49 ns per unit (extract_regex, replace_regex and split_on under the case-folded window (?i) + .{0,9} x 55 + z or the 333-branch alternation over 4096 characters, 75 to 230 ms one invocation), parse_date under a format of 128 MM tokens under 1 ns, and the rescanning shapes about 14 to 54 ns. The dearest units are the find-all steps under a pattern that matches at every position (x*, .): one search per code unit, each spanning about one, so what is charged is mostly the read and the output while the engine's cost of starting each search is not charged at all, about 50 to 590 ns per unit (replace_regex under x* over 4096 characters, 12 to 19 ms one invocation). The residual per-row CPU ceiling the budget buys is the budget times the worst of those: about 13 to 20 seconds per record per key in the worst measured configuration, against about 0.03 seconds at the cheap end. That residual is for the exchange path alone: the invitation-display grading pass opens one such meter per key element too (gradeElementPipeline, called once per element by elementBreadthMarker), so the consent surface's ceiling is the element count times the budget rather than one budget -- a path uncharged before this limb. That is the number the limb is worth, stated rather than an assertion that the work is bounded.
What a refusal names depends on where it fired; none of them interpolates the value, which is the operator's PII, or the key's partner-authored name. The two refusals that name only an element - the value-read refusal and the accumulation refusal - locate it by the issue path the terms-validation refusals use (linkageKeys[i].elements[j], the key index taken from the exchange's own key list; a caller holding one key alone roots the path at the element) plus the row index. The accumulation refusal's path names the element the total was accumulating at when it crossed, and its total is the row's across the key's elements, so it states the key rather than attributing that total to the one element. The step refusal extends that path with .transform[k] and the step's function name, narrowed to a literal this build recognizes before it is interpolated, as the fan-out refusals narrow theirs. The two assembly refusals, count limb and byte limb, name the key alone (linkageKeys[i]) with no element, step, or row - the projection is the row's rather than any one element's - and the row index appears on that path only in the warning a dropped row logs. A row dropped at the accumulation limb logs that same warning shape rather than an issue path: the row index, the total, and the key's partner-authored name escaped at that sink, which is what every drop warning states. On the receiver of a key declaring swap the position that reads a column and the position that declares it are different ones, since the swap moves the field references and leaves each element's name and transforms in place. A refusal on the value read names the position whose field is the offending column, so its remedy - bind the element to a shorter column - points where that column is written. A refusal on a step names the position the step is declared at. The accumulation refusal takes whichever of the two produced the candidates it was charging: the step-declared position where the element declares a transform, and the column-declaring position where it declares none and the candidates are the field's own realized values, so both parties name the same position for the same offending column. The work refusal's path is the accumulation refusal's, taken at the element the budget ran out at, and its total is likewise the row's across the key's elements.
The closer is the unilateral, local one. Neutralizing the amplifying $-sequences in a partner-supplied replacement was the other candidate and is not adopted: it changes what a replacement means on the wire, a PROTOCOL_VERSION and cross-implementation-vector question, where the ceiling changes no wire semantics at all. Five limits are stated rather than closed. First, the per-step check reads a step's output, so one candidate's amplification is allocated before it is refused. What bounds that transient is the ceiling on that step's input, and above it the engine's own maximum string length. Measured on this build's Node, a 1000-character cell under the $' replacement allocates 500.5 MB in 107 ms, and a 2048-character cell raises RangeError: Invalid string length in 73 ms with no heap growth. Where the step runs over an expanded candidate set it allocates one such value per candidate, which is what the accumulation bound above holds: the live total is that bound's three charges, 3 x MAX_ASSEMBLED_KEY_LENGTH_PER_ROW code units, plus the value in flight when it crosses, at the heap multiplier and in the non-fuzzy scope recorded with that figure. Either way the allocation stops at that row rather than compounding across the dataset - the exchange ends there where the crossing refuses, and where a declared fan-out producer accounts for it the row is dropped and every further row is held to the same per-row bound. Second, the bound is per row, so a dataset's total is that ceiling times the row count: what the control removes is the partner's ability to size a row's value independently of the operator's data, leaving a bounded constant-factor amplification (at most the ceiling over the honest cell length) where there was no bound at all. Third, the ceiling fires on the operator's own data, which the parameter controls never do, so an honest free-text cell above 4096 characters ends the exchange. The refusal names the element, the row, and the remedy - bind the element to a shorter column, or shorten the field in this party's own standardization, which the ceiling does not reach. Fourth, on the paths that refuse a partner can still tune an amplifier so that a run aborts deterministically, and learns one coarse bit about the local data's magnitude per consented exchange from whether it does. That availability trade is accepted: an abort is visible and attributable, where an unannounced narrowing is neither. Where a declared fan-out producer accounts for the row's width the same tuning buys the warned drop rather than the abort, which is the width bound's own trade rather than a second one. Fifth, the accumulation charge counts retained characters, so what it bounds is the bytes a row holds live and not the work that produced them - the second limit's "bounded constant-factor amplification" is a statement about a value's size and does not apply to CPU. Charging realized rather than retained candidates is not the closer, since it refuses legitimate collapsing transforms, which is why that limb charges what it does; the work limb above is the bound on the work instead, and two things remain inside it. The proxy is volume rather than cost, and the spread between the cheapest and the dearest charged unit is the four orders of magnitude the second measurement records, so a partner who makes each unit expensive spends that worst per-unit cost whatever the budget: what the budget removes is the REPLICATION of that work across a row's candidates, steps and elements, and the residual is the per-row ceiling stated with that measurement. And the charge is read after an invocation returns, so one value's amplification is allocated before the budget sees it, bounded by the per-value ceiling exactly as the first limit above records.
The severity mirrors the ReDoS sibling: a fail-open denial of service reachable from the authenticated counterparty, bounded to DoS (no data, no code execution), and not meaningfully self-inflicted. A human reviewing the consent screen cannot vet a numeric length, a date-format string, or a replacement literal for an allocation or compile blow-up, and the handshake authenticates identity, not that the authored terms are benign. The bound is fixed defense-in-depth, fail-closed, not a semantic limit. The sibling numeric vector audited alongside it is latent, not live, and holds its bound in its own implementation: substring reads a partner length but slices with String.slice, which cannot exceed its input. The sibling expansion vectors are live and hold their bounds in their own implementation. The split_on fan-out cross-product is matched on under both linkage strategies, and it has the per-(record, key) candidate cap specified in PROTOCOL.md. A record above the cap contributes no candidate for that key round, so the cap holds by construction rather than by a refusal, and a cross-product too wide to assemble takes that same drop rather than the refusal below. The cap bounds the retained candidate set, not the split that assembles it: splitOnFactory materializes the full parts array and Set before the cap is consulted, with no per-value length guard analogous to MAX_FUZZY_EXPANSION_INPUT_LENGTH. That transient cost is linear in the value split_on receives, and the string-param content bound above (MAX_TRANSFORM_PARAM_LENGTH) does not bound that value - a partner-authored replace_regex step preceding split_on in the same element sizes the retained candidate the split assembles, and that bound removes only the partner-chosen constant. What bounds it is the produced-value ceiling above: the preceding step's output is checked before split_on is handed it, so the value entering the split is at most MAX_TRANSFORMED_VALUE_LENGTH. The measurement that established the vector - a 1 MB replace_regex replacement over a 10-character operator cell, producing a 1,000,000-character retained candidate at ~12 MB peak, held only by the transport's frame ceiling (~512 MiB on file-sync, 256 MiB on the WebRTC data channel; see TRANSPORT_BOUNDS.md) - is refused at terms validation by that bound. The measured worst shape for a genuinely operator-sized cell -- ~1.2 MB, short tokens -- is 15-20 MB live (12-17x; the full standardization pipeline needs ~40 MB), dominated by per-string overhead scaling with the part count the delimiter chooses; the cost stays linear in the input length regardless of shape. The amplification is not split_on's: replace_regex alone, with no split_on downstream, amplifies identically, which is why the bound sits on the param rather than on the split.
The generateFuzzyComparisons expansion (transpositions / edit_distances / adjacent_years / adjacent_days / day_month_swaps) is implemented and has its bounds: each expanded value is refused past MAX_FUZZY_EXPANSION_INPUT_LENGTH before any candidate is built, and buildKeyStrings refuses a row whose candidate-array product exceeds MAX_KEY_STRINGS_PER_ROW before materializing it, counted ahead of allocation. A fuzzy element multiplies its kind's candidate ceiling into the width its key declares in PROTOCOL.md, and buildKeyStrings refuses a row a fuzzy expansion widened past that width, so the count either linkage strategy matches on is bounded by the declared width. Terms declaring a width above MAX_KEY_CANDIDATE_WIDTH, which is MAX_KEY_STRINGS_PER_ROW itself, are refused where the width is derived, so the two bounds coincide rather than one sitting above the other. transpositions enumerates all pairs of positions rather than adjacent ones alone, so its candidate count is quadratic in the value's width and its allocation cubic in it; what holds that is the declared width rather than the input limit, since an element bounded to more than 45 characters declares a width above MAX_KEY_CANDIDATE_WIDTH and is refused before any row is read (PROTOCOL.md). The vector is live and its bounds are the ones above. Two properties of that bound qualify them. The count cap bounds key strings, not bytes: a non-fuzzy element's full local cell is replicated across the fuzzy product, so a row's byte total scales with that element's transformed cell length times the cap. That total measures near 1 GB for one row at the cap with a 1 MB cell, a cell length the produced-value ceiling above does not admit. Those replicated bytes are not tied to the operator's raw input any more than split_on's retained candidate is (above): the string-param content bound does not cap what a transform can multiply a non-fuzzy element's cell by before it is replicated, and MAX_FUZZY_EXPANSION_INPUT_LENGTH does not reach that cell at all. The guard checks only the value the fuzzy element itself expands, after that element's own transform runs, never a sibling non-fuzzy element's transformed value. What holds them is the byte limb above, MAX_ASSEMBLED_KEY_LENGTH_PER_ROW, measured on the same projection as the count cap and covering both producers rather than fuzzy alone, and charged again as the expansion's own candidates accumulate. And edit-distance-1 semantics make a one-character element vacuous: edit_distances is the kind both parties expand (PROTOCOL.md), a single deletion of one character is the empty string, so any two distinct one-character values at such an element match each other and the element contributes no discrimination -- a matching-precision and disclosure property to weigh before declaring edit_distances on initials or single-digit codes.