Initial Commit
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
<!-- 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 -->
|
||||
Reference in New Issue
Block a user