# Capability Spec Delta: packer (enterprise-packer-integrity) **Package**: `@suaveplan/packer` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. ## ADDED Requirements ### Requirement: 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 the lone entry digest; for an empty bundle the Merkle root MUST be `BLAKE3("")` (the 32-byte all-zero-input digest). 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. #### Scenario: whole-file pack emits integrity - **WHEN** `Packer.pack(data)` runs - **THEN** the returned `PackedData.metadata.integrity` is present, its `entryDigest` equals `blake3(payload)` of the wire-shaped payload bytes, and its `merkleRoot` equals `entryDigest` #### Scenario: chunked manifest emits per-entry digest and Merkle root - **WHEN** `buildManifest({chunks, ...}, signKey)` runs - **THEN** every `manifest.chunks[i].entryDigest` equals `blake3(wireBytes_i)` and `manifest.merkleRoot` equals `blake3(concat(sort_by_chunkId(digests)))` #### Scenario: empty bundle Merkle root is BLAKE3 of zero bytes - **WHEN** `merkleRootBlake3([])` is called - **THEN** the result equals `blake3(new Uint8Array(0))` **Verified by:** `src/integrity/integrity.test.ts::"computes the BLAKE3 entry digest of payload bytes"` AND `src/integrity/integrity.test.ts::"computes the Merkle root as BLAKE3 of concatenated sorted digests"` AND `src/integrity/integrity.test.ts::"returns BLAKE3 of zero bytes for an empty digest list"` AND `src/packer/packer.spec-conformance.test.ts::"populates metadata.integrity on every pack"` AND `src/chunked/manifest/manifest.spec-conformance.test.ts::"populates entryDigest on every chunk entry and a manifest merkleRoot"` --- ### Requirement: 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. #### Scenario: whole-file unpack rejects missing integrity - **WHEN** `Unpacker.unpack(packed)` is called on a `PackedData` whose `metadata.integrity` field is absent - **THEN** it throws `PackerError(PACKER.INTEGRITY_FAILED)` and the resolved message is the catalog value for `packer.error.INTEGRITY_FAILED.absent` #### Scenario: whole-file unpack rejects entry-digest mutation - **WHEN** `Unpacker.unpack(packed)` is called on a `PackedData` whose `metadata.integrity.entryDigest` has been mutated by one bit - **THEN** it throws `PackerError(PACKER.INTEGRITY_FAILED)` before yielding the plaintext #### Scenario: whole-file unpack rejects merkle-root mutation - **WHEN** `Unpacker.unpack(packed)` is called on a `PackedData` whose `metadata.integrity.merkleRoot` has been mutated by one bit - **THEN** it throws `PackerError(PACKER.INTEGRITY_FAILED)` #### Scenario: chunked unpack rejects per-chunk BLAKE3 mismatch - **WHEN** `unpackChunksWithManifest(chunks, manifest, opts)` is consumed fully and one chunk's wire bytes differ from `manifest.chunks[i].entryDigest` - **THEN** the iterator throws `PackerError(PACKER.INTEGRITY_FAILED)` before yielding plaintext for the mismatched chunk #### Scenario: chunked unpack rejects merkle-root mismatch - **WHEN** `unpackChunksWithManifest(chunks, manifest, opts)` is given a manifest whose `merkleRoot` does not equal `merkleRootBlake3(...)` of its entries - **THEN** the iterator throws `PackerError(PACKER.INTEGRITY_FAILED)` before consuming any chunk **Verified by:** `src/packer/packer.spec-conformance.test.ts::"rejects unpack when metadata.integrity is absent"` AND `src/packer/packer.spec-conformance.test.ts::"rejects unpack on mutated entryDigest"` AND `src/packer/packer.spec-conformance.test.ts::"rejects unpack on mutated merkleRoot"` AND `src/chunked/unpacker/unpacker-with-manifest.spec-conformance.test.ts::"rejects per-chunk BLAKE3 mismatch with INTEGRITY_FAILED"` AND `src/chunked/unpacker/unpacker-with-manifest.spec-conformance.test.ts::"rejects manifest with inconsistent merkleRoot"` --- ### Requirement: 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: | Instrument | Type | Unit | |---|---|---| | `packer_bundle_size_bytes` | counter | `By` | | `packer_entry_count` | counter | `1` | | `packer_pack_total` | counter | `1` | | `packer_unpack_total` | counter | `1` | | `packer_integrity_failure_total` | counter | `1` | | `packer_pack_duration_ms` | histogram | `ms` | | `packer_unpack_duration_ms` | histogram | `ms` | | `packer_entry_decode_ms` | histogram | `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. #### Scenario: pack emits counters, histogram, and span - **WHEN** `Packer.pack(data)` runs under a real in-memory `MeterProvider` and `BasicTracerProvider` - **THEN** the collected metrics include a `packer_pack_total` increment, a `packer_bundle_size_bytes` increment equal to the output byte length, a `packer_entry_count` increment of `1`, and a `packer_pack_duration_ms` histogram observation; the collected spans include one whose name is `packer.pack` #### Scenario: unpack emits counters, histogram, and span - **WHEN** `Unpacker.unpack(packed)` runs under a real in-memory provider - **THEN** the collected metrics include `packer_unpack_total` (+1) and a `packer_unpack_duration_ms` histogram observation; spans include `packer.unpack` #### Scenario: integrity failure increments failure counter - **WHEN** `Unpacker.unpack` rejects with `PACKER.INTEGRITY_FAILED` - **THEN** `packer_integrity_failure_total` is incremented by 1 and the span is recorded with status ERROR #### Scenario: chunked paths emit spans and per-entry decode histogram - **WHEN** `packChunks` / `unpackChunks` / `unpackChunksWithManifest` are iterated to completion - **THEN** spans `packer.pack_chunks` / `packer.unpack_chunks` / `packer.unpack_chunks_with_manifest` are recorded and one `packer_entry_decode_ms` observation is recorded per consumed chunk **Verified by:** `src/observability/observability.spec-conformance.test.ts::"records packer_pack_total and packer_bundle_size_bytes on every pack"` AND `src/observability/observability.spec-conformance.test.ts::"records packer_unpack_total and a duration histogram on every unpack"` AND `src/observability/observability.spec-conformance.test.ts::"increments packer_integrity_failure_total when unpack rejects on integrity"` AND `src/observability/observability.spec-conformance.test.ts::"emits a packer.pack span captured by an in-memory span exporter"` AND `src/observability/observability.spec-conformance.test.ts::"emits a packer.unpack_chunks_with_manifest span and per-entry decode histogram observations"` --- ### Requirement: 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..`. 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)` MUST pass a resolver-returned string, never a hard-coded English literal. #### Scenario: every catalog key is reachable from a throw site - **WHEN** the test suite walks the catalog at `i18n/messages.ts` - **THEN** every key is also a string literal passed to the resolver in at least one `throw` site in `src/` #### Scenario: custom resolver intercepts every throw - **WHEN** an `Unpacker` is constructed with a custom `messageResolver` that returns `"LOCALE_X:" + key` and `unpack` is called on inputs that trigger every error path - **THEN** every thrown `PackerError.message` is prefixed with `"LOCALE_X:"` #### Scenario: default resolver returns the English catalog value - **WHEN** a `Packer` is constructed without `messageResolver` - **THEN** errors thrown by `pack` carry the English catalog string for the triggered key **Verified by:** `src/i18n/messages.spec-conformance.test.ts::"every key in the catalog is referenced from at least one throw site in src/"` AND `src/i18n/messages.spec-conformance.test.ts::"custom MessageResolver is consulted by Unpacker.unpack on every error path"` AND `src/i18n/messages.spec-conformance.test.ts::"default resolver returns the English catalog value when no resolver is injected"`