521 lines
32 KiB
Markdown
521 lines
32 KiB
Markdown
<!-- 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 -->
|