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.integrityis present, itsentryDigestequalsblake3(payload)of the wire-shaped payload bytes, and itsmerkleRootequalsentryDigest
Scenario: chunked manifest emits per-entry digest and Merkle root
- WHEN
buildManifest({chunks, ...}, signKey)runs - THEN every
manifest.chunks[i].entryDigestequalsblake3(wireBytes_i)andmanifest.merkleRootequalsblake3(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 aPackedDatawhosemetadata.integrityfield is absent - THEN it throws
PackerError(PACKER.INTEGRITY_FAILED)and the resolved message is the catalog value forpacker.error.INTEGRITY_FAILED.absent
Scenario: whole-file unpack rejects entry-digest mutation
- WHEN
Unpacker.unpack(packed)is called on aPackedDatawhosemetadata.integrity.entryDigesthas 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 aPackedDatawhosemetadata.integrity.merkleRoothas 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 frommanifest.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 whosemerkleRootdoes not equalmerkleRootBlake3(...)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-memoryMeterProviderandBasicTracerProvider - THEN the collected metrics include a
packer_pack_totalincrement, apacker_bundle_size_bytesincrement equal to the output byte length, apacker_entry_countincrement of1, and apacker_pack_duration_mshistogram observation; the collected spans include one whose name ispacker.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 apacker_unpack_duration_mshistogram observation; spans includepacker.unpack
Scenario: integrity failure increments failure counter
- WHEN
Unpacker.unpackrejects withPACKER.INTEGRITY_FAILED - THEN
packer_integrity_failure_totalis incremented by 1 and the span is recorded with status ERROR
Scenario: chunked paths emit spans and per-entry decode histogram
- WHEN
packChunks/unpackChunks/unpackChunksWithManifestare iterated to completion - THEN spans
packer.pack_chunks/packer.unpack_chunks/packer.unpack_chunks_with_manifestare recorded and onepacker_entry_decode_msobservation 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
throwsite insrc/
Scenario: custom resolver intercepts every throw
- WHEN an
Unpackeris constructed with a custommessageResolverthat returns"LOCALE_X:" + keyandunpackis called on inputs that trigger every error path - THEN every thrown
PackerError.messageis prefixed with"LOCALE_X:"
Scenario: default resolver returns the English catalog value
- WHEN a
Packeris constructed withoutmessageResolver - THEN errors thrown by
packcarry 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"