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

10 KiB

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.<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) 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"