Files
suaveplan/openspec/specs/packer/spec.md
2026-05-27 23:29:53 +01:00

193 lines
26 KiB
Markdown
Raw 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.
# Capability Spec: packer
- **Capability**: `packer`
- **Package**: `@suaveplan/packer`
- **Change**: packer-port
- **Date**: 2026-04-28
---
## Requirements
All requirements in this section are new for genesis. The package is a partial rewrite of `../suaveplan/packages/universal/packer` v2.0.0; whole-file mode preserves the source's pipeline shape with a simplified envelope, while chunked mode is rewritten to emit the locked v1 byte format pinned below.
### PACKER-01 — Whole-file pack/unpack round-trip
`Packer.pack(data, filename?)` followed by `Unpacker.unpack(packed, key, pubKey?)` MUST yield bytes equal to the original `data`. Optional MIME detection MUST surface in the returned metadata.
**Verified by:** `src/unpacker/unpacker.spec-conformance.test.ts::"round-trips uncompressed unencrypted bytes"`.
### PACKER-02 — Whole-file gzip compression
When `compressionLevel > 0`, `pack` MUST gzip the payload before AEAD; `unpack` MUST gunzip after AEAD. The `metadata.compressionAlgorithm` MUST surface as `"gzip"` when compression ran.
**Verified by:** `src/packer/packer.spec-conformance.test.ts::"compresses when compressionLevel > 0"`; `src/unpacker/unpacker.spec-conformance.test.ts::"round-trips compressed bytes"`.
### PACKER-03 — Whole-file signing
When a `signing.key` is provided to `pack`, the produced `PackedData.metadata` MUST carry `signed: true` and a 64-byte ed25519 signature over the post-encryption ciphertext. `unpack` with `verifySignature: true` and a 32-byte `verifyKey` MUST verify the signature and throw `PackerError(PACKER.VERIFICATION_FAILED)` on mismatch.
**Verified by:** `src/packer/packer.spec-conformance.test.ts::"appends an ed25519 signature when signing.key supplied"`; `src/unpacker/unpacker.spec-conformance.test.ts::"verifySignature with wrong key returns VERIFICATION_FAILED"`.
### PACKER-04 — Whole-file event lifecycle
`Packer` MUST emit typed events via `EventBus<PackerEventMap>`: `pack:start`, `pack:compressed`, `pack:encrypted`, `pack:signed`, `pack:complete`, with corresponding `unpack:*` events on the unpacker. Events MUST fire in deterministic order on completion.
**Verified by:** `src/packer/packer.spec-conformance.test.ts::"emits pack:start, pack:compressed, pack:encrypted, pack:signed, pack:complete in order"`; `src/unpacker/unpacker.spec-conformance.test.ts::"emits unpack:start, unpack:verified, unpack:decrypted, unpack:decompressed, unpack:complete in order"`.
### PACKER-05 — Chunked v1 byte layout
A v1 chunk produced by `ChunkedPacker` MUST be the byte concatenation `metadata(100) ‖ sig(64) ‖ ciphertext(payloadLen)`, total `164 + payloadLen` bytes, big-endian. Field offsets within metadata MUST be exactly: `version` 0–2, `fileId` 2–34, `chunkId` 34–38, `totalChunks` 38–42, `fileSize` 42–50, `chunkSize` 50–54, `payloadLen` 54–58, `flags` 58–60, `nonce` 60–84, `aeadTag` 84–100. Sig occupies 100–164. Ciphertext starts at 164.
**Verified by:** `src/chunked/codec/chunk.spec-conformance.test.ts::"metadata field offsets match the locked layout"`.
### PACKER-06 — `flags` reserved in v1
`flags` MUST be `0x0000` in v1. `parseHeader` MUST reject non-zero `flags` with `PackerError(PACKER.FLAGS_RESERVED_NONZERO)` before any further parse work.
**Verified by:** `src/chunked/codec/chunk.spec-conformance.test.ts::"parseHeader rejects flags != 0 with FLAGS_RESERVED_NONZERO"`.
### PACKER-07 — `payloadLen` invariants
For non-final chunks (`chunkId < totalChunks - 1`), `payloadLen` MUST equal `chunkSize - 164`. For the final chunk, `payloadLen` MUST equal `fileSize - chunkId * (chunkSize - 164)`. Mismatch MUST be rejected with `PackerError(PACKER.PAYLOAD_LEN_INVALID)` before sig verification.
**Verified by:** `src/chunked/codec/chunk.spec-conformance.test.ts::"non-final chunk MUST have payloadLen === chunkSize - 164"`; `src/chunked/codec/chunk.spec-conformance.test.ts::"final chunk MUST have payloadLen === fileSize - chunkId * (chunkSize - 164)"`.
### PACKER-08 — Streaming pipeline produces v1 chunks
`ChunkedPacker.pack(source, opts)` MUST stream-process the input via `normalizeSource → accumulate → encrypt → sign → emit`. The output MUST be an `AsyncIterable<{ chunkId, bytes, metadata }>` where each `bytes` is a v1-format chunk. The full source MUST NOT be buffered as a single in-memory copy.
**Verified by:** `src/chunked/packer/packer.spec-conformance.test.ts::"round-trips multi-chunk file with short final chunk"`.
### PACKER-09 — Non-final chunks are exactly `chunkSize` bytes
Every emitted chunk with `chunkId < totalChunks - 1` MUST be exactly `chunkSize` bytes on the wire. No zero padding is added.
**Verified by:** `src/chunked/packer/packer.spec-conformance.test.ts::"round-trips multi-chunk file with short final chunk"`; `src/chunked/packer/packer.spec-conformance.test.ts::"round-trips file whose size is exact multiple of payloadPer"`.
### PACKER-10 — Final chunk is naturally short
The final chunk MUST be `164 + lastPayloadLen` bytes, where `lastPayloadLen = fileSize - (totalChunks - 1) * (chunkSize - 164)`. The final chunk MUST NOT be zero-padded to `chunkSize`.
**Verified by:** `src/chunked/packer/packer.spec-conformance.test.ts::"round-trips multi-chunk file with short final chunk"`.
### PACKER-11 — Round-trip parity
`ChunkedUnpacker.unpack(chunks, fileKey, serverPubKey)` over the chunks emitted by `ChunkedPacker.pack(...)` MUST yield a plaintext stream byte-equal to the original input. Order MUST be `chunkId` ascending; out-of-order delivery is the consumer's responsibility to reorder before passing to unpacker.
**Verified by:** `src/chunked/unpacker/unpacker.spec-conformance.test.ts::"decrypts multi-chunk stream in order"`.
### PACKER-12 — Mutation detection
Any mutation to any signed byte MUST cause `ChunkedUnpacker.unpack` to throw `PackerError(PACKER.VERIFICATION_FAILED)` from the per-chunk sig-verify step before AEAD decryption is attempted.
**Verified by:** `src/chunked/unpacker/unpacker.spec-conformance.test.ts::"rejects chunk signed by a different key"`.
### PACKER-13 — Verifier without file key
`ChunkVerifier.verify(chunkBytes, serverPubKey)` MUST validate the structural invariants and the ed25519 signature without the file decryption key. It MUST return `{ ok: true, metadata }` for valid chunks and `{ ok: false, code }` for invalid chunks. It MUST NOT attempt AEAD decryption.
**Verified by:** `src/chunked/verifier/chunk-verifier.spec-conformance.test.ts::"returns ok for a properly-signed chunk"`.
### PACKER-14 — Verifier rejects forged sig
`ChunkVerifier.verify` MUST return `{ ok: false, code: "PACKER.VERIFICATION_FAILED" }` for a chunk whose signature does not validate under `serverPubKey`. Verifier MUST NOT consult the chunks plugin or any persistence layer.
**Verified by:** `src/chunked/verifier/chunk-verifier.spec-conformance.test.ts::"returns VERIFICATION_FAILED for tampered metadata"`; `src/chunked/verifier/chunk-verifier.spec-conformance.test.ts::"returns VERIFICATION_FAILED for chunk signed by a different key"`.
### PACKER-15 — Manifest signature
The manifest produced by `buildManifest(...)` MUST be ed25519-signed via the canonical encoding of every field except `sig` itself. `verifyManifest(manifest, serverPubKey)` MUST return `false` for any manifest whose signature does not validate; mutation of any signed field MUST cause verification failure.
**Verified by:** `src/chunked/manifest/manifest.spec-conformance.test.ts::"signs and verifies a manifest"`; `src/chunked/manifest/manifest.spec-conformance.test.ts::"verify returns false on tampered field"`.
### PACKER-16 — Manifest matches chunk metadata
`buildManifest` MUST produce a manifest where `totalChunks === chunks.length`, `version === 1`, and the per-chunk entries (`chunkId`, `hash`, `payloadLen`) match the inputs. The `sig` field MUST be 64 bytes.
**Verified by:** `src/chunked/manifest/manifest.spec-conformance.test.ts::"signs and verifies a manifest"`.
### PACKER-17 — Error registry namespaced and frozen
`PACKER_CODES` MUST be declared via `defineCodes("PACKER", [...])` from `@suaveplan/error`. `Object.isFrozen(PACKER_CODES)` MUST be `true`. Every code value MUST start with `PACKER.`. `PackerError` MUST extend `SuaveplanError<PackerErrorCode>`.
**Verified by:** `src/error-codes.spec-conformance.test.ts::"PACKER_CODES is frozen"`; `src/error-codes.spec-conformance.test.ts::"every code value starts with PACKER."`; `src/error-codes.spec-conformance.test.ts::"PackerError extends SuaveplanError"`.
### PACKER-18 — BLAKE3 per-entry digest and bundle-level Merkle root
The package SHALL compute and surface a content-addressable BLAKE3 digest for every entry written to a bundle, plus a bundle-level Merkle root computed over the sorted entry digests. The Merkle root MUST be a 32-byte BLAKE3 digest of the concatenation of the sorted entry digests in `chunkId`-ascending order; for a single-entry bundle the Merkle root equals `blake3(entryDigest)` (domain-separated wrap of the lone leaf); for an empty bundle the Merkle root MUST be `blake3("")` (the 32-byte digest of zero input bytes). Whole-file bundles carry `(entryDigest, merkleRoot)` on `PackedDataMetadata.integrity`; chunked bundles carry per-chunk `entryDigest` on every `ManifestChunkEntry` and `merkleRoot` at the manifest root. The production paths `Packer.pack` and `buildManifest` MUST always populate these fields.
**Verified by:** `src/integrity/integrity.test.ts::"computes the BLAKE3 entry digest of payload bytes"`; `src/integrity/integrity.test.ts::"computes the Merkle root as BLAKE3 of concatenated sorted digests"`; `src/integrity/integrity.test.ts::"returns BLAKE3 of zero bytes for an empty digest list"`; `src/packer/packer.spec-conformance.test.ts::"populates metadata.integrity on every pack"`; `src/chunked/manifest/manifest.spec-conformance.test.ts::"populates entryDigest on every chunk entry and a manifest merkleRoot"`.
### PACKER-19 — Mandatory integrity verification on unpack
The package SHALL reject any unpack of a bundle whose recomputed integrity digests do not match the bundle's recorded values. `Unpacker.unpack` MUST throw `PackerError(PACKER.INTEGRITY_FAILED)` when `metadata.integrity` is absent OR when the recomputed `entryDigest` or `merkleRoot` does not match. `unpackChunksWithManifest` MUST throw `PackerError(PACKER.INTEGRITY_FAILED)` for any chunk whose `blake3(wireBytes)` does not equal the matching `manifest.chunks[i].entryDigest`, OR when the manifest's `merkleRoot` does not equal `merkleRootBlake3(manifest.chunks.map(c => c.entryDigest))`. Integrity verification MUST run on **both** the whole-file and chunked paths; ed25519 signing remains in force in parallel. Callers MAY pass `verifyIntegrity: false` on `UnpackOptions` as a documented escape hatch for legacy bundles produced before the integrity layer existed.
**Verified by:** `src/packer/packer.spec-conformance.test.ts::"rejects unpack when metadata.integrity is absent"`; `src/packer/packer.spec-conformance.test.ts::"rejects unpack on mutated entryDigest"`; `src/packer/packer.spec-conformance.test.ts::"rejects unpack on mutated merkleRoot"`; `src/chunked/unpacker/unpacker-with-manifest.spec-conformance.test.ts::"rejects per-chunk BLAKE3 mismatch with INTEGRITY_FAILED"`; `src/chunked/unpacker/unpacker-with-manifest.spec-conformance.test.ts::"rejects manifest with inconsistent merkleRoot"`.
### PACKER-20 — OpenTelemetry instrumentation on every public entrypoint
The package SHALL emit OpenTelemetry counters, histograms, and spans on every public entrypoint via an injected `PackerObservability` bundle. The named instruments are `packer_bundle_size_bytes`, `packer_entry_count`, `packer_pack_total`, `packer_unpack_total`, `packer_integrity_failure_total` (counters), and `packer_pack_duration_ms`, `packer_unpack_duration_ms`, `packer_entry_decode_ms` (histograms, unit `ms`). Spans MUST wrap `Packer.pack`, `Unpacker.unpack`, `packChunks`, `unpackChunks`, and `unpackChunksWithManifest`. When no observability bundle is injected, the package MUST fall back to the no-op meter and tracer obtained from `@opentelemetry/api` (`metrics.getMeter` / `trace.getTracer`); no instrument call may throw.
**Verified by:** `src/observability/observability.spec-conformance.test.ts::"records packer_pack_total and packer_bundle_size_bytes on every pack"`; `src/observability/observability.spec-conformance.test.ts::"records packer_unpack_total and a duration histogram on every unpack"`; `src/observability/observability.spec-conformance.test.ts::"increments packer_integrity_failure_total when unpack rejects on integrity"`; `src/observability/observability.spec-conformance.test.ts::"emits a packer.pack span captured by an in-memory span exporter"`; `src/observability/observability.spec-conformance.test.ts::"emits a packer.unpack_chunks_with_manifest span and per-entry decode histogram observations"`.
### PACKER-21 — i18n-keyed error messages via injected MessageResolver
The package SHALL route every user-visible error message through an injected `MessageResolver` (`@suaveplan/types`). A per-package English-defaults catalog lives at `src/i18n/messages.ts` keyed by `packer.error.<CODE>.<scenario>`. Constructor options (`PackerOptions.messageResolver`, `PackChunkedOptions.messageResolver`, `UnpackChunkedOptions.messageResolver`) accept a caller-supplied resolver; when absent, the package falls back to `defaultPackerResolver` which reads the local catalog. Every `throw new PackerError(CODE, message)` site MUST pass a resolver-returned string, never a hard-coded English literal. The catalog MUST be `Object.freeze`d and every catalog key MUST be reachable from at least one `throw` site in `src/`.
**Verified by:** `src/i18n/messages.spec-conformance.test.ts::"every key in the catalog is referenced from at least one throw site in src/"`; `src/i18n/messages.spec-conformance.test.ts::"custom MessageResolver is consulted by Unpacker.unpack on every error path"`; `src/i18n/messages.spec-conformance.test.ts::"default resolver returns the English catalog value when no resolver is injected"`.
### PACKER-22 — `@suaveplan/retry` on IO-backed source reads
The package SHALL route every IO-backed source pull (every `reader.read()` call against a `ReadableStream` consumed by `normalizeSource` or the abstract `collect` helper) through `@suaveplan/retry`'s `withRetry`. The retry policy MUST be configurable per call via a `retryConfig?: RetryConfig` option on `PackerOptions`, `PackChunkedOptions`, and `UnpackChunkedOptions`. When `retryConfig` is absent the package MUST default to a single-attempt policy (`maxAttempts: 1`) so wire behaviour matches cluster #1. When `retryConfig` is supplied the retry loop MUST honour the caller's `AbortSignal`: cancellation MUST abort the retry loop immediately (no further attempts, no further sleeps) and surface as a `RetryAbortedError`.
**Verified by:** `src/retry/retryable-read.test.ts::"retries up to maxAttempts on a failing reader"`; `src/retry/retryable-read.test.ts::"cancels the retry loop when AbortSignal fires mid-backoff"`; `src/retry/retryable-read.spec-conformance.test.ts::"default policy is a single attempt with no retries"`; `src/retry/retryable-read.spec-conformance.test.ts::"retryRead surfaces RetryAbortedError when signal fires before first read"`.
### PACKER-23 — Idempotent repeat-pack short-circuit
The package SHALL expose an injectable idempotency cache on `PackerOptions.idempotency` (`{ store, ttlMs?, enabled? }`). When enabled, `Packer.pack(data, opts?)` MUST compute a content-derived dedup key from `blake3(data)` plus the packer's pipeline parameters (`compressionLevel`, `encryption.algorithm`, `signing` enabled bit), look the key up in the supplied `IdempotencyStoreAdapter`, and on hit return the previously-built `PackedData` without re-running the gzip / AEAD / sign / integrity pipeline. On miss the pipeline MUST run as before and the resulting `PackedData` MUST be written back to the store under the same key with the configured TTL (default `86_400_000` ms / 24 h). Idempotency MUST be opt-in: callers that do not pass `idempotency` see the legacy non-cached behaviour.
**Verified by:** `src/idempotency/idempotency.test.ts::"second pack with identical input short-circuits to cached PackedData"`; `src/idempotency/idempotency.test.ts::"cache miss populates the store with the resulting PackedData"`; `src/idempotency/idempotency.spec-conformance.test.ts::"idempotency is disabled by default"`; `src/idempotency/idempotency.spec-conformance.test.ts::"custom IdempotencyStoreAdapter is consulted exactly once per pack"`.
### PACKER-24 — Whole-file `AbortSignal` propagation
The package SHALL accept an `AbortSignal` on every async public entrypoint and MUST check the signal at every yield point. `Unpacker.unpack` accepts `UnpackOptions.signal?` and MUST check it before integrity verify, before signature verify, before AEAD decrypt, before gunzip, and before the final emit. `Packer.pack` accepts an additional overload `pack(data, opts: PackOptions)` where `PackOptions.signal?` is checked before compression, before encryption, before signing, before integrity compute, and before the final return. `packChunks` accepts `PackChunkedOptions.signal?` and MUST check it on every loop iteration before yielding the next packed chunk. Aborted operations MUST throw `PackerError(PACKER.ABORTED)` with the i18n catalog message `packer.error.ABORTED.<entrypoint>`. The original `Packer.pack(data, filename?)` overload MUST remain a valid call shape for back-compat.
**Verified by:** `src/unpacker/unpacker.spec-conformance.test.ts::"rejects with PACKER.ABORTED when signal is pre-aborted"`; `src/unpacker/unpacker.spec-conformance.test.ts::"rejects with PACKER.ABORTED when signal fires between pipeline steps"`; `src/packer/packer.spec-conformance.test.ts::"rejects with PACKER.ABORTED when opts.signal is pre-aborted"`; `src/chunked/packer/packer.spec-conformance.test.ts::"aborts mid-stream when signal fires after the second chunk"`; `src/packer/packer.spec-conformance.test.ts::"legacy pack(data, filename) overload still surfaces mimeType"`.
### PACKER-25 — Injected `Clock` and `Rng` everywhere
The package SHALL accept `clock?: Clock` and `rng?: Rng` (types from `@suaveplan/types`) on every public option bundle (`PackerOptions`, `PackChunkedOptions`, `UnpackChunkedOptions`). Every production read of wall-clock time MUST go through the resolved `Clock` (no direct `Date.now()`); every production source of randomness MUST go through the resolved `Rng` (no direct `Math.random()` / `crypto.getRandomValues`); every elapsed-time measurement MUST go through an injected `monotonicClock?: MonotonicClock` (defaults to a wrapper around `performance.now()`). The defaults MUST produce byte-identical wire output to the cluster #1 behaviour. Determinism: two `Packer.pack` calls with identical inputs, identical options, and a seeded `Rng` MUST produce byte-identical `PackedData.data` and identical `metadata.integrity` values.
**Verified by:** `src/packer/packer.spec-conformance.test.ts::"timestamp is sourced from the injected Clock"`; `src/packer/packer.spec-conformance.test.ts::"AEAD nonce is sourced from the injected Rng"`; `src/packer/packer.spec-conformance.test.ts::"two packs with identical inputs and seeded Rng produce byte-identical output"`; `src/runtime/runtime.spec-conformance.test.ts::"no production file calls Date.now / Math.random / crypto.getRandomValues directly"`.
### PACKER-26 — `@suaveplan/schema-registry` integration
The package SHALL register every Zod schema in `src/schemas/packer.schemas.ts` (`PackerOptionsSchema`, `UnpackOptionsSchema`, `ChunkedPackerOptionsSchema`, `ManifestSchema`) plus a `packer-chunk-format` subject capturing the wire-format version byte, into a process-singleton `SchemaRegistry` exposed via `getPackerSchemaRegistry()`. The `packer-chunk-format` subject MUST register V1 (the current wire format) under a `BACKWARD` compatibility policy with evolution metadata `{ formatVersion: "1", encoding: "binary", description: "v1 fixed-width 164-byte header + AEAD payload" }` so a future V2 entry can be added without disturbing V1. Every other schema MUST be registered under its own subject (`PackerOptions`, `UnpackOptions`, `ChunkedPackerOptions`, `Manifest`) carrying the metadata `{ formatVersion: "1" }`. A `registerPackerSchemas(registry)` function MUST allow callers to register the same schemas into any external `SchemaRegistry` instance for cross-package validation.
**Verified by:** `src/schemas/registration.test.ts::"registers every option schema as a subject with formatVersion 1 metadata"`; `src/schemas/registration.test.ts::"records packer-chunk-format V1 with binary encoding and BACKWARD compatibility"`; `src/schemas/registration.spec-conformance.test.ts::"getPackerSchemaRegistry returns a process-singleton populated with the canonical subjects"`; `src/schemas/registration.spec-conformance.test.ts::"registerPackerSchemas writes into a caller-supplied registry without mutating the global singleton"`.
### PACKER-27 — `@suaveplan/feature-flags` for format-version rollouts
The package SHALL accept a `flags?: PackerFlagsOptions` bundle on `PackerOptions` and route the wire-format selection through the resolved `PackerFeatureFlags` provider. The exported constant `PACKER_FLAG_KEYS.FORMAT_V2 === "packer.format.v2"` MUST identify the V2 rollout flag. When no provider is supplied, `defaultPackerFlags()` MUST act as a no-op that returns the V1 default (`false`) for every flag. When the resolved provider reports `packer.format.v2 === true`, `Packer.pack` MUST throw `PackerError(PACKER.FORMAT_VERSION_UNSUPPORTED)` with the catalog message `packer.error.FORMAT_VERSION_UNSUPPORTED.v2-not-implemented`; this is a versioning gate — the V2 codec is a future change. A caller-supplied `@suaveplan/feature-flags` `Evaluator` MUST be acceptable as the provider via `resolvePackerFlags({ evaluator, context? })`.
**Verified by:** `src/flags/flags.test.ts::"defaultPackerFlags returns false for packer.format.v2"`; `src/flags/flags.test.ts::"resolvePackerFlags adapts a feature-flags Evaluator into a PackerFeatureFlags provider"`; `src/flags/flags.spec-conformance.test.ts::"Packer.pack throws PACKER.FORMAT_VERSION_UNSUPPORTED when packer.format.v2 is enabled"`; `src/flags/flags.spec-conformance.test.ts::"Packer.pack succeeds in V1 when flags option is omitted"`; `src/flags/flags.spec-conformance.test.ts::"a caller-supplied feature-flags Evaluator drives the format-version gate"`.
### PACKER-28 — Plugin / extension interface
The package SHALL expose a `PackerPlugin` contract carrying six lifecycle hooks (`onPackStart(ctx)`, `onEntry(ctx)`, `onPackEnd(ctx)`, `onUnpackStart(ctx)`, `onEntryDecoded(ctx)`, `onUnpackEnd(ctx)`), each optional and each invoked in plugin-declaration order. Plugins are registered via `plugins?: ReadonlyArray<PackerPlugin>` on `PackerOptions`. `Packer.pack` MUST invoke `onPackStart` exactly once before the pipeline, `onEntry` exactly once per entry packed (one for whole-file mode), and `onPackEnd` exactly once after the final result is composed. `Unpacker.unpack` MUST invoke `onUnpackStart` / `onEntryDecoded` / `onUnpackEnd` symmetrically. Any plugin error MUST be caught and re-thrown as `PackerError(PACKER.PLUGIN_FAILED)` with a catalog message `packer.error.PLUGIN_FAILED.<hook>` and the original error carried on `error.cause`.
**Verified by:** `src/plugins/plugins.test.ts::"runs onPackStart / onEntry / onPackEnd in declaration order"`; `src/plugins/plugins.test.ts::"runs onUnpackStart / onEntryDecoded / onUnpackEnd in declaration order"`; `src/plugins/plugins.test.ts::"wraps thrown plugin errors as PackerError(PACKER.PLUGIN_FAILED) with cause"`; `src/plugins/plugins.spec-conformance.test.ts::"a noop-logger plugin observes every lifecycle hook"`; `src/plugins/plugins.spec-conformance.test.ts::"a double-integrity sidecar plugin contributes SHA-256 alongside BLAKE3"`.
### PACKER-29 — `fast-check` property-based round-trip + determinism
The package SHALL include `fast-check` property tests pinning the pack/unpack round-trip identity, streaming↔batch equivalence, and seeded-Rng determinism invariants. Property assertions MUST run over arbitrary `Uint8Array` inputs of length 0..1024 (at least 25 runs per property). Round-trip identity: for every `data`, `unpack(pack(data))` returns bytes byte-equal to `data`. Streaming equivalence: the concatenation of every `PackedChunk.bytes` emitted by `packChunks(asyncIter(data), { ...same fileKey/signKey/baseNonce })` MUST byte-equal the wire bytes of `packChunks(data, { ...same opts })` consumed from a whole-buffer source. Determinism: two `Packer.pack` runs with identical inputs, identical options, the same seeded `Rng` and the same fixed `Clock` MUST produce byte-identical `PackedData` (both `data` and every byte of `metadata.integrity`).
**Verified by:** `src/__property__/round-trip.property.test.ts::"pack then unpack recovers the original bytes for arbitrary inputs"`; `src/__property__/round-trip.property.test.ts::"chunked streaming pack matches whole-buffer chunked pack byte-for-byte"`; `src/__property__/determinism.property.test.ts::"two packs with identical inputs and seeded Rng produce byte-identical bundles"`.
### PACKER-30 — mitata benchmark suite
The package SHALL ship a runnable mitata benchmark suite at `src/__benchmarks__/packer.bench.ts` covering: (a) whole-file `Packer.pack` over a fixed 1 KiB payload, (b) whole-file `Unpacker.unpack` over the result of (a), (c) chunked `packChunks` over a single-chunk source, (d) chunked `packChunks` over a 1000-chunk source, (e) `computeWholeFileIntegrity` over a fixed 1 KiB payload, and (f) a plugin-overhead comparison (`Packer.pack` with zero plugins vs. with a no-op plugin). The benchmark MUST be runnable via `bun run benchmark` declared in `package.json` and MUST execute without error. Baseline numbers MUST be published in `README.md` under a `## Benchmarks` section with a hardware/runtime context line.
**Verified by:** `src/benchmarks-conformance/packer.bench.spec-conformance.test.ts::"declares the six named benchmark cases"`; `src/benchmarks-conformance/packer.bench.spec-conformance.test.ts::"package.json declares a bun run benchmark script"`; `src/benchmarks-conformance/packer.bench.spec-conformance.test.ts::"README.md publishes a Benchmarks section with a hardware context line"`.