# Capability Spec Delta: packer (enterprise-packer-resilience) **Package**: `@suaveplan/packer` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. ## ADDED Requirements ### Requirement: 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`. #### Scenario: failing reader is retried up to maxAttempts - **WHEN** `normalizeSource` consumes a `ReadableStream` whose `read()` rejects the first two times and resolves on the third, with `retryConfig: { maxAttempts: 3, delay: createConstantBackoff({ delay: 0 }) }` - **THEN** the iterable yields the eventually-read bytes and the consumer observes three total read attempts #### Scenario: AbortSignal cancels the retry loop mid-backoff - **WHEN** the retry loop is sleeping between attempts and the caller's `AbortSignal` fires - **THEN** the retry rejects with `RetryAbortedError` and no further `reader.read()` is invoked #### Scenario: default policy is a single attempt - **WHEN** `retryConfig` is not supplied and a `read()` rejects - **THEN** the rejection surfaces unchanged and the iterable terminates with the original error (no retries) **Verified by:** `src/retry/retryable-read.test.ts::"retries up to maxAttempts on a failing reader"` AND `src/retry/retryable-read.test.ts::"cancels the retry loop when AbortSignal fires mid-backoff"` AND `src/retry/retryable-read.spec-conformance.test.ts::"default policy is a single attempt with no retries"` AND `src/retry/retryable-read.spec-conformance.test.ts::"retryRead surfaces RetryAbortedError when signal fires before first read"` --- ### Requirement: 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. #### Scenario: identical pack inputs short-circuit on the second call - **WHEN** `Packer.pack(data)` is called twice with identical `data`, identical options, and a `MemoryIdempotencyStore` injected through `idempotency.store` - **THEN** the second call returns a `PackedData` whose `data` bytes and `metadata.integrity` are byte-equal to the first call's result, and the underlying pipeline (gzip / AEAD / sign) ran exactly once #### Scenario: cache miss populates the store - **WHEN** the first call's dedup key is absent from the store - **THEN** the store's `set(key, record)` is invoked exactly once after the pack completes, with the resulting `PackedData` carried on `record.response` #### Scenario: idempotency disabled by default - **WHEN** `Packer.pack(data)` is called with no `idempotency` option - **THEN** no store interaction occurs and the pipeline runs end-to-end on every call **Verified by:** `src/idempotency/idempotency.test.ts::"second pack with identical input short-circuits to cached PackedData"` AND `src/idempotency/idempotency.test.ts::"cache miss populates the store with the resulting PackedData"` AND `src/idempotency/idempotency.spec-conformance.test.ts::"idempotency is disabled by default"` AND `src/idempotency/idempotency.spec-conformance.test.ts::"custom IdempotencyStoreAdapter is consulted exactly once per pack"` --- ## MODIFIED Requirements ### Requirement: 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.`. The original `Packer.pack(data, filename?)` overload MUST remain a valid call shape for back-compat. #### Scenario: Unpacker.unpack aborts before integrity verify - **WHEN** `Unpacker.unpack(packed, { signal })` is called with a signal that is already aborted - **THEN** the call rejects with `PackerError(PACKER.ABORTED)` and no integrity recomputation runs #### Scenario: Unpacker.unpack aborts between pipeline steps - **WHEN** the signal fires after integrity verify but before AEAD decrypt - **THEN** the call rejects with `PackerError(PACKER.ABORTED)` and no decrypted plaintext is materialised #### Scenario: Packer.pack(data, opts) honours opts.signal - **WHEN** `Packer.pack(data, { signal })` is called with a signal that is already aborted - **THEN** the call rejects with `PackerError(PACKER.ABORTED)` before any compression / encryption / signing / integrity work runs #### Scenario: packChunks aborts mid-stream - **WHEN** `packChunks(source, { ..., signal })` is iterated and the signal fires after the second emitted chunk - **THEN** the iterator rejects with `PackerError(PACKER.ABORTED)` before yielding any further chunks #### Scenario: legacy filename overload remains valid - **WHEN** `Packer.pack(data, "logo.png")` is called - **THEN** the call succeeds, `metadata.mimeType` is `"image/png"`, and no signal-related behaviour kicks in **Verified by:** `src/unpacker/unpacker.spec-conformance.test.ts::"rejects with PACKER.ABORTED when signal is pre-aborted"` AND `src/unpacker/unpacker.spec-conformance.test.ts::"rejects with PACKER.ABORTED when signal fires between pipeline steps"` AND `src/packer/packer.spec-conformance.test.ts::"rejects with PACKER.ABORTED when opts.signal is pre-aborted"` AND `src/chunked/packer/packer.spec-conformance.test.ts::"aborts mid-stream when signal fires after the second chunk"` AND `src/packer/packer.spec-conformance.test.ts::"legacy pack(data, filename) overload still surfaces mimeType"` --- ### Requirement: 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. #### Scenario: timestamp comes from the injected Clock - **WHEN** `Packer.pack(data)` is called with a clock returning a fixed value `1_700_000_000_000` - **THEN** `result.metadata.timestamp === 1_700_000_000_000` #### Scenario: AEAD nonce comes from the injected Rng - **WHEN** `Packer.pack(data, opts?)` is called with `encryption` set and an `Rng` whose `nextBytes(n)` returns a fixed byte pattern - **THEN** `result.metadata.nonce` equals the byte pattern truncated to the nonce length #### Scenario: same inputs + same seed produce byte-identical output - **WHEN** two `Packer.pack(data)` calls run with the same seeded `Rng` and the same fixed `Clock` - **THEN** their `data` and `metadata.integrity.entryDigest` are byte-equal #### Scenario: no production file calls Date.now / Math.random / crypto.getRandomValues directly - **WHEN** a recursive grep against `src/**/*.ts` (excluding `*.test.ts`, `*.types.ts`, `*.schemas.ts`) is run for `Date.now\|Math.random\|crypto.getRandomValues` - **THEN** zero matches exist outside the default-Clock / default-Rng factories in `src/runtime/runtime.ts` **Verified by:** `src/packer/packer.spec-conformance.test.ts::"timestamp is sourced from the injected Clock"` AND `src/packer/packer.spec-conformance.test.ts::"AEAD nonce is sourced from the injected Rng"` AND `src/packer/packer.spec-conformance.test.ts::"two packs with identical inputs and seeded Rng produce byte-identical output"` AND `src/runtime/runtime.spec-conformance.test.ts::"no production file calls Date.now / Math.random / crypto.getRandomValues directly"`