Files
suaveplan/openspec/archive/2026-05-19-enterprise-packer-resilience/specs/packer/spec.md
2026-05-27 23:29:53 +01:00

194 lines
9.8 KiB
Markdown

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