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

9.8 KiB

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"