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

32 KiB
Raw Permalink Blame History

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"