ADR 0018: SDK bounds contract — caller-tightenable limits through the expected structs
Copy Markdown View Source- Status: accepted
- Date: 2026-08-17
- Track: T2
- Refines: ADR 0014 (the SDK support surface whose bounds
entry this ADR specifies — 0014 mentions
Bounds.maximum/Bounds.newonly in passing), ADR 0002 (the bounded-decoder origin of the profile bounds), ADR 0017 (the behavioral contract these gates feed)
Context
The Elixir reference threads caller-tightenable limits everywhere a ceiling applies: caller context
arrives as expected structs whose optional bounds are resolved once (Bounds.coerce) and applied
to every bound-sensitive check on the path. Until BAP-15, the SDKs' archive façades ran at profile
maximum only — a caller could not tighten below the maxima, silently diverging from the reference
contract for every consumer that verifies under constrained memory or untrusted-input budgets.
The parity landing (b4ca616, Rust) plus the sibling encode-path landings gave all three SDKs the
full contract, proven by 27 (Rust) + 15 + 15 (TS/Python) mutation-proven battery legs against the
Elixir oracle. The contract itself was recorded only in ROADMAP amendment #2; ADR
0014 names Bounds.maximum/Bounds.new as versioned
primitives without specifying the threading semantics. This ADR is the contract of record.
Decision
Additive optional bounds on the expected structs. Each archive/chain expected struct carries an optional bounds field — Rust:
Option<Bounds>onExpectedChain,ExpectedAnchor,ExpectedKeyTransition,ExpectedExport,ExpectedAnchoredExport(sdks/rust/src/types.rs); TypeScript:bounds?: Boundson the corresponding expected types; Python:bounds: Bounds | None = None. Absent means the profile maximum — the additive default preserves every existing call; a present value may only tighten below the profile maxima (ADR 0002's tightening-only discipline — a caller can never loosen above them).Nested-pins identity semantics. Where an expected struct nests another expected struct (an anchored export carrying an expected chain, anchors, and transitions), a present nested bounds must coerce-equal the outer bounds; an absent nested bounds is valid only under an effectively-untightened outer. An identity override is NOT tightening — supplying the same value twice does not create a second, independently-checkable limit. The reference's resolution order is mirrored exactly; the identity rule exists so that a tightened outer cannot be silently satisfied by a nested structure that re-asserts the maximum.
One resolved bounds, threaded through every ceiling. The resolved bounds apply at every bound-sensitive check on BOTH the encode and verify paths, and on the standalone chain / anchor / transition entries — including the chunk-count pin (the archive chunk count is bounded by the resolved bounds, not just the wire size). A bounds field that is accepted but not threaded into some ceiling is a contract breach even when the unthreaded ceiling coincides with the maximum: the contract is the threading, not the value.
Named divergence —
assemble_compactstayed at maximum in all three SDKs; RESOLVED 2026-08-18 on maintainer direction. The Elixir reference threads caller limits throughassemble_compact(runtime.ex:147-151:Bounds.coerce→CompactJws.assemble→validate_assembled_compact); the SDKs'assemble_compactequivalents ran at profile maximum only. Resolution (dated amendment; the original divergence text is preserved above): all three SDKs now take an optional bounds parameter (Pythonbounds: Bounds | None = None, TypeScriptbounds?: Bounds, Rustbounds: Option<&Bounds>— a public-API addition per SDK, landed as its own slice, never a silent widening) and thread it through the reference's assemble gates: encoded-segment bounds on both segments, compact_bytes on the assembled output, and the kind re-parse.signature_bytescarries no assemble-time gate in any implementation — it is a FIXED-WIDTH key rejected at Bounds construction unless 64, so the reference's assemble-time check is subsumed by Bounds validation itself. Mutation-proven per SDK (reverting the threading to maximum reddens each battery's tightened-bounds legs). The Go SDK (BAP-16) picks the full contract up at authoring.
Alternatives considered
- Maximum-only SDKs (the pre-BAP-15 state). Rejected: every consumer verifying untrusted input under a memory budget gets no lever, and the SDK silently diverges from the reference contract.
- Required bounds fields. Rejected: breaks the additive default for every existing caller and
forces ceremony (
bounds: Bounds.maximum()) on the common path; optionality-with-maximum-default matches the reference's optional expected-side limits. - Verifier-side-only threading (encode stays at maximum). Rejected: the encode paths build the very archives the verify paths check; a producer that can exceed a consumer's tightened bounds defeats the point of tightening. The parity landing threaded both sides deliberately.
Consequences
- A caller tightening via the expected structs now gets enforcement at every ceiling in all three SDKs — parity with the reference on the archive façades, proven mutation-red per SDK.
- The
assemble_compactdivergence is closed in all three SDKs, and the Elixir reference exposes the same optional-bounds shape publicly throughassemble_compact/3under ADR 0020. The retained two-argument Elixir form and absent SDK bounds all mean profile maximum. - The Go SDK (BAP-16) implements this contract at authoring; its acceptance bar cites this ADR alongside ADR 0014/0017.