# Capability Spec: cbor **Package**: `@suaveplan/cbor` **Status**: ACTIVE > Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.) ## Requirements ### Requirement: RFC 8949 encoder for major types 0–7 with preferred-serialization shortest-form integers The package SHALL export an `Encoder` class and an `encode(value, options?): Uint8Array` function that produce RFC 8949 §3.1 byte sequences for every major type: 0 unsigned integer, 1 negative integer, 2 byte string, 3 text string, 4 array, 5 map, 6 tagged item, 7 simple/float. Integers SHALL use the preferred-serialization shortest-form rule (RFC 8949 §4.2.1) — the smallest of 1 / 2 / 3 / 5 / 9 byte representations that fits the value, regardless of the input numeric type. Floats SHALL encode as `float64` by default; `float32` SHALL be emitted only when the value round-trips through `float32` losslessly; `float16` SHALL be emitted only when `EncoderOptions.preferFloat16: true` AND the value round-trips through binary16 losslessly. #### Scenario: encodes unsigned integer 0 to single byte 0x00 - **WHEN** `encode(0)` is called - **THEN** the result equals `Uint8Array([0x00])` #### Scenario: encodes unsigned integer 23 to single byte 0x17 - **WHEN** `encode(23)` is called - **THEN** the result equals `Uint8Array([0x17])` #### Scenario: encodes unsigned integer 24 to two bytes 0x18 0x18 - **WHEN** `encode(24)` is called - **THEN** the result equals `Uint8Array([0x18, 0x18])` #### Scenario: encodes negative integer -1 to single byte 0x20 - **WHEN** `encode(-1)` is called - **THEN** the result equals `Uint8Array([0x20])` #### Scenario: encodes empty array to single byte 0x80 - **WHEN** `encode([])` is called - **THEN** the result equals `Uint8Array([0x80])` #### Scenario: encodes empty map to single byte 0xa0 - **WHEN** `encode(new Map())` is called - **THEN** the result equals `Uint8Array([0xa0])` #### Scenario: integer 1000000 encodes to 5-byte form not 9-byte - **WHEN** `encode(1_000_000)` is called - **THEN** the result is 5 bytes long with leading byte `0x1a` **Verified by:** `packages/flow/cbor/src/encoder/encoder.test.ts::"encodes unsigned integer 0"` AND `packages/flow/cbor/src/encoder/encoder.test.ts::"encodes unsigned integer 23 in single byte"` AND `packages/flow/cbor/src/encoder/encoder.test.ts::"encodes unsigned integer 24 in two bytes"` AND `packages/flow/cbor/src/encoder/encoder.test.ts::"encodes negative integer -1"` AND `packages/flow/cbor/src/encoder/encoder.test.ts::"encodes empty array"` AND `packages/flow/cbor/src/encoder/encoder.test.ts::"encodes empty map"` AND `packages/flow/cbor/src/encoder/encoder.test.ts::"encodes 1000000 in shortest 5-byte form"` AND `packages/flow/cbor/src/encoder/encoder.spec-conformance.test.ts::"RFC 8949 Appendix A vectors round-trip"` --- ### Requirement: Indefinite-length encoding (opt-in) The package SHALL emit indefinite-length arrays, maps, byte strings, and text strings (RFC 8949 §3.2) only when the caller passes `EncoderOptions.indefiniteLength: true`. With the option off (default), the encoder SHALL emit definite-length forms for every container. Under the deterministic profile (`deterministic: true`), indefinite-length encoding SHALL be rejected: callers passing both `indefiniteLength: true` and `deterministic: true` SHALL receive a `CborError(CBOR.INDEFINITE_NOT_ALLOWED)` at the boundary. #### Scenario: indefinite-length emission opt-in - **WHEN** `encode([1, 2, 3], { indefiniteLength: true })` is called - **THEN** the first byte equals `0x9f` (indefinite-length array start) and the last byte equals `0xff` (break) #### Scenario: definite-length is the default - **WHEN** `encode([1, 2, 3])` is called - **THEN** the first byte equals `0x83` (definite-length array of 3) and there is no break stop #### Scenario: indefinite + deterministic is rejected - **WHEN** `encode([1, 2, 3], { indefiniteLength: true, deterministic: true })` is called - **THEN** the call throws `CborError` with code `"CBOR.INDEFINITE_NOT_ALLOWED"` **Verified by:** `packages/flow/cbor/src/encoder/indefinite.test.ts::"emits indefinite-length array when opt-in"` AND `packages/flow/cbor/src/encoder/indefinite.test.ts::"defaults to definite-length"` AND `packages/flow/cbor/src/encoder/indefinite.test.ts::"throws INDEFINITE_NOT_ALLOWED under deterministic"` --- ### Requirement: Tagged items with built-in registry The package SHALL encode and decode tagged items (RFC 8949 §3.4 / major type 6) and SHALL ship a `TagRegistry` with built-in handlers for IANA-registered tags 0 (RFC 3339 date/time), 1 (epoch-based date/time), 2 (positive bignum), 3 (negative bignum), 21 (expected base64url), 22 (expected base64), 23 (expected base16), 24 (encoded CBOR — re-enters the decoder), 32 (URI), 35 (regex), and 55799 (self-describe CBOR). Consumers SHALL be able to register custom handlers via `registry.register(tag, handler)`. Per RFC 8949 §3.4, an unknown tag SHALL NOT cause decode failure — the decoder SHALL return a `Tagged` value object so the caller can decide. #### Scenario: tag 0 round-trips an RFC 3339 date string - **WHEN** `encode(new Date("2026-05-10T00:00:00Z"))` is called and the result is decoded - **THEN** the decoded value is a `Date` whose ISO string equals `"2026-05-10T00:00:00.000Z"` #### Scenario: tag 24 (encoded CBOR) re-enters the decoder - **GIVEN** a tag 24 wrapping the byte string for `encode(42)` - **WHEN** the wrapper is decoded - **THEN** the result equals `42` (the inner CBOR was decoded transparently) #### Scenario: unknown tag in strict mode returns Tagged - **GIVEN** a CBOR payload with tag number 9999 wrapping the string `"hi"` - **WHEN** `decode(bytes)` is called with default `mode: "strict"` - **THEN** the result is a `Tagged` with `{ tag: 9999, value: "hi" }` #### Scenario: registering a handler intercepts decode - **GIVEN** a `TagRegistry` with `register(9999, (v) => ({ wrapped: v }))` - **WHEN** the same payload is decoded through that registry - **THEN** the result equals `{ wrapped: "hi" }` #### Scenario: double-register throws - **WHEN** `registry.register(0, handler)` is called twice for the same tag - **THEN** the second call throws `CborError` with code `"CBOR.UNEXPECTED_TYPE"` **Verified by:** `packages/flow/cbor/src/tags/builtin.test.ts::"tag 0 round-trips RFC 3339 date"` AND `packages/flow/cbor/src/tags/builtin.test.ts::"tag 24 re-enters the decoder"` AND `packages/flow/cbor/src/tags/registry.test.ts::"unknown tag in strict mode returns Tagged"` AND `packages/flow/cbor/src/tags/registry.test.ts::"registered handler intercepts decode"` AND `packages/flow/cbor/src/tags/registry.test.ts::"double-register throws"` --- ### Requirement: Half-precision float encode (opt-in) and decode The package SHALL decode IEEE-754 binary16 values (RFC 8949 §3.3, major type 7 / value 25) — always available, never gated. The package SHALL encode binary16 only when `EncoderOptions.preferFloat16: true` AND the value round-trips through binary16 losslessly. Subnormals, ±0, ±Inf, and quiet/signalling NaN payloads SHALL all decode correctly per RFC 8949 §D. #### Scenario: decodes 0xf90000 to +0 - **WHEN** `decode(Uint8Array([0xf9, 0x00, 0x00]))` is called - **THEN** the result equals `0` and `Object.is(result, 0)` is true (positive zero) #### Scenario: decodes 0xf98000 to -0 - **WHEN** `decode(Uint8Array([0xf9, 0x80, 0x00]))` is called - **THEN** the result satisfies `Object.is(result, -0)` #### Scenario: decodes 0xf97c00 to +Infinity - **WHEN** `decode(Uint8Array([0xf9, 0x7c, 0x00]))` is called - **THEN** the result equals `Number.POSITIVE_INFINITY` #### Scenario: encodes 1.0 as binary16 when opt-in - **WHEN** `encode(1.0, { preferFloat16: true })` is called - **THEN** the result equals `Uint8Array([0xf9, 0x3c, 0x00])` #### Scenario: encoder skips binary16 when value would lose precision - **WHEN** `encode(1.1, { preferFloat16: true })` is called - **THEN** the result is NOT a 3-byte half-float — it is the float32 or float64 form that preserves `1.1` **Verified by:** `packages/flow/cbor/src/decoder/half-float.test.ts::"decodes binary16 +0"` AND `packages/flow/cbor/src/decoder/half-float.test.ts::"decodes binary16 -0"` AND `packages/flow/cbor/src/decoder/half-float.test.ts::"decodes binary16 +Infinity"` AND `packages/flow/cbor/src/encoder/half-float.test.ts::"encodes 1.0 as binary16 when opt-in"` AND `packages/flow/cbor/src/encoder/half-float.test.ts::"skips binary16 when value would lose precision"` --- ### Requirement: Deterministic encoding profile (RFC 8949 §4.2) The package SHALL provide a deterministic encoding profile gated by `EncoderOptions.deterministic: true` that enforces every rule in RFC 8949 §4.2.1 + §4.2.2: integers use shortest-form encoding, floats use the smallest losslessly-representable form (float64 → float32 → no half-float — half-float disabled in deterministic mode), maps use sorted keys via lexicographic comparison over canonical encoded key bytes, and indefinite-length encodings are forbidden. Two runs of the deterministic encoder over the same logical structure (with map-key ordering shuffled) SHALL produce byte-identical output. #### Scenario: map keys are sorted lexicographically over encoded key bytes - **WHEN** `encode(new Map([["b", 1], ["a", 2]]), { deterministic: true })` is called - **THEN** the resulting bytes encode `"a"` before `"b"` regardless of insertion order #### Scenario: integer key 10 sorts after integer key 2 by encoded-byte length - **WHEN** a map containing keys `2` and `10` is encoded under deterministic mode - **THEN** key `2` (single byte 0x02) precedes key `10` (two bytes 0x18 0x0a) per RFC 8949 §4.2.1 length-then-lexicographic ordering #### Scenario: half-float disabled under deterministic profile - **WHEN** `encode(1.0, { deterministic: true, preferFloat16: true })` is called - **THEN** the result is the float32 form (5 bytes starting `0xfa`), not the binary16 form (3 bytes starting `0xf9`) #### Scenario: shuffled-key map produces byte-identical output across runs - **GIVEN** a fast-check arbitrary that generates the same logical map with shuffled insertion order - **WHEN** both shuffles are encoded under `deterministic: true` - **THEN** the two `Uint8Array` outputs are byte-identical **Verified by:** `packages/flow/cbor/src/encoder/deterministic.test.ts::"sorts map keys lexicographically by encoded bytes"` AND `packages/flow/cbor/src/encoder/deterministic.test.ts::"sorts integer keys by length then value"` AND `packages/flow/cbor/src/encoder/deterministic.test.ts::"disables half-float under deterministic profile"` AND `packages/flow/cbor/src/encoder/deterministic-property.test.ts::"shuffled-key map produces byte-identical deterministic output"` --- ### Requirement: dCBOR profile (draft-mcnally-deterministic-cbor) The package SHALL provide an opt-in dCBOR profile under the `./deterministic` subpath that layers the dCBOR draft's reduction rules over the RFC 8949 §4.2 deterministic baseline: numeric values SHALL collapse to the smallest type that represents them losslessly (e.g. `1.0` collapses to integer `1`), and `undefined` and unknown simple values SHALL be rejected with `CborError(CBOR.NON_DETERMINISTIC)`. dCBOR output SHALL be a strict subset of RFC 8949 §4.2 deterministic output and SHALL satisfy every scenario of the prior requirement. #### Scenario: numeric reduction collapses 1.0 to integer 1 - **WHEN** `encodeDcbor(1.0)` is called - **THEN** the result equals `Uint8Array([0x01])` (major type 0 unsigned integer 1) #### Scenario: rejects undefined value - **WHEN** `encodeDcbor(undefined)` is called - **THEN** the call throws `CborError` with code `"CBOR.NON_DETERMINISTIC"` #### Scenario: dCBOR output is RFC 8949 deterministic - **GIVEN** any non-rejected input - **WHEN** `encodeDcbor(value)` is called and the bytes are also produced via `encode(value, { deterministic: true })` - **THEN** the dCBOR output is byte-identical to the deterministic output OR is a strict reduction of it (numeric collapse rule) **Verified by:** `packages/flow/cbor/src/encoder/dcbor.test.ts::"reduces 1.0 to integer 1"` AND `packages/flow/cbor/src/encoder/dcbor.test.ts::"rejects undefined as NON_DETERMINISTIC"` AND `packages/flow/cbor/src/encoder/dcbor.test.ts::"dCBOR output satisfies RFC 8949 deterministic invariants"` --- ### Requirement: RFC 8742 CBOR Sequences The package SHALL encode and decode CBOR Sequences per RFC 8742 §2 and §3 — the concatenation of zero or more well-formed CBOR data items with no enclosing array. The package SHALL export `encodeSequence(items, options?): Uint8Array`, `decodeSequence(bytes, options?): unknown[]`, and `decodeSequenceStream(stream, options?): AsyncIterable` for incremental consumption. An empty input SHALL produce an empty `Uint8Array` on encode and an empty array (or empty stream) on decode. #### Scenario: encodes mixed-major-type sequence - **WHEN** `encodeSequence([1, "two", [3]])` is called - **THEN** the result equals the concatenation of `encode(1)`, `encode("two")`, and `encode([3])` #### Scenario: decodes the same bytes back to the same array - **GIVEN** the bytes from the prior scenario - **WHEN** `decodeSequence(bytes)` is called - **THEN** the result deeply equals `[1, "two", [3]]` #### Scenario: empty sequence round-trips - **WHEN** `encodeSequence([])` is called and the result is decoded - **THEN** the round-trip yields `[]` and the encoded byte length equals 0 #### Scenario: stream form yields one item at a time - **GIVEN** an `AsyncIterable` chunking the bytes from scenario 1 - **WHEN** `decodeSequenceStream(stream)` is iterated - **THEN** three items are yielded in order: `1`, `"two"`, `[3]` **Verified by:** `packages/flow/cbor/src/sequences/sequences.test.ts::"encodes mixed-major-type sequence"` AND `packages/flow/cbor/src/sequences/sequences.test.ts::"decodes sequence back to source array"` AND `packages/flow/cbor/src/sequences/sequences.test.ts::"empty sequence round-trips"` AND `packages/flow/cbor/src/sequences/sequences.test.ts::"stream form yields one item at a time"` --- ### Requirement: Decoder strict mode and well-formedness checks (RFC 8949 §5.1) The package SHALL export a `Decoder` class and `decode(bytes, options?): unknown` function. Strict mode (default) SHALL enforce every well-formedness rule from RFC 8949 §5.1: no extraneous trailing bytes after a complete item, every break stop matches an indefinite-length container start, every indefinite-length text/byte string contains only definite-length chunks of the same major type, integer / length encodings use a valid initial-byte form, and reserved simple values are rejected. Lax mode (`options.mode: "lax"`) SHALL apply only RFC 8949 §5.2 basic validity. Strict-mode failures SHALL throw `CborError(CBOR.MALFORMED)`. #### Scenario: strict mode rejects extraneous trailing bytes - **GIVEN** the bytes `[0x01, 0xff]` (valid integer 1 followed by stray break) - **WHEN** `decode(bytes)` is called with default mode - **THEN** the call throws `CborError` with code `"CBOR.MALFORMED"` #### Scenario: lax mode tolerates extraneous trailing bytes - **GIVEN** the same bytes - **WHEN** `decode(bytes, { mode: "lax" })` is called - **THEN** the result equals `1` and no error is thrown #### Scenario: strict mode rejects indefinite-string with wrong-major-type chunk - **GIVEN** an indefinite-length text string (`0x7f`) containing a byte-string chunk - **WHEN** `decode(bytes)` is called - **THEN** the call throws `CborError` with code `"CBOR.MALFORMED"` #### Scenario: isWellFormed returns false for every §5.1 failure class - **WHEN** `isWellFormed` is invoked over a fixture array of malformed payloads (one per §5.1 rule) - **THEN** every fixture returns `false` **Verified by:** `packages/flow/cbor/src/decoder/decoder.test.ts::"strict mode rejects extraneous trailing bytes"` AND `packages/flow/cbor/src/decoder/decoder.test.ts::"lax mode tolerates extraneous trailing bytes"` AND `packages/flow/cbor/src/decoder/decoder.spec-conformance.test.ts::"strict mode rejects indefinite text with wrong-major chunk"` AND `packages/flow/cbor/src/decoder/well-formed.test.ts::"isWellFormed returns false for every §5.1 failure class"` --- ### Requirement: Streaming pull-decoder over Uint8Array and ReadableStream The package SHALL export a `PullDecoder` that consumes either a `Uint8Array` or an `AsyncIterable` / `ReadableStream` and exposes `next(): Promise>` so callers materialize one CBOR item at a time. Memory usage SHALL be bounded — the decoder SHALL NOT buffer the entire input before yielding. The decoder SHALL honor `AbortSignal`: when the signal aborts mid-item, the next `next()` call SHALL reject with `CborError(CBOR.MALFORMED)` and SHALL release any internal buffers. #### Scenario: pulls one item per next() call - **GIVEN** an input encoding `[1, 2, 3]` as a CBOR Sequence - **WHEN** `next()` is called three times - **THEN** the calls resolve to `{ value: 1, done: false }`, `{ value: 2, done: false }`, `{ value: 3, done: false }` in order, followed by `{ value: undefined, done: true }` #### Scenario: bounded memory under a 1 GiB sequence - **GIVEN** a synthetic stream emitting one million 1 KiB CBOR items - **WHEN** the consumer iterates with `next()` and discards every result - **THEN** the peak heap delta during iteration is bounded by a fixed constant (asserted via `process.memoryUsage().heapUsed` snapshot) regardless of total stream length #### Scenario: AbortSignal cancellation rejects next() - **GIVEN** a `PullDecoder` constructed with an `AbortSignal` - **WHEN** the signal is aborted mid-stream and `next()` is called - **THEN** the returned promise rejects with `CborError` whose code equals `"CBOR.MALFORMED"` **Verified by:** `packages/flow/cbor/src/streaming/pull-decoder.test.ts::"pulls one item per next() call"` AND `packages/flow/cbor/src/streaming/pull-decoder.test.ts::"bounded memory under 1 GiB sequence"` AND `packages/flow/cbor/src/streaming/pull-decoder.test.ts::"AbortSignal cancellation rejects next()"` --- ### Requirement: Bounded recursion and bounded item count The encoder and decoder SHALL enforce caller-configurable security bounds per RFC 8949 §10: `maxDepth` (default `64`) caps nested container depth and overflows SHALL throw `CborError(CBOR.NESTED_TOO_DEEP)`; `maxItems` (default `1_000_000`) caps the total number of items materialized in a single decode (across nested arrays / maps / sequences) and overflows SHALL throw `CborError(CBOR.LENGTH_OVERFLOW)`. Both caps SHALL be configurable via `EncoderOptions` / `DecoderOptions`. The defaults SHALL apply when the caller passes no override. #### Scenario: depth overflow throws NESTED_TOO_DEEP - **GIVEN** an array nested 65 levels deep - **WHEN** `encode(value)` is called with default `maxDepth: 64` - **THEN** the call throws `CborError` with code `"CBOR.NESTED_TOO_DEEP"` #### Scenario: item-count overflow throws LENGTH_OVERFLOW - **GIVEN** a CBOR payload claiming an indefinite array of 1_000_001 small items - **WHEN** `decode(bytes)` is called with default `maxItems: 1_000_000` - **THEN** the call throws `CborError` with code `"CBOR.LENGTH_OVERFLOW"` #### Scenario: configurable override increases the cap - **GIVEN** a 100-deep nested array - **WHEN** `encode(value, { maxDepth: 128 })` is called - **THEN** the call succeeds with no error **Verified by:** `packages/flow/cbor/src/encoder/encoder.test.ts::"throws NESTED_TOO_DEEP at default depth 64"` AND `packages/flow/cbor/src/decoder/decoder.test.ts::"throws LENGTH_OVERFLOW at default cap"` AND `packages/flow/cbor/src/encoder/encoder.test.ts::"honors caller-supplied maxDepth override"` --- ### Requirement: COSE_Key decoder (RFC 9052 §7) The package SHALL export `decodeCoseKey(bytes): CoseKey` from the `./cose` subpath that decodes a COSE_Key map per RFC 9052 §7 into a discriminated union `OkpKey | Ec2Key | RsaKey | SymmetricKey`. The discriminator SHALL be the `kty` parameter (label 1): `1` → OKP (RFC 8037), `2` → EC2 (RFC 9052 §7.1), `3` → RSA (RFC 8230), `4` → Symmetric. Each variant SHALL include the structurally-typed parameters required by its key type (`crv`, `x`, `y`, `d`, `n`, `e`, `k` as appropriate). Malformed input SHALL throw `CborError(CBOR.MALFORMED)`; unknown `kty` SHALL throw `CborError(CBOR.UNEXPECTED_TYPE)`. #### Scenario: decodes EC2 P-256 public key - **GIVEN** the canonical RFC 9052 §7.1 sample EC2 P-256 public key bytes - **WHEN** `decodeCoseKey(bytes)` is called - **THEN** the result is `{ kty: "EC2", crv: "P-256", x: , y: }` and TypeScript narrows the type to `Ec2Key` #### Scenario: decodes Ed25519 OKP public key - **GIVEN** an OKP public key with `crv: Ed25519` - **WHEN** `decodeCoseKey(bytes)` is called - **THEN** the result is `{ kty: "OKP", crv: "Ed25519", x: }` #### Scenario: rejects unknown kty - **GIVEN** a COSE_Key map with `kty: 99` - **WHEN** `decodeCoseKey(bytes)` is called - **THEN** the call throws `CborError` with code `"CBOR.UNEXPECTED_TYPE"` **Verified by:** `packages/flow/cbor/src/cose/cose-key.test.ts::"decodes EC2 P-256 public key"` AND `packages/flow/cbor/src/cose/cose-key.test.ts::"decodes Ed25519 OKP public key"` AND `packages/flow/cbor/src/cose/cose-key.test.ts::"rejects unknown kty with UNEXPECTED_TYPE"` --- ### Requirement: COSE ↔ JWK round-trip The package SHALL export `coseKeyToJwk(key: CoseKey): JsonWebKey` and `jwkToCoseKey(jwk: JsonWebKey): CoseKey` round-trip helpers per RFC 9052 §7.1, RFC 8037 §2 (OKP), and RFC 7518 §6 (RSA / EC / symmetric). Round-trip SHALL be lossless for every supported key type — calling `jwkToCoseKey(coseKeyToJwk(key))` SHALL produce a CoseKey deeply equal to the input, and the reverse SHALL hold for any JWK constructible from a supported COSE key. RSA keys SHALL serialize `n` and `e` as base64url strings per RFC 7518; EC2/OKP coordinate buffers SHALL be base64url-encoded. #### Scenario: ES256 EC2 round-trip - **GIVEN** an RFC 9052 §7.1 sample ES256 EC2 key - **WHEN** `jwkToCoseKey(coseKeyToJwk(key))` is computed - **THEN** the result deeply equals the input key #### Scenario: Ed25519 OKP round-trip - **GIVEN** an RFC 8037 sample Ed25519 OKP key - **WHEN** the same round-trip is computed - **THEN** the result deeply equals the input #### Scenario: RS256 RSA round-trip via base64url n/e - **GIVEN** an RSA public key `{ kty: "RSA", n: , e: }` - **WHEN** `coseKeyToJwk(key)` is called - **THEN** the resulting JWK has `n` and `e` as base64url strings, and the round-trip back via `jwkToCoseKey` recovers the original buffers **Verified by:** `packages/flow/cbor/src/cose/jwk.test.ts::"ES256 EC2 round-trip"` AND `packages/flow/cbor/src/cose/jwk.test.ts::"Ed25519 OKP round-trip"` AND `packages/flow/cbor/src/cose/jwk.test.ts::"RS256 RSA round-trip via base64url n/e"` --- ### Requirement: COSE alg label → genesis crypto-curve algorithm ID mapping The package SHALL export `coseAlgToCryptoAlg(alg: number)` mapping every IANA-registered COSE algorithm label this package supports to the corresponding `@suaveplan/crypto-curve` algorithm ID per RFC 9053 §2: `-7` → ES256, `-8` → EdDSA (Ed25519), `-35` → ES384, `-36` → ES512, `-257` → RS256, `-258` → RS384, `-259` → RS512. Unknown labels SHALL throw `CborError(CBOR.UNEXPECTED_TYPE)`. The mapping SHALL be one-to-one and SHALL NOT silently substitute a "close enough" algorithm. #### Scenario: ES256 maps to crypto-curve ES256 ID - **WHEN** `coseAlgToCryptoAlg(-7)` is called - **THEN** the result is the `@suaveplan/crypto-curve` ES256 algorithm ID #### Scenario: EdDSA maps to Ed25519 - **WHEN** `coseAlgToCryptoAlg(-8)` is called - **THEN** the result is the Ed25519 algorithm ID #### Scenario: unknown label throws UNEXPECTED_TYPE - **WHEN** `coseAlgToCryptoAlg(99999)` is called - **THEN** the call throws `CborError` with code `"CBOR.UNEXPECTED_TYPE"` **Verified by:** `packages/flow/cbor/src/cose/alg-map.test.ts::"ES256 (-7) maps to crypto-curve ES256"` AND `packages/flow/cbor/src/cose/alg-map.test.ts::"EdDSA (-8) maps to Ed25519"` AND `packages/flow/cbor/src/cose/alg-map.test.ts::"unknown label throws UNEXPECTED_TYPE"` --- ### Requirement: Diagnostic notation emitter (RFC 8949 §8) The package SHALL export `toDiagnostic(value): string` from the `./diagnostic` subpath that emits RFC 8949 §8 diagnostic notation for any decoded CBOR value. The emitter SHALL be debug-only — its sibling `.md` SHALL document that it is never to be invoked from encode/decode hot paths. Output SHALL match RFC 8949 §8 examples for the documented value forms (integers, floats with `_h_`, byte strings as `h''` hex, text strings as quoted, arrays / maps / tags with the documented separators). #### Scenario: emits RFC 8949 §8 example for {1: 2, 3: 4} - **WHEN** `toDiagnostic(new Map([[1, 2], [3, 4]]))` is called - **THEN** the result equals `"{1: 2, 3: 4}"` #### Scenario: emits byte-string as h'' - **WHEN** `toDiagnostic(new Uint8Array([0x01, 0x02]))` is called - **THEN** the result equals `"h'0102'"` #### Scenario: emits tagged value as N(...) - **WHEN** `toDiagnostic(new Tagged(0, "2026-05-10T00:00:00Z"))` is called - **THEN** the result equals `"0(\"2026-05-10T00:00:00Z\")"` **Verified by:** `packages/flow/cbor/src/diagnostic/diagnostic.test.ts::"emits RFC §8 example map form"` AND `packages/flow/cbor/src/diagnostic/diagnostic.test.ts::"emits byte string as h''"` AND `packages/flow/cbor/src/diagnostic/diagnostic.test.ts::"emits tagged value as N(...)"` --- ### Requirement: Error registry — every code thrown by ≥1 production path The package SHALL register exactly seven error codes via `defineCodes("CBOR", [...])`: `MALFORMED`, `UNEXPECTED_TYPE`, `UNKNOWN_TAG_STRICT`, `NESTED_TOO_DEEP`, `LENGTH_OVERFLOW`, `INDEFINITE_NOT_ALLOWED`, `NON_DETERMINISTIC`. Every code SHALL be thrown by at least one production code path inside `src/`; the registry SHALL NOT contain unused entries. The package SHALL export `CborError extends SuaveplanError` where `CborErrorCode` is the literal-union of the seven codes. #### Scenario: every code is reachable from src/ - **WHEN** the test scans `src/` for the throw site of each registered code - **THEN** every one of the seven codes is found in at least one production `.ts` file #### Scenario: CborErrorCode literal union is the registry shape - **WHEN** `expectTypeOf().toEqualTypeOf<"CBOR.MALFORMED" | "CBOR.UNEXPECTED_TYPE" | "CBOR.UNKNOWN_TAG_STRICT" | "CBOR.NESTED_TOO_DEEP" | "CBOR.LENGTH_OVERFLOW" | "CBOR.INDEFINITE_NOT_ALLOWED" | "CBOR.NON_DETERMINISTIC">()` runs - **THEN** the type-level assertion holds **Verified by:** `packages/flow/cbor/src/errors/error-codes.test.ts::"every registered code is thrown from production src"` AND `packages/flow/cbor/src/errors/error-codes.test.ts::"CborErrorCode literal union matches registry"` --- ### Requirement: Zod boundary schemas at every public option type The package SHALL declare Zod schemas (`EncoderOptionsSchema`, `DecoderOptionsSchema`, `TagHandlerSchema`, `CoseKeySchema` — discriminated by `kty` — and `JwkSchema`) and SHALL parse caller-supplied options at every public entry point (`encode`, `decode`, `decodeCoseKey`, `jwkToCoseKey`, `registry.register`). Schema failures SHALL surface as the genesis Zod-wrapped boundary error and SHALL NOT silently coerce malformed options into valid encodes / decodes. #### Scenario: malformed EncoderOptions rejected at boundary - **WHEN** `encode(0, { maxDepth: -1 } as never)` is called - **THEN** the call throws a Zod-validation error before any encoding takes place #### Scenario: malformed DecoderOptions rejected at boundary - **WHEN** `decode(bytes, { mode: "weird" as never })` is called - **THEN** the call throws a Zod-validation error #### Scenario: CoseKeySchema validates discriminated union - **WHEN** `CoseKeySchema.parse({ kty: "EC2", crv: "P-256", x: new Uint8Array(32), y: new Uint8Array(32) })` is called - **THEN** the result narrows to `Ec2Key` **Verified by:** `packages/flow/cbor/src/schemas/schemas.test.ts::"EncoderOptionsSchema rejects negative maxDepth"` AND `packages/flow/cbor/src/schemas/schemas.test.ts::"DecoderOptionsSchema rejects unknown mode"` AND `packages/flow/cbor/src/schemas/schemas.test.ts::"CoseKeySchema discriminates by kty"` --- ### Requirement: OpenTelemetry instrumentation The package SHALL emit `cbor.encode_duration_ms` and `cbor.decode_duration_ms` histograms (units: milliseconds) wrapping every `encode` / `decode` call, a `cbor.tag_total{tag}` counter incremented per decoded tagged item (where `tag` is the numeric tag label as a string), and a `cbor.error_total{code}` counter incremented on every thrown `CborError` (where `code` is one of the seven registry codes). Instruments SHALL be sourced via `@opentelemetry/api` and SHALL be assertable under a real in-memory `MeterProvider`. #### Scenario: encode emits histogram observation - **GIVEN** a test in-memory `MeterProvider` registered globally - **WHEN** `encode(0)` is called - **THEN** the recorded histogram for `cbor.encode_duration_ms` has at least one observation #### Scenario: decoded tag increments tag_total counter - **GIVEN** a payload encoding a tag-32 URI value - **WHEN** `decode(bytes)` is called under the test meter - **THEN** `cbor.tag_total{tag="32"}` equals 1 #### Scenario: thrown CborError increments error_total counter - **GIVEN** a malformed payload - **WHEN** `decode(bytes)` throws `CborError(CBOR.MALFORMED)` - **THEN** `cbor.error_total{code="CBOR.MALFORMED"}` equals 1 **Verified by:** `packages/flow/cbor/src/observability/metrics.test.ts::"encode records encode_duration_ms histogram"` AND `packages/flow/cbor/src/observability/metrics.test.ts::"decoded tag increments tag_total counter"` AND `packages/flow/cbor/src/observability/metrics.test.ts::"thrown CborError increments error_total counter"` --- ### Requirement: Property-based round-trip and deterministic-mode commutativity The package SHALL ship fast-check property tests that prove encoder + decoder agree on the wire format under arbitrary structures: for any randomly-generated CBOR-representable value `v`, `decode(encode(v))` SHALL deeply equal `v`. The package SHALL also ship a property test proving deterministic-mode commutativity: for any logical map `m`, encoding `m` under `deterministic: true` SHALL produce byte-identical output regardless of the map's internal key ordering. #### Scenario: round-trip property holds over arbitrary inputs - **GIVEN** a fast-check arbitrary covering integers, floats, byte strings, text strings, arrays, maps, tagged items, and nested combinations - **WHEN** `decode(encode(v))` is computed for 1000 generated values - **THEN** every result deeply equals the input #### Scenario: deterministic-mode commutativity holds over shuffled maps - **GIVEN** a fast-check arbitrary that generates the same logical map with shuffled insertion order - **WHEN** `encode(map, { deterministic: true })` is computed for both shuffles across 1000 trials - **THEN** the two byte sequences are byte-identical for every trial **Verified by:** `packages/flow/cbor/src/encoder/property.test.ts::"round-trip holds for arbitrary CBOR values"` AND `packages/flow/cbor/src/encoder/deterministic-property.test.ts::"deterministic encoding is commutative over shuffled map keys"` --- ### Requirement: Subpath exports The package SHALL declare subpath exports so consumers tree-shake to the minimum surface they need: - `@suaveplan/cbor` — root barrel - `@suaveplan/cbor/encoder` — `Encoder`, `encode`, encoder types, half-float encode helper - `@suaveplan/cbor/decoder` — `Decoder`, `decode`, decoder types, well-formedness checker - `@suaveplan/cbor/streaming` — `PullDecoder` - `@suaveplan/cbor/sequences` — RFC 8742 `encodeSequence`, `decodeSequence`, `decodeSequenceStream` - `@suaveplan/cbor/tags` — `TagRegistry`, `Tagged`, built-in tag handlers - `@suaveplan/cbor/deterministic` — RFC 8949 §4.2 deterministic profile + dCBOR profile - `@suaveplan/cbor/cose` — `decodeCoseKey`, `coseKeyToJwk`, `jwkToCoseKey`, `coseAlgToCryptoAlg`, COSE types - `@suaveplan/cbor/diagnostic` — `toDiagnostic` - `@suaveplan/cbor/errors` — `CBOR_CODES`, `CborError`, `CborErrorCode` #### Scenario: every subpath resolves - **WHEN** every documented subpath is imported in a consumer test fixture - **THEN** every import resolves with no `Module not found` errors **Verified by:** `packages/flow/cbor/src/index.test.ts::"every documented subpath resolves"`