Files
2026-05-27 23:29:53 +01:00

521 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- markdownlint-disable MD024 -->
# 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<T>` 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<unknown>` 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<Uint8Array>` 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<Uint8Array>` / `ReadableStream<Uint8Array>` and exposes `next(): Promise<IteratorResult<unknown>>` 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: <Uint8Array>, y: <Uint8Array> }` 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: <Uint8Array> }`
#### 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: <Uint8Array>, e: <Uint8Array> }`
- **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<CborErrorCode>` 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<CborErrorCode>().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"`
<!-- markdownlint-enable MD024 -->