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

191 lines
10 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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"`
<!-- markdownlint-enable MD024 -->