Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,137 @@
# Design — StructuredCloneCodec for @suaveplan/cache-storage
> Non-normative. Normative requirements live in `./specs/cache-storage/spec.md`.
## Goal
Add a third `Codec<T>` implementation to `@suaveplan/cache-storage` that round-trips the structured-clone-ish subset of JS values — `Date`, `Map`, `Set`, `Uint8Array`, `bigint`, `RegExp`, `URL`, and arbitrarily nested combinations — over `@suaveplan/cbor`. The default `JsonCodec` remains untouched; callers opt in.
## Codec interface (unchanged)
```ts
// existing — packages/flow/cache-storage/src/codec/codec.types.ts
export interface Codec<T> {
encode(value: T): Uint8Array;
decode(bytes: Uint8Array): T;
}
```
The new codec implements this interface verbatim. No constructor surface beyond the bare class is required for the default case; a future `{ tags?: ReadonlyMap<number, TagHandler> }` option is sketched below but is out of scope for this change.
## Tag scheme
CBOR tags from the IANA "Concise Binary Object Representation (CBOR) Tags" registry. Choosing standard tags keeps the on-wire format interop-friendly (a non-JS reader can decode the blob with any RFC 8949 implementation plus the standard tag set).
| JS type | CBOR tag | Payload shape | IANA reference |
|---------------|----------|----------------------------------------------------------------|---------------------------------|
| `Date` | `0` | RFC 3339 string (`date.toISOString()`) | RFC 8949 §3.4.1 |
| `bigint > 0` | `2` | byte string, big-endian magnitude | RFC 8949 §3.4.3 (positive bignum)|
| `bigint < 0` | `3` | byte string, big-endian magnitude of `-1 - n` | RFC 8949 §3.4.3 (negative bignum)|
| `URL` | `32` | string (the `URL.href`) | RFC 8949 §3.4.5.3 (URI) |
| `RegExp` | `35` | string (`regex.source`); flags lost — see "Trade-offs" below | RFC 8949 §3.4.5.4 (PCRE regex) |
| `Uint8Array` | `64` | byte string | RFC 8746 §3.3 |
| `Set` | `258` | array of items (recursively encoded) | https://github.com/input-output-hk/cbor-sets-spec |
| `Map` | `259` | array of `[key, value]` pairs (recursively encoded) | https://github.com/shanewholloway/js-cbor-codec/blob/master/docs/CBOR-259-spec--explicit-maps.md |
Plain objects, arrays, `null`, `boolean`, finite `number`, and `string` round-trip natively through CBOR — no transformation, no tag.
> Note on the Map choice: RFC 8949 §3.1 major type 5 already carries maps natively, but CBOR maps key on the encoded *bytes* of the key, which means a JS `Map` with object keys would either lose distinct identity (every `{}` encodes to the same bytes) or throw. Tag `259` was introduced by the JS ecosystem specifically to disambiguate "this came from a JS Map and the keys are values, not strings" — picking it preserves round-trip without breaking the major-type-5 invariants for plain-object encoding.
## Encode pipeline
```text
input value
│
▼
walk(value) ── recursive lift
│ plain JS value ─► returned as-is
│ carrier type ─► returned as new Tagged(tag, walk(payload))
│ unsupported type ─► throw CACHE_STORAGE.CODEC_UNSUPPORTED_TYPE
│
▼
encode(walked, { deterministic: false }) ── @suaveplan/cbor
│
▼
Uint8Array
```
Detection order is intentional — most specific first — so that subclasses do not slip through. `Array.isArray` is checked before "plain object"; `instanceof Date`, `instanceof Map`, etc. come before the duck-typed fallthrough.
## Decode pipeline
```text
input bytes
│
▼
decode(bytes, { tagHandling: "registry", tags: TAG_REGISTRY }) ── @suaveplan/cbor
│
▼ each Tagged hit
TagHandler[tag](payload) ── reverse-lift
│ known tag ─► returns the reconstructed JS carrier
│ unknown tag ─► throw CACHE_STORAGE.CODEC_UNKNOWN_TAG
│
▼
value
```
Unknown tags fail loud rather than passing through as `Tagged` objects. Silent pass-through would change the runtime shape the caller observes versus what they wrote, breaking the `decode(encode(x)) deepEquals x` contract every other codec honours.
### TagHandler shape (from `@suaveplan/cbor`)
```ts
type TagHandler = (payload: DecodedValue) => unknown;
```
The codec builds a frozen `Map<number, TagHandler>` once at module load. The registry is `internal` for this change; a public `register(tag, handler)` surface is sketched in the "Future work" section below.
## Error handling
Two new entries extend `CACHE_STORAGE_CODES`:
| Code | Raised by | Caller-actionable response |
|-------------------------------|---------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------|
| `CODEC_UNSUPPORTED_TYPE` | `StructuredCloneCodec.encode` when the walk hits `Function`, `Symbol`, `WeakMap`, `WeakSet`, `Promise`, or any class instance without a registered tag | Switch to `JsonCodec` (for primitives only) or pre-serialise the value yourself |
| `CODEC_UNKNOWN_TAG` | `StructuredCloneCodec.decode` when CBOR yields a `Tagged(n, …)` for `n` not in the registry | Upgrade the codec version, or pass a custom tag map (future-work surface) |
Existing `CODEC_ERROR` is reused for malformed CBOR bytes (catch-all for `CborError` thrown by `@suaveplan/cbor/decoder`) so the boundary remains a single "decode failed" code for back-compat — the new `CODEC_UNKNOWN_TAG` covers only the well-formed-but-unrecognised case.
## Fallback strategy when CBOR decoder hits an unknown tag
The codec configures `@suaveplan/cbor`'s decoder with a `TagRegistry` containing handlers for the eight tags above. For any other tag number, the registry returns a `Tagged` placeholder; the codec then inspects the decoded tree post-hoc — any leftover `Tagged` instance triggers `CACHE_STORAGE.CODEC_UNKNOWN_TAG` carrying the offending tag number in the error context. Walking the tree once for the post-check is O(n) over the decoded value count and runs at most once per `decode` call.
Why not configure the cbor decoder to throw immediately on unknown tags? Because `@suaveplan/cbor`'s tag-handling mode is a single decoder-wide flag; flipping it to "throw" would also kill any caller who later wants to mix this codec with a custom fallback handler. Inspecting post-hoc keeps the policy local to this codec.
## File layout
```text
packages/flow/cache-storage/src/codec/
codec.types.ts # unchanged
identity.ts # unchanged
identity.test.ts # extended with conformance harness call
identity.md # unchanged
json.ts # unchanged
json.test.ts # extended with conformance harness call
json.md # unchanged
structured-clone.ts # NEW — codec implementation (~180 LoC)
structured-clone.test.ts # NEW — conformance + negative cases
structured-clone.md # NEW — sibling docs
conformance.ts # NEW — runCodecConformance harness
conformance.test.ts # NEW — harness self-test
conformance.md # NEW — sibling docs
index.ts # extended — explicit named re-export added
```
The 300-line soft cap is comfortably met. If the lift table grows (custom-tag registration), `structured-clone.tags.ts` extracts cleanly.
## Trade-offs
- **`RegExp.flags` is dropped on round-trip.** RFC 8949's PCRE-regex tag carries only the pattern string; flags are not part of the registry payload spec. Documented in the sibling `.md`. Callers needing flags can wrap the regex in `{ source, flags }` and cache that.
- **`number === NaN` / `±Infinity` survive CBOR but not JSON.** With `StructuredCloneCodec` these round-trip cleanly (CBOR has float specials); `JsonCodec` already turns them into `null`. Worth noting in docs but not a defect of this codec.
- **No transferable objects.** `ArrayBuffer.transfer`, `MessagePort`, `ImageBitmap` are out of scope — they belong to browser-only structured clone, not a universal-tier cache codec.
- **CBOR map key constraint.** A JS `Map` with object keys round-trips because we use tag `259` (array of `[k, v]` pairs), where each `k` is recursively encoded as a full CBOR item. The major-type-5 "duplicate keys collide" hazard does not apply.
## Future work (explicitly deferred)
- Public `StructuredCloneCodec` constructor option `{ tags?: ReadonlyMap<number, { encode?, decode? }> }` so callers can register their own carriers (e.g. `Decimal`, `Big`, domain value objects).
- Promotion of `runCodecConformance` to `@suaveplan/testing` if a second consumer adopts the `Codec<T>` interface.
- Streaming variant `StructuredCloneStreamCodec` for >10 MiB blobs once a real consumer asks. Current synchronous design is the right default.

View File

@@ -0,0 +1,89 @@
# Add StructuredCloneCodec (CBOR-backed) to @suaveplan/cache-storage
**Package**: `@suaveplan/cache-storage` (universal)
**Status**: PROPOSED
**Type**: additive — new optional codec, JSON default unchanged
**Picks up deferred work from**: `openspec/archive/2026-05-16-add-cache-storage-redis-backends/`
- **Design**: ./design.md
- **Spec delta**: ./specs/cache-storage/spec.md
## Why
The archived change `add-cache-storage-redis-backends` shipped `StorageCache<T>` with two codecs (`JsonCodec`, `IdentityCodec`) and explicitly deferred the third under its `## Out of scope` heading:
> **`StructuredCloneCodec` / CBOR codec.** Deferred. JSON + Identity cover the common case; CBOR-backed structured-clone (`Date`/`Map`/`Set` faithful round-trip) ships in a follow-up alongside the cbor catalog entry. No placeholder code lands in this change.
That follow-up is now unblocked: `@suaveplan/cbor` ships on genesis with a complete RFC 8949 encoder/decoder, `Tagged` value, and registry-extensible tag handlers. Callers that cache rich JS values (`Date`, `Map`, `Set`, `Uint8Array`, `bigint`, nested combinations) currently either lose information through `JsonCodec` (`Date` → string; `Map`/`Set` → `{}` or `[]`; `Uint8Array` → object-with-numeric-keys; `bigint` → `JSON.stringify` throw) or are forced to author their own pre/post serializers. Neither is acceptable for an enterprise cache layer that already exposes `Codec<T>` as the documented swap-point.
This change adds an opt-in `StructuredCloneCodec` that round-trips the structured-clone-ish subset over `@suaveplan/cbor`, leaving `JsonCodec` as the back-compat default.
## What changes
### `@suaveplan/cache-storage` (additive only)
- ADDED: `StructuredCloneCodec<T> implements Codec<T>` — new `src/codec/structured-clone.ts`. `encode` walks the input, lifts non-JSON-native carriers (`Date`, `Map`, `Set`, `Uint8Array`, `bigint`, `RegExp`, `URL`) into `Tagged(tagNumber, payload)` per the table in `design.md`, then delegates to `encode` from `@suaveplan/cbor`. `decode` calls `decode` from `@suaveplan/cbor` with a `TagHandler` map that reverses the lift. Plain objects, arrays, primitives, `null`, `boolean`, finite `number`, and `string` round-trip natively through CBOR with zero transformation.
- ADDED: Tag numbers chosen from IANA-registered CBOR tags so the wire format is interop-friendly: `0` (RFC 3339 date string — used for `Date`), `258` (Set), `259` (Map with explicit key/value pairs), `64`–`72` reserved typed-array tags (`Uint8Array` uses tag `64`), `2`/`3` (positive/negative bignum for `bigint`), `35` (PCRE regex string for `RegExp`), `32` (URI for `URL`). All references are in `design.md`.
- ADDED: Subpath export `./codec/structured-clone` in `package.json` matching the existing `./codec/json` and `./codec/identity` shape, plus re-export from `./src/codec/index.ts`.
- ADDED: Two new entries in `CACHE_STORAGE_CODES` via `defineCodes("CACHE_STORAGE", [...])`: `CODEC_UNSUPPORTED_TYPE` (raised on encode of a value the codec cannot lift — `Function`, `Symbol`, `WeakMap`, `WeakSet`, DOM nodes, `Promise`, class instance without a registered tag handler) and `CODEC_UNKNOWN_TAG` (raised on decode of a CBOR tag the codec does not recognise AND for which no caller-supplied fallback handler exists).
- ADDED: Conformance harness `src/codec/conformance.ts` exporting `runCodecConformance(codec, { name, supportsStructuredClone })` — round-trip cases covering: primitives (null, boolean, number, string), nested plain objects, arrays, `Date`, `Map`, `Set`, `Uint8Array`, `bigint` (positive + negative), `RegExp`, `URL`, deeply nested combinations, empty collections, and (when `supportsStructuredClone: false`) the `unsupported-type-on-JSON` negative case. The existing `JsonCodec` and `IdentityCodec` tests gain a thin harness-invocation wrapper so all three codecs share one contract; the new codec's test file invokes it with `supportsStructuredClone: true`.
### `@suaveplan/cbor` (consumed; no changes)
- The `StructuredCloneCodec` imports `encode`, `decode`, `Tagged`, and `type TagHandler` directly from `@suaveplan/cbor` per CLAUDE.md rule 4 (no re-exports).
### Default codec unchanged
- `StorageCache`'s constructor still defaults `codec` to `new JsonCodec<T>()`. Existing callers see no behaviour change.
## Out of scope
- **Cross-realm `structuredClone()` parity.** The HTML structured-clone algorithm covers `ArrayBuffer` transfer, `ImageBitmap`, `Blob`, `File`, etc. This codec covers only the JS-language subset relevant to caching. Browser-only types stay out of a universal-tier package.
- **Custom user tag registration.** The codec exposes the handler map internally; a public `registerTag` surface is a follow-up if real callers ask for it. Documented as such — not silently missing.
- **Wiring the new codec into `@suaveplan/cache-redis`.** Out of scope here; `cache-redis` already consumes the `Codec<T>` interface from `@suaveplan/cache-storage/codec`, so callers can already `new StructuredCloneCodec()` and pass it. No code change is needed in `cache-redis` for that to work.
- **Promoting the conformance harness to `@suaveplan/testing`.** The harness lives inside `cache-storage` for now; if other packages adopt the codec interface it gets lifted.
## Source mapping
- `openspec/archive/2026-05-16-add-cache-storage-redis-backends/proposal.md` — the deferral text quoted above.
- `packages/flow/cache-storage/src/codec/` — existing `Codec<T>` seam (`codec.types.ts`, `json.ts`, `identity.ts`, `index.ts`). New file `structured-clone.ts` plugs into the same barrel and exports.
- `packages/flow/cache-storage/src/storage-cache/storage-cache.ts:13-14,54,93,168` — codec seam (constructor parameter, encode/decode call sites). No changes required: the new codec is interface-compatible.
- `packages/flow/cache-storage/src/errors/errors.ts` — `CACHE_STORAGE_CODES` registry to extend with two new codes via the same `defineCodes` call.
- `packages/flow/cbor/src/index.ts` — public root barrel; `encode`, `decode`, `Tagged`, `type TagHandler` are the exports consumed.
### Requirements with no source line
| Requirement | Justification |
|---|---|
| Tag numbers chosen from the IANA CBOR tag registry (0, 2, 3, 32, 35, 64, 258, 259) | Standardised wire format is the only sane choice for a cache that may be read by a non-JS consumer (Redis blob, file dump). Inventing private tag numbers would lock the format to this codec forever. |
| Conformance harness lives in `cache-storage`, not `@suaveplan/testing` | `@suaveplan/testing` carries zero `@suaveplan/*` runtime deps (rule 8). The harness must import the `Codec<T>` interface to type its parameter; lifting it would invert the dep graph. Promotion is a separate change if a second consumer appears. |
| Unknown-tag default: throw `CODEC_UNKNOWN_TAG` (not silent pass-through) | A cache that silently turns an unknown tag into a `Tagged` wrapper changes the runtime type the caller observes versus what they cached. Failing loudly preserves the codec's round-trip contract and matches the `JsonCodec` "decode failure = throw" precedent. |
| `RegExp` and `URL` lifted (not just structured-clone-spec types) | These are the two most common JS-host carriers that round-trip cleanly through one IANA tag each and that callers reliably cache. Adding them costs ~10 lines and avoids "almost structured-clone" surprise. |
## Verification approach
- `src/codec/structured-clone.test.ts` — every `**Verified by:**` line in the spec delta, invoked via `runCodecConformance(new StructuredCloneCodec(), { name: "StructuredCloneCodec", supportsStructuredClone: true })` plus direct tests for `CODEC_UNSUPPORTED_TYPE` and `CODEC_UNKNOWN_TAG`.
- `src/codec/conformance.test.ts` — harness self-test: passing a deliberately broken codec causes the harness assertions to fail, proving the harness has teeth (no tautological green).
- `src/codec/json.test.ts` and `src/codec/identity.test.ts` — extended with a `runCodecConformance(codec, { supportsStructuredClone: false })` call to prove the shared contract holds for the existing codecs at the JSON subset.
- `src/errors/errors.test.ts` — extended to assert the two new codes (`CODEC_UNSUPPORTED_TYPE`, `CODEC_UNKNOWN_TAG`) are reachable from real call sites in `StructuredCloneCodec`.
- `bunx turbo test:coverage --filter=@suaveplan/cache-storage` — 100/100/100/100 retained.
- `bunx turbo lint typecheck test --filter=@suaveplan/cache-storage` — green.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/cache-storage` to enumerate.
- `@suaveplan/cache-redis` — [ ] tests run / [x] docs reviewed / [x] no action needed (reason: consumes the `Codec<T>` interface from `@suaveplan/cache-storage/codec`; the new codec is interface-compatible and opt-in. No source change required; a follow-up README note can mention "pass `new StructuredCloneCodec()` if you want `Date`/`Map`/`Set` round-trips" but does not block this change.)
- No other dependents at time of writing — verified by `bunx turbo ls --filter=...@suaveplan/cache-storage` returning only `cache-redis`.
### Affected upstreams
- `@suaveplan/cbor` — newly consumed via `workspace:^`. Not modified. [x] no action needed.
- `@suaveplan/error` — `defineCodes` extended (additive); existing codes unchanged. [x] no action needed.
- `@suaveplan/cache`, `@suaveplan/storage`, `@suaveplan/events`, `@suaveplan/types` — not touched.
## Migration
This is purely additive: no error renames, no default flips, no removed exports. Callers opt in by constructing `StorageCache({ storage, codec: new StructuredCloneCodec() })`. Existing callers using the default `JsonCodec` see byte-identical behaviour.

View File

@@ -0,0 +1,186 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: cache-storage (cache-storage)
**Package**: `@suaveplan/cache-storage`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: StructuredCloneCodec round-trips the structured-clone-ish JS subset
The package SHALL export `StructuredCloneCodec<T> implements Codec<T>` from `./codec/structured-clone`. The codec SHALL faithfully round-trip the following JS value types through `encode` then `decode`: `null`, `boolean`, finite `number`, `string`, plain object, array, `Date`, `Map`, `Set`, `Uint8Array`, `bigint` (positive and negative), `RegExp` (source only — flags excluded by tag-35 wire format), `URL`, and arbitrary nestings of the above. Encoding SHALL delegate to `encode` from `@suaveplan/cbor`; decoding SHALL delegate to `decode` from `@suaveplan/cbor` with a tag-handler registry that reverses the lift table.
#### Scenario: round-trip preserves Date instances
- **WHEN** a caller calls `codec.decode(codec.encode(new Date("2026-05-17T12:00:00.000Z")))`
- **THEN** the result is `instanceof Date` AND its `.getTime()` equals `new Date("2026-05-17T12:00:00.000Z").getTime()`
#### Scenario: round-trip preserves Map with object keys and values
- **WHEN** a caller round-trips `new Map<unknown, unknown>([[{ id: 1 }, "a"], [{ id: 2 }, new Set([1, 2])]])`
- **THEN** the result is `instanceof Map`, has `size === 2`, AND iterating yields keys/values deep-equal to the input (the second value being `instanceof Set` with members `1` and `2`)
#### Scenario: round-trip preserves Uint8Array byte identity
- **WHEN** a caller round-trips `new Uint8Array([0, 1, 127, 128, 255])`
- **THEN** the result is `instanceof Uint8Array` AND every byte is equal to the input at the same index
#### Scenario: round-trip preserves bigint magnitude and sign
- **WHEN** a caller round-trips both `2n ** 200n` and `-(2n ** 200n) - 7n`
- **THEN** each result is `typeof "bigint"` AND strictly equals the input
#### Scenario: deeply nested structures survive
- **WHEN** a caller round-trips `{ users: new Map([["alice", { tags: new Set(["admin"]), joined: new Date(0), avatar: new Uint8Array([1, 2, 3]) }]]) }`
- **THEN** the result deep-equals the input across `Map`, `Set`, `Date`, and `Uint8Array` boundaries
**Verified by:** `src/codec/structured-clone.test.ts::"round-trip preserves Date instances"` AND `src/codec/structured-clone.test.ts::"round-trip preserves Map with object keys and values"` AND `src/codec/structured-clone.test.ts::"round-trip preserves Uint8Array byte identity"` AND `src/codec/structured-clone.test.ts::"round-trip preserves bigint magnitude and sign"` AND `src/codec/structured-clone.test.ts::"deeply nested structures survive"`
---
### Requirement: StructuredCloneCodec uses IANA-registered CBOR tags
The codec SHALL use the following CBOR tag numbers from the IANA CBOR Tags registry: `0` (RFC 3339 date-time string) for `Date`; `2` (positive bignum) for `bigint > 0n`; `3` (negative bignum) for `bigint < 0n`; `32` (URI) for `URL`; `35` (PCRE regex) for `RegExp`; `64` (`Uint8` typed array) for `Uint8Array`; `258` (Set) for `Set`; `259` (Map with explicit key/value pairs) for `Map`. The codec SHALL NOT invent private tag numbers for any carrier listed in the round-trip requirement above.
#### Scenario: encoded Date emits CBOR tag 0
- **WHEN** a caller calls `codec.encode(new Date("2026-05-17T12:00:00.000Z"))` AND the result is fed back through `decode` from `@suaveplan/cbor` with default tag handling
- **THEN** the decoded top-level value is `instanceof Tagged` with `.tag === 0` AND `.value` is the RFC 3339 string
#### Scenario: encoded Uint8Array emits CBOR tag 64
- **WHEN** a caller calls `codec.encode(new Uint8Array([1, 2, 3]))` AND the result is fed back through `decode` from `@suaveplan/cbor` with default tag handling
- **THEN** the decoded top-level value is `instanceof Tagged` with `.tag === 64`
**Verified by:** `src/codec/structured-clone.test.ts::"Date encodes under CBOR tag 0"` AND `src/codec/structured-clone.test.ts::"Uint8Array encodes under CBOR tag 64"`
---
### Requirement: CODEC_UNSUPPORTED_TYPE on encode of unencodable carriers
`StructuredCloneCodec.encode` SHALL throw `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_UNSUPPORTED_TYPE"` when the input contains (directly or transitively) any of: `Function`, `Symbol`, `WeakMap`, `WeakSet`, `Promise`, or any class instance whose constructor is not one of the registered carriers (`Date`, `Map`, `Set`, `Uint8Array`, `RegExp`, `URL`). The error SHALL carry the failing type name in its message or context.
#### Scenario: encoding a function throws CODEC_UNSUPPORTED_TYPE
- **WHEN** a caller calls `codec.encode((() => 1) as unknown)`
- **THEN** the call throws `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_UNSUPPORTED_TYPE"`
#### Scenario: encoding a Symbol nested in an object throws CODEC_UNSUPPORTED_TYPE
- **WHEN** a caller calls `codec.encode({ ok: 1, bad: Symbol("x") } as unknown)`
- **THEN** the call throws `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_UNSUPPORTED_TYPE"`
**Verified by:** `src/codec/structured-clone.test.ts::"encode throws CODEC_UNSUPPORTED_TYPE on a function"` AND `src/codec/structured-clone.test.ts::"encode throws CODEC_UNSUPPORTED_TYPE on a nested Symbol"`
---
### Requirement: CODEC_UNKNOWN_TAG on decode of an unregistered CBOR tag
`StructuredCloneCodec.decode` SHALL throw `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_UNKNOWN_TAG"` when the input CBOR bytes contain a `Tagged(n, …)` value whose tag `n` is not one of the registered tags (`0`, `2`, `3`, `32`, `35`, `64`, `258`, `259`). The error context SHALL carry the offending tag number. The codec SHALL NOT silently return the value as a `Tagged` wrapper.
#### Scenario: decoding an unrecognised tag throws CODEC_UNKNOWN_TAG
- **WHEN** a caller encodes `new Tagged(9999, "x")` via `encode` from `@suaveplan/cbor` AND passes the resulting bytes to `codec.decode`
- **THEN** the call throws `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_UNKNOWN_TAG"`
**Verified by:** `src/codec/structured-clone.test.ts::"decode throws CODEC_UNKNOWN_TAG on an unregistered tag"`
---
### Requirement: CODEC_ERROR on malformed CBOR bytes
`StructuredCloneCodec.decode` SHALL throw `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_ERROR"` when the input bytes are not valid CBOR (catching any `CborError` thrown by `decode` from `@suaveplan/cbor` and re-wrapping). The original `CborError` SHALL be attached as `cause`.
#### Scenario: decoding random bytes throws CODEC_ERROR with cbor cause
- **WHEN** a caller calls `codec.decode(new Uint8Array([0xff, 0xff, 0xff, 0xff]))` for bytes that do not form a valid CBOR item
- **THEN** the call throws `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_ERROR"` AND `error.cause` is the underlying `CborError`
**Verified by:** `src/codec/structured-clone.test.ts::"decode throws CODEC_ERROR with cbor cause on malformed bytes"`
---
### Requirement: StructuredCloneCodec interoperates with StorageCache
`StructuredCloneCodec` SHALL be assignable to the `codec` constructor option of `StorageCache<T>` without any cast. A `StorageCache<T>` constructed with `codec: new StructuredCloneCodec<T>()` SHALL preserve every round-trip case from the first requirement when values are written via `cache.set` and read back via `cache.get`.
#### Scenario: StorageCache + StructuredCloneCodec round-trips a Map value
- **WHEN** a caller constructs `new StorageCache({ storage: new MemoryStorage(), codec: new StructuredCloneCodec() })`, calls `cache.set("k", new Map([["a", 1]]))`, AND calls `cache.get("k")`
- **THEN** the result is `instanceof Map` with `size === 1` AND `.get("a") === 1`
**Verified by:** `src/codec/structured-clone.test.ts::"StorageCache with StructuredCloneCodec round-trips a Map value"`
---
### Requirement: Codec conformance harness exposed to package tests
The package SHALL export a `runCodecConformance(codec, options)` harness from `./codec/conformance` accepting `{ name: string; supportsStructuredClone: boolean }`. The harness SHALL execute round-trip assertions for every type listed in the first requirement; when `supportsStructuredClone` is `false`, the harness SHALL restrict assertions to types the JSON profile preserves (primitives, plain objects, arrays). The harness SHALL fail loudly when given a deliberately broken codec (proof of teeth — no tautological green).
#### Scenario: harness reports failure on a broken codec
- **WHEN** the test suite passes a codec whose `decode` returns a fixed sentinel `null` instead of the round-tripped value
- **THEN** at least one harness assertion fails
#### Scenario: harness passes for the existing JsonCodec at the JSON profile
- **WHEN** the test suite calls `runCodecConformance(new JsonCodec<unknown>(), { name: "JsonCodec", supportsStructuredClone: false })`
- **THEN** every assertion passes
**Verified by:** `src/codec/conformance.test.ts::"harness fails on a deliberately broken codec"` AND `src/codec/json.test.ts::"JsonCodec satisfies the JSON-profile conformance harness"`
---
### Requirement: CACHE_STORAGE registry extended with two codes
`CACHE_STORAGE_CODES` SHALL be extended via the same `defineCodes("CACHE_STORAGE", [...])` call to include `CODEC_UNSUPPORTED_TYPE` and `CODEC_UNKNOWN_TAG`, bringing the registry to 7 codes total. Both new codes SHALL be reachable from at least one test in this package. The existing 5 codes (`ENTRY_CORRUPT`, `CODEC_ERROR`, `BACKEND_ERROR`, `KEY_TOO_LONG`, `VALUE_TOO_LARGE`) SHALL remain unchanged.
#### Scenario: every CACHE_STORAGE code (including the two new ones) is reachable
- **WHEN** the test suite runs
- **THEN** for every entry in `CACHE_STORAGE_CODES`, at least one assertion exercises a real call site that produces that code
**Verified by:** `src/errors/errors.test.ts::"every CACHE_STORAGE code is reachable from a real call site"`
---
### Requirement: Default codec for StorageCache remains JsonCodec
The constructor default for `StorageCache.codec` SHALL remain `new JsonCodec<T>()`. Existing callers that do not pass `codec` SHALL observe byte-identical behaviour to the codec they observed before this change. The new `StructuredCloneCodec` SHALL be opt-in by explicit construction only.
#### Scenario: StorageCache without explicit codec uses JsonCodec
- **WHEN** a caller constructs `new StorageCache({ storage: new MemoryStorage() })`, sets a string value, and inspects the bytes written by the underlying storage past `HEADER_BYTES`
- **THEN** the bytes equal `new TextEncoder().encode(JSON.stringify(value))`
**Verified by:** `src/storage-cache/storage-cache.test.ts::"default codec is JsonCodec — bytes past the header are UTF-8 JSON"`
---
### Requirement: Subpath export for the new codec
`package.json` SHALL expose `./codec/structured-clone` as a subpath export mirroring the existing `./codec/json` and `./codec/identity` entries, so tree-shaking consumers can import only the new codec without dragging in the rest of the codec barrel.
#### Scenario: subpath import resolves the StructuredCloneCodec class
- **WHEN** a consumer imports `import { StructuredCloneCodec } from "@suaveplan/cache-storage/codec/structured-clone"`
- **THEN** the import resolves to the same class as `import { StructuredCloneCodec } from "@suaveplan/cache-storage/codec"`
**Verified by:** `src/codec/structured-clone.test.ts::"./codec/structured-clone subpath resolves to the same class as ./codec"`
---
### Requirement: Tier purity — universal preserved
Adding `StructuredCloneCodec` SHALL NOT introduce any `node:*`, `bun:*`, or DOM-only import into the package. The package SHALL continue to run unmodified under Node 18+, Bun, Deno, modern browsers, and Cloudflare Workers.
#### Scenario: tier-purity grep finds no forbidden imports after the change
- **WHEN** `grep -rnE "from ['\"](node:|bun:)" packages/flow/cache-storage/src/` is run
- **THEN** the output is empty
**Verified by:** `src/codec/structured-clone.test.ts::"structured-clone codec contains no node: or bun: imports"`

View File

@@ -0,0 +1,60 @@
# Tasks — add-cache-storage-cbor-codec
> Every checkbox MUST reflect work actually done in this change. Additive only — no behaviour change to existing codecs.
## 1. Pre-flight
- [ ] 1.1 Re-read `openspec/AGENTS.md` §0c (delta format) and §13 (gate order)
- [ ] 1.2 Confirm `@suaveplan/cbor` ships on genesis with `encode`, `decode`, `Tagged`, `type TagHandler` in the root barrel
- [ ] 1.3 Confirm `packages/flow/cache-storage/src/codec/codec.types.ts` exposes `Codec<T>` and that `storage-cache.ts` defaults to `JsonCodec` (no behaviour change needed)
- [ ] 1.4 Numbered checklist of every `### Requirement` and `#### Scenario` in `specs/cache-storage/spec.md` exists in working notes (AGENTS §0c step 6)
## 2. Package wiring
- [ ] 2.1 Add `@suaveplan/cbor` to `packages/flow/cache-storage/package.json` `dependencies` at `workspace:^`
- [ ] 2.2 Add subpath export `./codec/structured-clone` mirroring the shape of `./codec/json` and `./codec/identity`
- [ ] 2.3 Confirm `sideEffects` array remains correct (no new side-effect file added)
## 3. Error registry extension
- [ ] 3.1 Extend `CACHE_STORAGE_CODES` in `src/errors/errors.ts` with `CODEC_UNSUPPORTED_TYPE` and `CODEC_UNKNOWN_TAG`
- [ ] 3.2 Update `src/errors/errors.md` Features + API Reference to list the two new codes (doc-drift gate §4a)
## 4. Implementation — StructuredCloneCodec
- [ ] 4.1 `src/codec/structured-clone.ts` — `StructuredCloneCodec<T> implements Codec<T>`; lift table per `design.md`; encode/decode against `@suaveplan/cbor`
- [ ] 4.2 `src/codec/structured-clone.md` — sibling docs (≥200 words, basic + advanced examples, API reference, implementation notes)
- [ ] 4.3 `src/codec/index.ts` — explicit named re-export of `StructuredCloneCodec`
- [ ] 4.4 Confirm file size ≤300 lines; split if needed (e.g. lift table to `src/codec/structured-clone.tags.ts`)
## 5. Conformance harness
- [ ] 5.1 `src/codec/conformance.ts` — `runCodecConformance(codec, { name, supportsStructuredClone })` covering primitives, nested objects, arrays, `Date`, `Map`, `Set`, `Uint8Array`, `bigint` (positive + negative), `RegExp`, `URL`, deep nesting, empty collections
- [ ] 5.2 `src/codec/conformance.md` — sibling docs
- [ ] 5.3 `src/codec/conformance.test.ts` — harness self-test: a deliberately broken `Codec<unknown>` must cause the harness to fail (no tautological green)
## 6. Tests
- [ ] 6.1 `src/codec/structured-clone.test.ts` — invoke `runCodecConformance(new StructuredCloneCodec(), { name: "StructuredCloneCodec", supportsStructuredClone: true })`; plus direct tests for `CODEC_UNSUPPORTED_TYPE` (encode `() => {}`, `Symbol("x")`, `new WeakMap()`) and `CODEC_UNKNOWN_TAG` (decode a CBOR-encoded `Tagged(9999, "x")`)
- [ ] 6.2 Extend `src/codec/json.test.ts` with `runCodecConformance(new JsonCodec<unknown>(), { name: "JsonCodec", supportsStructuredClone: false })`
- [ ] 6.3 Extend `src/codec/identity.test.ts` with `runCodecConformance(new IdentityCodec(), { name: "IdentityCodec", supportsStructuredClone: false })` — using `Uint8Array` payloads only
- [ ] 6.4 Extend `src/errors/errors.test.ts` to assert the two new codes are reachable
- [ ] 6.5 Every `it(...)` carries `{ timeout: 5000 }`
- [ ] 6.6 All test imports via `@suaveplan/testing/runner` (§7)
## 7. Gates (§13 order)
- [ ] 7.1 Spec reconciliation — walk every `### Requirement` / `#### Scenario` against impl
- [ ] 7.2 `bunx turbo lint typecheck test --filter=@suaveplan/cache-storage`
- [ ] 7.3 `bunx turbo test:coverage --filter=@suaveplan/cache-storage` — 100/100/100/100
- [ ] 7.4 `bunx turbo build --filter=@suaveplan/cache-storage`
- [ ] 7.5 Banned-pattern, barrel-shape, runner-import, missing-`.md`, bare-version scans (zero output)
- [ ] 7.6 `bunx turbo test --filter=...@suaveplan/cache-storage` — dependent sweep (`@suaveplan/cache-redis` must remain green)
## 8. Manifest & archival
- [ ] 8.1 `bun scripts/folder-reorg/generate-manifest.ts`
- [ ] 8.2 Update `packages/flow/cache-storage/README.md` Modules section with the new codec
- [ ] 8.3 `bunx markdownlint-cli2 --fix`
- [ ] 8.4 Fold `specs/cache-storage/spec.md` into `openspec/specs/cache-storage/spec.md` (merge `## ADDED` block into existing spec)
- [ ] 8.5 Move change folder → `openspec/archive/<yyyy-mm-dd>-add-cache-storage-cbor-codec/`

View File

@@ -0,0 +1,164 @@
# Design — add-drive-range-reads
> Non-normative. Normative requirements live in `./specs/drive/spec.md` and `./specs/drive-uri/spec.md`.
## Goals
1. Extend `Drive.createReadStream` with `{ offset, length }` without breaking the bare-`AbortSignal` signature used at every existing call site.
2. Push the range hint through the plugin middleware so `drive-plugin-chunks` only fetches/decrypts/emits chunks intersecting the range.
3. Over `drive+mesh://`, request only the in-range chunkIds from peers — `ChunkRequest.specific` for small windows, `ChunkRequest.bitset` for large contiguous spans.
4. Surface range failures via two new `DRIVE_*` codes; reuse existing wire format and pass-through semantics elsewhere.
## API surface evolution
### `Drive.createReadStream` — overloaded
```ts
interface RangeReadOptions {
offset?: number; // default 0; integer ≥ 0
length?: number; // default → EOF; integer ≥ 0
signal?: AbortSignal;
}
class Drive {
// Existing (preserved)
createReadStream(path: string, signal?: AbortSignal): ReadableStream<Uint8Array>;
// Added
createReadStream(path: string, options: RangeReadOptions): ReadableStream<Uint8Array>;
// Implementation
createReadStream(
path: string,
signalOrOptions?: AbortSignal | RangeReadOptions,
): ReadableStream<Uint8Array> { … }
}
```
Detection: `signalOrOptions instanceof AbortSignal ? { signal: signalOrOptions } : (signalOrOptions ?? {})`. The runtime `instanceof` check is reliable in every supported runtime (Bun, Node 22+, Chromium 102+, Safari 16+) and avoids a brittle duck-type sniff.
Validation: synchronous — when `offset` or `length` is present, it MUST be a finite non-negative integer (`Number.isInteger(x) && x >= 0`). On failure, throw `DriveError(DRIVE.RANGE_INVALID, "offset must be a non-negative integer")` BEFORE constructing the stream.
### `StorageUriResolver` contract
`@suaveplan/storage-uri`'s `UriPlugin.adapter.createReadStream(key, options?)` already accepts an options bag; we extend the contract to include `{ offset?, length? }` per the `StorageLike` evolution path in `add-signal-to-storage-like` (archived). The resolver passes the bag through unchanged. `drive-uri`'s three plugins each set `adapter.capabilities.nativeRange = true` so the resolver issues one range-aware `createReadStream` call instead of buffering and slicing.
## Range → chunk math
Given:
- `chunkSize` (uint32, from `manifest.chunkSize` for chunks-plugin; from `storage.chunkSize` otherwise; conventionally 65536)
- `fileSize` (bigint, from `manifest.fileSize`)
- requested `offset` (default 0) and `length` (default `fileSize - offset`)
Compute:
- `end = min(offset + length, fileSize)` // exclusive
- `firstChunk = floor(offset / chunkSize)`
- `lastChunk = floor((end - 1) / chunkSize)` // inclusive; only when `end > offset`
- `firstChunkSkip = offset - firstChunk * chunkSize` // bytes to drop from the front of `firstChunk`
- `lastChunkKeep = end - lastChunk * chunkSize` // bytes to retain from the front of `lastChunk`
Emit:
- For `firstChunk`: decrypt, slice `[firstChunkSkip, min(chunkSize, lastChunkKeep))` if `firstChunk === lastChunk`, else `[firstChunkSkip, payloadLen)`
- For interior chunks `(firstChunk, lastChunk)`: emit whole `chunk.slice(0, payloadLen)`
- For `lastChunk` (when distinct from `firstChunk`): decrypt, slice `[0, lastChunkKeep)`
Zero-length: when `length === 0`, do not enter the loop — emit an empty `Uint8Array` and close.
### Worked examples (chunkSize = 65 536; last chunk holds `payloadLen` bytes only)
| Scenario | offset | length | end | firstChunk | lastChunk | firstSkip | lastKeep | Chunks pulled |
|--------------------------------|---------|---------|-----------|------------|-----------|-----------|----------|---------------|
| Range fully inside one chunk | 1 024 | 2 048 | 3 072 | 0 | 0 | 1 024 | 3 072 | {0} (1) |
| Spans one boundary | 60 000 | 10 000 | 70 000 | 0 | 1 | 60 000 | 4 464 | {0, 1} (2) |
| Aligned multi-chunk | 65 536 | 196 608 | 262 144 | 1 | 3 | 0 | 65 536 | {1, 2, 3} (3) |
| Tail to EOF (fileSize=200 000) | 150 000 | — | 200 000 | 2 | 3 | 18 928 | 3 392 | {2, 3} (2) |
| Zero-length | 12 345 | 0 | 12 345 | n/a | n/a | n/a | n/a | ∅ (0) |
| Whole file | 0 | — | fileSize | 0 | last | 0 | tailLen | {0..last} (N) |
## Mesh-request optimisation
The mesh pull path today issues `ChunkRequest.whole`. With a range, build:
```ts
const span = lastChunk - firstChunk + 1;
const req: ChunkRequest =
span <= 64
? { kind: "specific", chunkIds: rangeArray(firstChunk, lastChunk) }
: { kind: "bitset", bytes: bitsetForRange(firstChunk, lastChunk, totalChunks) };
```
Pivot rationale:
- `MAX_CHUNKS_DEFAULT = 64` (`drive-mesh-protocol/src/schemas/envelope.schema.ts`). JSON `chunkIds` for 64 numbers ~ 200 B; a bitset for the same window is ~ 8 B header + ⌈64/8⌉ = 16 B = under 30 B framed. At 64+ chunks, the bitset is uniformly cheaper.
- The bitset is constructed via `encodeBitset` from `drive-mesh-protocol`; the receiver decode path is unchanged.
- `ParallelPuller`'s window applies to whichever chunkIds the request enumerated, so range simply narrows the parallelism domain.
## Abort handling
The `AbortSignal` (from `RangeReadOptions.signal` OR the bare-signal form) is threaded into:
1. `storage.createReadStream(path, { signal })` when the storage has `nativeStreams`.
2. The chunks-plugin `#reassembleAsStream` loop — each `layout.getChunk(fileId, i)` call is wrapped by `throwIfAborted(signal)` between iterations.
3. The mesh pull — `drive-mesh.ParallelPuller` already accepts a signal; the range path passes it through unchanged.
On abort: cancel in-flight chunk fetches, close the stream with `controller.error(new DOMException("aborted", "AbortError"))`, and (over mesh) emit a single `cd:error` envelope with `code = "ABORTED"` so peers can free reservation slots.
## Error codes table
| Code | Origin | When raised |
|-------------------------------|---------------------------------|--------------------------------------------------------------------------------------------------------------|
| `DRIVE.RANGE_INVALID` | `Drive.createReadStream` | `offset`/`length` is non-integer / negative / NaN / not finite. Thrown synchronously before stream creation. |
| `DRIVE.RANGE_OUT_OF_BOUNDS` | `Drive.createReadStream` OR stream `error` event | Synchronously: `offset > size` or `offset + length > size` when `size` is known at start. Asynchronously: short-fall on stream close when size was unknown at start. |
| `CHUNKS.RANGE_OUT_OF_BOUNDS` | `chunksPlugin` middleware | Manifest `fileSize` is loaded and the range exceeds it. Surfaced on stream `error`; the drive layer re-wraps as `DRIVE.RANGE_OUT_OF_BOUNDS` with `cause`. |
No new codes in `DRIVE_URI`, `DMP`, or `DTP`. The resolver pass-through rule already forwards `DRIVE.*` codes to the caller intact.
## File layout
```text
packages/drive/drive/src/
file-operations/
streams.ts # MODIFIED — overload signatures, range slicing transform
streams.test.ts # MODIFIED — new range cases
streams.range.test.ts # ADDED — boundary cases (table above)
streams.range.md # ADDED — docs for the new path
types/
drive.types.ts # MODIFIED — RangeReadOptions, DriveStorage.capabilities.nativeRange
error-codes.ts # MODIFIED — RANGE_INVALID, RANGE_OUT_OF_BOUNDS
packages/drive/drive-plugin-chunks/src/
pipeline/
range.ts # ADDED — pure range→chunk math (table above)
range.test.ts # ADDED — property tests over (offset, length, fileSize, chunkSize)
range.md # ADDED
plugin.ts # MODIFIED — #reassemble takes range?, #reassembleAsStream
error-codes.ts # MODIFIED — RANGE_OUT_OF_BOUNDS
packages/drive/drive-mesh/src/
pull/
range-request.ts # ADDED — specific-vs-bitset pivot
range-request.test.ts # ADDED
range-request.md # ADDED
parallel-puller.ts # MODIFIED — accept range, build request via range-request
packages/drive/drive-uri/src/
adapter/
drive-storage-like.ts # MODIFIED — forward { offset, length } from createReadStream options
drive-storage-like.test.ts # MODIFIED — range forwarding
plugins/
drive/drive.streams.ts # MODIFIED — InstrumentedDriveAdapter.createReadStream forwards range
drive/drive.streams.test.ts# MODIFIED — range + AbortSignal
otel/
otel.ts # MODIFIED — drive.uri.range attribute, range_chunks_fetched histogram
otel.test.ts # MODIFIED
```
Every new `.ts` carries a sibling `.md` per CLAUDE.md §4; every new test uses `@suaveplan/testing/runner` per rule 11.
## Trade-offs considered
- **`{ offset, length }` vs. `{ start, end }`.** Picked `offset`/`length` to match the HTTP `Range: bytes=offset-(offset+length-1)` mental model and avoid the inclusive-vs-exclusive `end` ambiguity that bites every range API at least once.
- **Always-bitset vs. always-specific request.** Always-bitset wastes ~ 20 bytes on a 3-chunk range; always-specific blows the `MAX_CHUNKS_DEFAULT` cap on multi-MiB ranges. Pivoting at the cap is honest about the wire's own constraint.
- **`Drive.read({ offset, length })` companion.** Tempting but redundant: `await new Response(stream).arrayBuffer()` reduces to the same thing. Defer to a hoist when a concrete consumer asks.
- **Throw vs. clamp on overflow.** Clamping (HTTP `Range: bytes=900-2000` on a 1 000-byte file silently returning bytes 900–999) hides upstream bugs. Throwing `RANGE_OUT_OF_BOUNDS` is louder and matches the rest of `DRIVE_*`'s explicit failure style.

View File

@@ -0,0 +1,118 @@
# Add byte-range reads to Drive.createReadStream and `drive*://` URI plugins
**Packages**: `@suaveplan/drive`, `@suaveplan/drive-uri`
**Status**: PROPOSED
**Type**: capability-extension
**Defers from**: `openspec/archive/2026-05-16-add-drive-uri/proposal.md` § "Out of scope → Range reads on `drive+mesh://`"
- **Design**: ./design.md
- **Spec deltas**:
- ./specs/drive/spec.md
- ./specs/drive-uri/spec.md
## Why
`add-drive-uri` shipped `resolver.read("drive+mesh://swarm-x/clip.mov")` as a whole-file fetch only, explicitly carving out byte-range reads:
> **Range reads on `drive+mesh://`.** `drive.createReadStream` already streams chunk-by-chunk via the chunks plugin; byte-range slicing on top requires a future `Drive.createReadStream({ offset, length })` extension — tracked separately.
Range reads are the single most-asked-for missing capability for the drive surface — every consumer that wants HTTP `Range:` header support (audio/video seek, virtual scrolling over chunked logs, partial-image decode, resume-on-reconnect, S3-style multipart staging) is currently forced to fetch the full file and slice client-side. Over `drive+mesh://` this is catastrophic: a 4 GiB media file pulled to seek the last 1 MiB downloads the entire swarm payload. The chunked layout in `drive-plugin-chunks` already addresses every chunk by `(fileId, chunkId)` with a known fixed `chunkSize` per manifest, so range → chunk math is deterministic; the missing piece is plumbing the offset/length pair from `Drive.createReadStream` through the chunks-plugin middleware so only the chunks intersecting the range are fetched, decrypted, and emitted.
This change adds the `{ offset, length }` options to `Drive.createReadStream`, extends the chunks plugin's `#reassemble` to honour them (computing first/last chunk indices, slicing the boundary chunks), threads range hints through the `drive-uri` adapters, and — over `drive+mesh://` — emits a `ChunkRequest.specific` (or `bitset` for large contiguous ranges) listing only the in-range chunkIds rather than `ChunkRequest.whole`. The local-storage and template paths get the same offset/length math for free because they go through the same chunks-plugin middleware.
## What changes
### `@suaveplan/drive`
- MODIFIED: `Drive.createReadStream(path, signal?)` → `Drive.createReadStream(path, options?: RangeReadOptions | AbortSignal)`. The new positional-options overload accepts `{ offset?: number; length?: number; signal?: AbortSignal }`. The bare-`AbortSignal` form is preserved via overload so all existing call sites compile unchanged.
- ADDED: `RangeReadOptions` interface in `src/types/drive.types.ts` — `offset` defaults to `0`, `length` defaults to "to EOF". Both MUST be non-negative integers when present; violations raise `DRIVE.RANGE_INVALID` synchronously (before stream construction).
- ADDED: `DRIVE.RANGE_INVALID` and `DRIVE.RANGE_OUT_OF_BOUNDS` codes in `DRIVE_CODES`. `RANGE_INVALID` covers negative / non-integer / NaN inputs. `RANGE_OUT_OF_BOUNDS` covers `offset > size` and `offset + length > size` where `size` is known at stream-start (e.g. via the chunks-plugin manifest); when size is unknown at start-time, the stream end-event MUST surface `RANGE_OUT_OF_BOUNDS` if the underlying source closed before `length` bytes were emitted.
- MODIFIED: `defaultCreateReadStream` and the storage-capability passthrough in `src/file-operations/streams.ts` honour `RangeReadOptions`. The default buffered path slices the loaded `Uint8Array`; the native-streams path forwards `{ offset, length }` to `storage.createReadStream` when `storage.capabilities?.nativeRange === true`, otherwise wraps the native stream in a slicing transform that drops bytes before `offset` and stops after `length`.
- ADDED: `DriveStorage` capability flag `capabilities.nativeRange?: boolean` and an extended `createReadStream(path, options?: { offset?: number; length?: number; signal?: AbortSignal })` signature on the storage layer. Storages that already accept a range (e.g. file with `fs.createReadStream({ start, end })`) opt in by setting the flag; existing storages are unaffected.
- ADDED: Middleware context carries `range?: { offset: number; length?: number }`. The chunks-plugin middleware reads it and short-circuits its `#reassemble` to the intersecting chunk window.
### `@suaveplan/drive-plugin-chunks`
- MODIFIED: `ChunksPluginHandle.#reassemble(fileId, range?)` accepts an optional range and emits only the chunks intersecting `[offset, offset + length)`. First and last chunks are sliced; interior chunks are emitted whole. Algorithm in `design.md` § "Range → chunk math".
- MODIFIED: The `read` middleware forwards `ctx.range` (when present) to `#reassemble` and to a sibling `#reassembleAsStream` used by `createReadStream`. The middleware's `read(path)` buffered call buffers only the in-range bytes.
- ADDED: `CHUNKS.RANGE_OUT_OF_BOUNDS` code — raised when the requested range exceeds `manifest.fileSize`. Surfaced on stream `error`.
### `@suaveplan/drive-mesh-protocol`
- MODIFIED: `drive-mesh`'s pull path (consumer of this package; no protocol wire change) constructs `ChunkRequest.specific { chunkIds }` for ranges spanning ≤ 64 chunks and `ChunkRequest.bitset { bytes }` for larger contiguous spans. No new wire fields — both forms already exist in `ChunkRequestPayload`.
- No `DMP_*` registry changes.
### `@suaveplan/drive-uri`
- MODIFIED: `DriveStorageLikeAdapter.createReadStream(key, options?)` accepts and forwards `{ offset?, length?, signal? }`. The three plugins (`driveUriPlugin`, `driveMeshUriPlugin`, `driveTemplateUriPlugin`) advertise `capabilities.nativeRange = true` on their adapter so `StorageUriResolver` issues a single range-aware `createReadStream` call instead of a whole-file `get` + client-side slice.
- ADDED: `DRIVE_URI.RANGE_UNSUPPORTED` is **not** introduced — every `drive*://` URI now supports ranges because the underlying chunks plugin does. The resolver surfaces upstream `DRIVE.RANGE_INVALID` / `DRIVE.RANGE_OUT_OF_BOUNDS` directly under the existing pass-through rule.
- ADDED: OTel attribute `drive.uri.range = "{offset}-{end}"` on `createReadStream` spans (omitted when no range supplied). Histogram `drive_uri.range_chunks_fetched` tags `{scheme}` and records the count of chunks actually pulled — measurable savings vs. whole-file fetch.
## Out of scope
- **Multi-range reads** (RFC 7233 `multipart/byteranges`). One range per call. A caller wanting N disjoint ranges issues N `createReadStream` calls.
- **`Drive.read(path, { offset, length })`** buffered range read. Achievable today via `createReadStream({ offset, length })` + buffer-collect; adding it to the buffered path would duplicate the chunk-math without changing observable behaviour. Hoist if a real consumer needs it.
- **Range writes / partial uploads.** Writes remain whole-file. Chunk-level patch semantics belong in a future `add-drive-patch` change.
- **Range hints on `drive`-tier `read()`** for non-chunked storages without `nativeRange`. Falls back to the slicing transform; performance characteristics are documented but no new code path is added.
- **Backpressure-aware peer scheduling** for large bitset requests. The mesh plugin's existing `ParallelPuller` window applies unchanged; range simply narrows the chunk set it pulls from.
## Source mapping
This is a **genesis-original** extension; deferral source is `openspec/archive/2026-05-16-add-drive-uri/proposal.md`. Surface anchors:
- `Drive.createReadStream` — `packages/drive/drive/src/drive/drive.ts:222-230`, `packages/drive/drive/src/file-operations/streams.ts`.
- Chunks plugin `#reassemble` — `packages/drive/drive-plugin-chunks/src/plugin.ts:218-251`; chunkSize lives on the manifest (`packages/drive/drive-plugin-chunks/src/manifest/build.ts:11,30,74`), default 65536 (`packages/drive/drive-plugin-chunks/src/plugin.ts:78`).
- `ChunkRequest` union — `packages/drive/drive-mesh-protocol/src/types/protocol.types.ts:38-42`; both `specific` and `bitset` already encodable.
- `drive-uri` adapter — `packages/drive/drive-uri/src/plugins/drive-mesh/drive-mesh.ts` extends `InstrumentedDriveAdapter`.
### Requirements with no source line
| Requirement | Justification |
|---|---|
| Overload `createReadStream(path, signal)` AND `createReadStream(path, options)` rather than breaking the single-signal form | Every existing consumer in the monorepo (chunks plugin, drive-uri adapters, drive integration tests) passes the bare `AbortSignal`. A breaking signature change for an additive feature is unjustified when overloads cost nothing. |
| Switch `ChunkRequest.specific` ↔ `ChunkRequest.bitset` at 64 chunks | `MAX_CHUNKS_DEFAULT = 64` in `drive-mesh-protocol`; beyond that, the JSON `chunkIds` array out-weighs a bitset that addresses every chunk in two bytes per 16. The 64-chunk pivot matches the wire-cap and minimises bytes-on-wire for the common case (4 MiB range over 64 KiB chunks). |
| `RANGE_OUT_OF_BOUNDS` may fire at start OR at stream-end | When `size` is known (chunks-plugin manifest loaded, storage `stat` populated), reject synchronously. When unknown (raw `defaultCreateReadStream` over a non-stat-able storage), accept the range, emit what's available, then surface the code on `close` if shortfall is detected. Two paths because the size-known/size-unknown split is a real storage capability variance, not a defect. |
| Zero-length range is a valid request | `length: 0` returns an immediately-closed empty stream. Useful as a probe-for-existence pattern; rejecting it would surprise consumers who compute ranges from upstream API responses. |
| `nativeRange` storage capability flag rather than always-slicing in drive | `bun:sqlite`-backed and `fs`-backed storages can deliver true range reads with no read-amplification. Always wrapping in a slicing transform would discard that win for the storages that have it. The flag is opt-in so adding it is purely additive on every existing storage. |
## Migration
This is a purely additive change for every existing call site:
- **Old**: `drive.createReadStream("file.bin", abortSignal)` → still compiles, still returns whole-file stream.
- **New**: `drive.createReadStream("file.bin", { offset: 1024, length: 4096, signal: abortSignal })` → emits exactly bytes [1024, 5120). Over `drive+mesh://`, requests at most ⌈(4096 + 1024 % chunkSize) / chunkSize⌉ chunks from peers.
No consumer must change. Consumers that want range behaviour migrate at their leisure. Search command for opt-in adoption:
```bash
grep -rn "createReadStream" packages/ | grep -v "/dist/\|/coverage/"
```
Recommended replacement when range-aware: pass `{ offset, length, signal }` instead of the bare `signal`.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/drive` to enumerate. The blast radius is "every package that calls `Drive.createReadStream`," which today is:
- `@suaveplan/drive-uri` — [x] tests run / [x] docs reviewed / [ ] no action needed (adapter `createReadStream` extended to forward range; new requirement added to spec delta)
- `@suaveplan/drive-plugin-chunks` — [x] tests run / [x] docs reviewed / [ ] no action needed (middleware honours range; `#reassemble` extended)
- `@suaveplan/drive-mesh` — [x] tests run / [x] docs reviewed / [ ] no action needed (pull path constructs `ChunkRequest.specific` / `bitset` from range)
- `@suaveplan/drive-transport-https` — [ ] tests run / [x] docs reviewed / [x] no action needed (transport carries chunk envelopes opaquely; no surface change)
- `@suaveplan/drive-transport-webrtc` — [ ] tests run / [x] docs reviewed / [x] no action needed (same as above)
- `@suaveplan/drive-template` — [ ] tests run / [x] docs reviewed / [x] no action needed (hydration unchanged; range applies post-hydration)
- `@suaveplan/drive-sharing` — [ ] tests run / [x] docs reviewed / [x] no action needed (calls `Drive.read`, not `createReadStream`)
- `@suaveplan/drive-webrtc` — [ ] tests run / [x] docs reviewed / [x] no action needed (browser-tier transport, unaffected)
### Affected upstreams (none modified)
| Package | Action | Breaking? |
|---|---|---|
| `@suaveplan/storage` | Optional: extend `AbstractStorage.createReadStream` with `{ offset, length }` options. Existing storages opt in via `capabilities.nativeRange`. | no |
| `@suaveplan/storage-uri` | None — already routes `createReadStream` through `UriPlugin.adapter`. | no |
| `@suaveplan/types` | None — `RangeReadOptions` lives in `@suaveplan/drive` until a second consumer emerges. | no |
### Ordering
Unblocked. All packages exist on genesis today; the change is additive.

View File

@@ -0,0 +1,121 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: drive (drive)
**Package**: `@suaveplan/drive`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. The named tests are planned; they MUST exist and pass verbatim before this delta is folded into `openspec/specs/drive/spec.md`.
## ADDED Requirements
### Requirement: `Drive.createReadStream` accepts a range-options bag
`Drive.createReadStream` SHALL be overloaded to accept either a bare `AbortSignal` (existing form) or a `RangeReadOptions` bag with optional `offset`, `length`, and `signal`. The bare-signal form MUST continue to return a whole-file stream. When the options form is passed, the drive MUST forward `{ offset, length, signal }` through the plugin middleware chain via the middleware context's `range` field. The implementation MUST runtime-discriminate via `instanceof AbortSignal`.
#### Scenario: bare signal still returns whole-file stream
- **WHEN** `drive.createReadStream("file.bin", abortSignal)` is called on a 200 KiB file
- **THEN** the returned stream emits the entire 200 KiB payload and respects `abortSignal` for cancellation
#### Scenario: options form forwards offset and length
- **WHEN** `drive.createReadStream("file.bin", { offset: 1024, length: 4096 })` is called
- **THEN** the middleware context carries `range = { offset: 1024, length: 4096 }` and the returned stream emits exactly bytes `[1024, 5120)` of the source
#### Scenario: zero-length range yields an empty stream
- **WHEN** `drive.createReadStream("file.bin", { offset: 12345, length: 0 })` is called
- **THEN** the returned stream closes immediately with zero enqueued bytes
**Verified by:** `src/drive/drive.test.ts::"createReadStream(path, signal) preserves whole-file behaviour"` AND `src/drive/drive.test.ts::"createReadStream(path, { offset, length }) emits exactly the requested slice"` AND `src/drive/drive.test.ts::"createReadStream(path, { length: 0 }) returns an immediately closed stream"`
---
### Requirement: Invalid range options throw `DRIVE.RANGE_INVALID` synchronously
`Drive.createReadStream(path, options)` SHALL validate `options.offset` and `options.length` synchronously before constructing the `ReadableStream`. Either field, when present, MUST be a finite non-negative integer (`Number.isInteger(x) && x >= 0 && Number.isFinite(x)`). On violation, the method MUST throw `DriveError(DRIVE.RANGE_INVALID, ...)` BEFORE returning. The error's `context` SHALL include the offending field name and value.
#### Scenario: negative offset throws synchronously
- **WHEN** `drive.createReadStream("file.bin", { offset: -1 })` is called
- **THEN** the call throws `DriveError` with `code === "DRIVE.RANGE_INVALID"` and `context.field === "offset"`, and no `ReadableStream` is created
#### Scenario: non-integer length throws synchronously
- **WHEN** `drive.createReadStream("file.bin", { length: 1.5 })` is called
- **THEN** the call throws `DriveError` with `code === "DRIVE.RANGE_INVALID"` and `context.field === "length"`
#### Scenario: NaN / Infinity rejected
- **WHEN** `drive.createReadStream("file.bin", { offset: Number.NaN })` is called OR `drive.createReadStream("file.bin", { length: Number.POSITIVE_INFINITY })` is called
- **THEN** each call throws `DriveError(DRIVE.RANGE_INVALID, ...)` synchronously
**Verified by:** `src/drive/drive.test.ts::"createReadStream throws RANGE_INVALID for negative offset"` AND `src/drive/drive.test.ts::"createReadStream throws RANGE_INVALID for non-integer length"` AND `src/drive/drive.test.ts::"createReadStream throws RANGE_INVALID for NaN or Infinity"`
---
### Requirement: Out-of-bounds range surfaces `DRIVE.RANGE_OUT_OF_BOUNDS`
When the underlying source exposes a known size at stream-start (via the chunks-plugin manifest, a storage `stat`, or a `nativeRange`-capable storage that reports `size`), `Drive.createReadStream` MUST raise `DriveError(DRIVE.RANGE_OUT_OF_BOUNDS, ...)` synchronously for `offset > size` or `offset + length > size`. When size is unknown at start-time, the stream MUST accept the request, emit available bytes, and on close MUST surface `DRIVE.RANGE_OUT_OF_BOUNDS` via `controller.error` if the byte count emitted is less than the requested `length`. The error MUST carry the originating `CHUNKS.RANGE_OUT_OF_BOUNDS` (when applicable) as `cause`.
#### Scenario: offset past EOF rejected synchronously when size is known
- **WHEN** the underlying file is 1024 bytes and `drive.createReadStream("file.bin", { offset: 2048 })` is called via a chunks-plugin-mediated path
- **THEN** the call throws `DriveError(DRIVE.RANGE_OUT_OF_BOUNDS, ...)` synchronously with `cause` carrying `CHUNKS.RANGE_OUT_OF_BOUNDS`
#### Scenario: offset+length past EOF rejected synchronously when size is known
- **WHEN** the file is 1024 bytes and `drive.createReadStream("file.bin", { offset: 900, length: 500 })` is called via the chunks plugin
- **THEN** the call throws `DriveError(DRIVE.RANGE_OUT_OF_BOUNDS, ...)` synchronously
#### Scenario: short-fall on stream end when size was unknown at start
- **WHEN** the storage cannot report size, `drive.createReadStream("file.bin", { offset: 0, length: 10_000 })` is called, and the source closes after 4000 bytes
- **THEN** the stream errors with `DriveError(DRIVE.RANGE_OUT_OF_BOUNDS, ...)` and `context.emittedBytes === 4000`
**Verified by:** `src/drive/drive.test.ts::"createReadStream throws RANGE_OUT_OF_BOUNDS when offset exceeds known size"` AND `src/drive/drive.test.ts::"createReadStream throws RANGE_OUT_OF_BOUNDS when offset+length exceeds known size"` AND `src/file-operations/streams.range.test.ts::"stream errors with RANGE_OUT_OF_BOUNDS on short-fall when size is unknown"`
---
### Requirement: `nativeRange` storage capability is honoured
`DriveStorage` SHALL accept an optional `capabilities.nativeRange?: boolean` flag. When `true`, `Drive.createReadStream(path, { offset, length, signal })` MUST forward `{ offset, length }` to `storage.createReadStream` directly. When `false` (or absent), the drive MUST wrap the storage stream in a slicing transform that drops bytes before `offset` and stops emitting after `length` bytes. Both paths MUST produce identical observable output.
#### Scenario: storage with nativeRange receives offset/length
- **WHEN** `storage.capabilities.nativeRange === true` and `drive.createReadStream("file.bin", { offset: 1024, length: 4096 })` is invoked
- **THEN** `storage.createReadStream` is called exactly once with `{ offset: 1024, length: 4096, signal }` and no slicing transform is interposed
#### Scenario: storage without nativeRange is wrapped in slicing transform
- **WHEN** `storage.capabilities?.nativeRange` is `undefined` or `false` and the same call is made
- **THEN** `storage.createReadStream` is called with `{ signal }` only, and the returned stream is wrapped so the consumer observes exactly bytes `[1024, 5120)`
**Verified by:** `src/file-operations/streams.range.test.ts::"nativeRange storage receives offset and length directly"` AND `src/file-operations/streams.range.test.ts::"non-nativeRange storage is wrapped in a slicing transform"`
---
### Requirement: Range cancellation propagates via `AbortSignal`
`Drive.createReadStream(path, options)` MUST propagate `options.signal` (or the bare signal in the back-compat form) through the chunks-plugin / mesh / storage pull chain. On abort, in-flight chunk fetches MUST be cancelled, the stream MUST close with `controller.error(AbortError)`, and (over `drive+mesh://`) the mesh transport MUST emit a `cd:error` envelope with `code: "ABORTED"`.
#### Scenario: mid-stream abort cancels chunk fetches
- **WHEN** `drive.createReadStream("file.bin", { offset: 0, length: 1_048_576, signal })` is called and `signal.abort()` is invoked after the first chunk emits
- **THEN** the stream closes with an `AbortError`, no further chunks are fetched, and the chunks-plugin loop exits at the next `throwIfAborted` checkpoint
**Verified by:** `src/file-operations/streams.range.test.ts::"abort mid-range stream cancels in-flight chunk fetches"`
---
### Requirement: `DRIVE_CODES` registry adds `RANGE_INVALID` and `RANGE_OUT_OF_BOUNDS`
`DRIVE_CODES` SHALL include `"RANGE_INVALID"` and `"RANGE_OUT_OF_BOUNDS"` declared via the existing `defineCodes("DRIVE", [...] as const)` call. `Object.isFrozen(DRIVE_CODES)` MUST remain `true`. Every code value MUST start with `DRIVE.`.
#### Scenario: registry exposes the new codes
- **WHEN** the test imports `DRIVE_CODES` from `@suaveplan/drive`
- **THEN** `DRIVE_CODES.RANGE_INVALID === "DRIVE.RANGE_INVALID"` AND `DRIVE_CODES.RANGE_OUT_OF_BOUNDS === "DRIVE.RANGE_OUT_OF_BOUNDS"` AND `Object.isFrozen(DRIVE_CODES) === true`
**Verified by:** `src/error-codes.spec-conformance.test.ts::"DRIVE_CODES exposes RANGE_INVALID and RANGE_OUT_OF_BOUNDS"` AND `src/error-codes.spec-conformance.test.ts::"Object.isFrozen(DRIVE_CODES) is true"`

View File

@@ -0,0 +1,79 @@
# Tasks — add-drive-range-reads
> Every checkbox MUST reflect work actually done in this change.
## 1. Spec authoring
- [ ] 1.1 Read `openspec/AGENTS.md` §0c, §2b, §6a, §7, §11, §13, §13a, §14
- [ ] 1.2 Read `openspec/archive/2026-05-16-add-drive-uri/proposal.md` deferral note and `add-drive-uri` archived delta
- [ ] 1.3 Read `openspec/specs/drive/spec.md`, `openspec/specs/drive-uri/spec.md`
- [ ] 1.4 Confirm `Drive.createReadStream`, chunks `#reassemble`, mesh `ChunkRequest` shapes in source
- [ ] 1.5 Draft `proposal.md` + `design.md` + `tasks.md` + spec deltas
- [ ] 1.6 Build the numbered Requirement / Scenario implementation checklist (per §0c step 6)
- [ ] 1.7 `openspec validate add-drive-range-reads --strict` exits 0
## 2. `@suaveplan/drive` — range plumbing
- [ ] 2.1 `src/types/drive.types.ts` — add `RangeReadOptions` interface; extend `DriveStorage` with optional `capabilities.nativeRange?: boolean`; extend `DriveStorage.createReadStream?` signature with optional `{ offset, length }` (back-compat preserved)
- [ ] 2.2 `src/error-codes.ts` — add `RANGE_INVALID`, `RANGE_OUT_OF_BOUNDS` to `DRIVE_CODES`
- [ ] 2.3 `src/error-codes.spec-conformance.test.ts` — extend frozen-registry test, every-code-starts-with-`DRIVE.` test
- [ ] 2.4 `src/file-operations/streams.ts` — overload `createReadStream(storage, path, signal?)` AND `createReadStream(storage, path, options: RangeReadOptions)`; runtime `instanceof AbortSignal` discriminator; synchronous validation → `DRIVE.RANGE_INVALID`; slicing transform when `!nativeRange`
- [ ] 2.5 `src/file-operations/streams.md` — update docs (range API, capability flag, sliced vs. native behaviour)
- [ ] 2.6 `src/file-operations/streams.range.test.ts` — boundary table cases: range fully inside one chunk, spans boundary, aligned multi-chunk, tail-to-EOF, zero-length, whole-file, abort mid-stream
- [ ] 2.7 `src/file-operations/streams.range.md` — sibling doc
- [ ] 2.8 `src/drive/drive.ts` — `createReadStream` overload signatures forwarded to `streams.ts` helpers; middleware context carries `range?: { offset, length? }`
- [ ] 2.9 `src/drive/drive.test.ts` — overload-compat (bare signal still works), options form, `DRIVE.RANGE_INVALID` synchronous throw, `DRIVE.RANGE_OUT_OF_BOUNDS` (size-known) synchronous throw
## 3. `@suaveplan/drive-plugin-chunks` — range-aware reassemble
- [ ] 3.1 `src/pipeline/range.ts` — pure function `chunkRangeFor({ offset, length, fileSize, chunkSize })` returning `{ firstChunk, lastChunk, firstSkip, lastKeep }` or `null` for zero-length
- [ ] 3.2 `src/pipeline/range.md`
- [ ] 3.3 `src/pipeline/range.test.ts` — exhaustive table (design § "Worked examples"), fast-check property tests for invariants (`offset + length ≤ fileSize`, slices reassemble to `bytes[offset, end)`)
- [ ] 3.4 `src/error-codes.ts` — add `CHUNKS.RANGE_OUT_OF_BOUNDS`
- [ ] 3.5 `src/error-codes.spec-conformance.test.ts` — extend
- [ ] 3.6 `src/plugin.ts` — refactor `#reassemble` to accept `range?: RangeReadOptions`; sibling `#reassembleAsStream(fileId, range?)`; middleware reads `ctx.range` and forwards; per-iteration `signal.throwIfAborted()`
- [ ] 3.7 `src/plugin.md` — update docs (range path, mesh request optimisation, error code)
- [ ] 3.8 `src/plugin.range.test.ts` — covers buffered read with range + streaming read with range + `RANGE_OUT_OF_BOUNDS` on oversize range + abort mid-range
## 4. `@suaveplan/drive-mesh` — range → ChunkRequest pivot
- [ ] 4.1 `src/pull/range-request.ts` — `buildRangeRequest({ firstChunk, lastChunk, totalChunks })` returns `ChunkRequest.specific` when `span ≤ 64`, else `ChunkRequest.bitset`
- [ ] 4.2 `src/pull/range-request.md`
- [ ] 4.3 `src/pull/range-request.test.ts` — pivot boundary at exactly 64 chunks; bitset round-trips through `encodeBitset`/`decodeBitset`; specific shape rejects empty span
- [ ] 4.4 `src/parallel-puller.ts` — accept range, build request via `buildRangeRequest`, narrow parallelism domain to in-range chunkIds, propagate `AbortSignal`
- [ ] 4.5 `src/parallel-puller.test.ts` — extend: only in-range chunks pulled; abort cancels in-flight; small-range uses `specific`, large-range uses `bitset`
## 5. `@suaveplan/drive-uri` — range pass-through
- [ ] 5.1 `src/types/plugin-options.types.ts` — extend adapter capabilities (`nativeRange: true`)
- [ ] 5.2 `src/adapter/drive-storage-like.ts` — `createReadStream(key, { offset?, length?, signal? })` forwards to `Drive.createReadStream(key, { offset, length, signal })`
- [ ] 5.3 `src/adapter/drive-storage-like.md` — update
- [ ] 5.4 `src/adapter/drive-storage-like.test.ts` — range forwarded; `nativeRange` capability flag asserted
- [ ] 5.5 `src/plugins/drive/drive.streams.ts` — `InstrumentedDriveAdapter.createReadStream` accepts and forwards range; emits `drive.uri.range` span attribute when present
- [ ] 5.6 `src/plugins/drive/drive.streams.test.ts` — range round-trip via plugin; abort mid-stream; attribute present iff range present
- [ ] 5.7 `src/otel/otel.ts` — `range_chunks_fetched` histogram; `drive.uri.range = "{offset}-{end}"` setter
- [ ] 5.8 `src/otel/otel.test.ts` — histogram increments, attribute set, attribute omitted when no range
- [ ] 5.9 `tests/integration/drive-range-roundtrip.test.ts` — real `Drive` + storage-file: write 200 KiB file, read bytes [60 000, 70 000) via `resolver.read` flow, assert equality
## 6. Pre-archive verification (§13)
- [ ] 6.1 `bunx turbo lint typecheck test --filter=@suaveplan/drive --filter=@suaveplan/drive-plugin-chunks --filter=@suaveplan/drive-mesh --filter=@suaveplan/drive-uri`
- [ ] 6.2 `bunx turbo test:coverage` (100 / 100 / 100 / 100 on every touched package)
- [ ] 6.3 `bunx turbo build`
- [ ] 6.4 Banned-pattern scan; barrel-shape scan; test-runner import scan; missing `.md` scan; README check; bare-version check; subpath export count; security scan
- [ ] 6.5 §13 step 10 dependent sweep: `bunx turbo test --filter=...@suaveplan/drive`
- [ ] 6.6 Walk every `### Requirement` / `#### Scenario` / `**Verified by:**` — confirm every named test exists and the name matches verbatim
## 7. Doc + manifest refresh (§13b)
- [ ] 7.1 Update `packages/drive/drive/README.md`, `packages/drive/drive-plugin-chunks/README.md`, `packages/drive/drive-mesh/README.md`, `packages/drive/drive-uri/README.md`
- [ ] 7.2 `bun scripts/folder-reorg/generate-manifest.ts`
- [ ] 7.3 `bun scripts/generate-missing.ts`
- [ ] 7.4 Update `STATUS.md`, `SUMMARY.md` for the four touched packages
- [ ] 7.5 `bunx markdownlint-cli2 --fix`
## 8. Archival (§14)
- [ ] 8.1 Fold `specs/drive/spec.md` delta into `openspec/specs/drive/spec.md`
- [ ] 8.2 Fold `specs/drive-uri/spec.md` delta into `openspec/specs/drive-uri/spec.md`
- [ ] 8.3 Move `openspec/changes/add-drive-range-reads/` → `openspec/archive/<yyyy-mm-dd>-add-drive-range-reads/`

View File

@@ -0,0 +1,93 @@
# Design — `add-drive-uri-writes`
Non-normative. The normative contract is `./specs/drive-uri/spec.md`.
## Per-scheme operation matrix
| Operation | `drive://` | `drive+mesh://` | `drive+template://` |
|---|---|---|---|
| `set(key, bytes)` (resolver `write`) | `Drive.write(key, bytes, { signal })` → `void` | `Drive.write(key, bytes, { signal })` → `void`; mesh advertise NOT triggered | throw `DRIVE_URI.TEMPLATE_READ_ONLY` |
| `delete(key)` (resolver `remove`) | `Drive.delete(key, signal)` → `boolean` | `Drive.delete(key, signal)` → `boolean`; mesh "no-longer-available" advert NOT triggered | throw `DRIVE_URI.TEMPLATE_READ_ONLY` |
| `clear()` | throw `DRIVE_URI.WRITE_UNSUPPORTED` (Drive-wide wipe footgun) | same | same |
| `createReadStream` | unchanged | unchanged | unchanged |
| `get` / `exists` / `list` / `stat` | unchanged | unchanged | unchanged |
`clear()` retains `DRIVE_URI.WRITE_UNSUPPORTED` to preserve the existing error contract for that footgun call site. The new `TEMPLATE_READ_ONLY` code is specific to template-backed mutation attempts — operators differentiate "this drive forbids wholesale wipes" (a config-time concern) from "this URI scheme is intrinsically read-only" (an architectural constraint).
## Error code transition
| Old behaviour | New behaviour | Code |
|---|---|---|
| `set` on `drive://` → throw `WRITE_UNSUPPORTED` | succeeds | (success) |
| `set` on `drive+mesh://` → throw `WRITE_UNSUPPORTED` | succeeds | (success) |
| `set` on `drive+template://` → throw `WRITE_UNSUPPORTED` | throw `TEMPLATE_READ_ONLY` | `DRIVE_URI.TEMPLATE_READ_ONLY` |
| `delete` on `drive+template://` → forwarded to underlying `Drive.delete` (silent footgun: would mutate the hydrated cache, breaking the dehydrated semantics) | throw `TEMPLATE_READ_ONLY` | `DRIVE_URI.TEMPLATE_READ_ONLY` |
| `clear()` on any scheme → throw `WRITE_UNSUPPORTED` | unchanged | `DRIVE_URI.WRITE_UNSUPPORTED` |
`DRIVE_URI.WRITE_UNSUPPORTED` is intentionally retained — consumers who already asserted on it for the `clear()` path continue to work; we narrow its meaning rather than churn the constant.
## Mesh write — propagation timing and observability
`drive+mesh://` writes complete when local persist is durable. Specifically:
1. `Drive.write(path, bytes, { signal })` returns when the installed `chunksPlugin` middleware has resolved (encrypted, chunked, persisted to the configured storage).
2. Mesh re-advertise (`mesh.advertise(fileId, bitset, totalChunks)`) is NOT triggered by the URI surface. This matches direct-`Drive` behaviour and avoids surprising consumers who manage `GenerationTracker` / `AdvertiseThrottle` lifecycles themselves.
3. The plugin emits a span event `drive_uri.mesh_write_local_only` on every successful `drive+mesh://` write, with attributes `{ scheme: "drive+mesh", path }`. Operators can grep the trace to confirm the contract holds in their environment.
4. Consumers wanting gossip propagation invoke `mesh.advertise(fileId, ...)` after the write resolves. Propagation thereafter follows the existing `GossipEngine` timing (best-effort, backoff-throttled, observable via the mesh package's own events).
This deliberate choice preserves separation of concerns: the URI surface is a routing layer; mesh participation is a Drive-tier policy.
## Key-rotation impact (none, by inheritance)
A write may produce data encrypted under a NEW `KeySlotPublic` version. The URI surface does no key derivation, no slot resolution, and no grant signing — those live in `chunksPlugin` and in `Drive.hydrateFrom`'s `key:grant` subscription. The read path validates retroactive grants today; nothing in the write path needs to change for new key versions to be honoured on subsequent reads.
No new wiring required at the URI tier.
## AbortSignal threading
- `resolver.write(uri, body, signal?)` passes `signal` to `InstrumentedDriveAdapter.set(key, value, signal)`.
- `InstrumentedDriveAdapter.set` updates its signature to accept `AbortSignal` (currently typed `set(key, value)` per `StorageLike`; we add the third arg as an optional propagated to `Drive.write({ signal })`). Pre-aborted signals short-circuit to `DRIVE_URI.ABORTED` before any work.
- `resolver.remove(uri, signal?)` already threads `signal` to `adapter.delete(key)`; we widen the override to accept and forward `signal` to `Drive.delete(path, signal)`.
`StorageLike` itself does not specify `signal` on `set`/`delete`. `DriveStorageLikeAdapter` adds the parameter as a tolerated extension; the resolver already passes it via the streaming-write surface (`createWriteStream(key, body, signal)`), so the precedent is established.
## File-layout impact
New / edited files (under `packages/drive/drive-uri/src/`):
```
adapter/
drive-storage-like.ts # widen set/delete to accept signal; forward through
drive-storage-like.md # doc update — note new TEMPLATE_READ_ONLY case
error-codes/
error-codes.ts # add TEMPLATE_READ_ONLY to defineCodes
error-codes.md # update; describe narrowed WRITE_UNSUPPORTED role
plugins/
drive/
drive.streams.ts # remove unconditional WRITE_UNSUPPORTED; per-scheme dispatch
drive.streams.md # doc update
drive-template/
drive-template.ts # override set/delete → TEMPLATE_READ_ONLY
drive-template.md # doc update
otel/
otel.ts # add `mesh_write_local_only` span-event constant
otel.md # doc update
README.md # update Features / Modules sections
tests/integration/
drive-roundtrip-write.test.ts # NEW — real Drive, write+read cycle
drive-mesh-roundtrip-write.test.ts # NEW — mesh-configured Drive, assert no implicit advertise
template-write-rejection.test.ts # NEW — TEMPLATE_READ_ONLY round-trip
```
All new code stays inside the existing 300-line / 500-line file budgets; the changed `drive.streams.ts` currently sits at 148 lines and the per-scheme branching adds ~30 lines.
## Trade-offs considered
- **Per-scheme `set` dispatch via `if (scheme === "drive+template")` vs. polymorphic adapter subclass.** Chose the inline branch — only one scheme has the constraint and a subclass would duplicate the entire instrumented method body. If a fourth scheme appears, revisit.
- **Auto-advertise on mesh writes.** Rejected: violates the direct-`Drive` contract, would surprise consumers who run `GenerationTracker` themselves, and forces every mesh-write call site through an advertise budget. The span event gives operators visibility without enforcing policy.
- **Reusing `WRITE_UNSUPPORTED` for template rejections.** Rejected: it conflates a temporary state (the deferred scope this change closes) with a permanent architectural constraint (templates are signed). Distinct codes let consumers branch correctly.
- **Removing `WRITE_UNSUPPORTED` entirely.** Rejected: it would churn the public surface (consumers may already assert on it for the `clear()` path) and §2c forbids deleting a registered code without a real reason. Narrowing the role is the right move.
## Cross-reference
This change is the second consumer of structural `DriveLike` (after `add-drive-range-reads`), strengthening the case for `hoist-drivelike-to-types`. Independent landing — `drive-uri` imports `DriveLike` from `@suaveplan/drive` today and will move to `@suaveplan/types` once `hoist-drivelike-to-types` archives.

View File

@@ -0,0 +1,93 @@
# Unblock writes/removes via `drive://`, `drive+mesh://`, `drive+template://`
**Package**: `@suaveplan/drive-uri`
**Status**: PROPOSED
**Type**: capability-extension (genesis-original follow-up)
**Depends on**: `add-drive-uri` (archived `2026-05-16`) — provides the three plugins + `InstrumentedDriveAdapter`.
**Sibling**: `hoist-drivelike-to-types` (independent — landing order is irrelevant; `drive-uri` consumes structural `DriveLike` from whichever source has merged when this lands).
**Sibling**: `add-drive-range-reads` (read-side, independent — out of scope here).
- **Design**: ./design.md
- **Spec delta**: ./specs/drive-uri/spec.md
## Why
The shipped `add-drive-uri` change explicitly deferred writes:
> *Initial release rejects `write`/`remove` with `DRIVE_URI.WRITE_UNSUPPORTED`. Reason: write semantics for shared/mesh files (chunk re-upload, gossip re-advertise, template re-issue, retroactive key grants) are non-trivial; they belong in a follow-up `add-drive-uri-writes` change once read paths are proven in production.*
Read paths have shipped, the conformance harness is green, and the four originally-cited blockers all resolve cleanly at the URI tier when analysed per-scheme:
| Cited blocker | Resolution per scheme |
|---|---|
| Chunk re-upload | Already handled inside `Drive.write` via the installed `chunksPlugin` middleware. The URI surface forwards `path`/`data`/`signal` unchanged — no chunk codec logic in `drive-uri`. |
| Gossip re-advertise | `drive-mesh` exposes `advertise(fileId, bitset, totalChunks)` as an application-controlled call (see `drive-mesh/src/mesh/drive-mesh/drive-mesh.ts:176`). Writes do NOT auto-advertise on direct `Drive` use today, and `drive-uri` MUST preserve that contract — best-effort propagation is observable via the existing mesh events the consumer already subscribes to. |
| Template re-issue | Templates are dehydrated, authority-signed descriptors. The resolver cannot re-sign them. `drive+template://` writes are therefore **permanently** rejected with a new specific code `DRIVE_URI.TEMPLATE_READ_ONLY`, not the generic `WRITE_UNSUPPORTED`. |
| Retroactive key grants | Inherited from the underlying `Drive` — `key:grant` subscriptions and `KeySlotPublic` rotation live in `Drive.hydrateFrom` and the `chunksPlugin`, not in the URI surface. A new key version added after a write is consumed by future reads through the same path the existing read surface already validates. |
This change ships the two surfaces that are safe today (`drive://` pass-through and `drive+mesh://` write-then-let-consumer-advertise), permanently locks down the surface that cannot be safely lifted (`drive+template://` writes), and narrows the role of `DRIVE_URI.WRITE_UNSUPPORTED` so it no longer pretends to cover a permanent constraint.
## What changes
- ADDED: `drive://` writes/removes — `InstrumentedDriveAdapter.set(key, value)` and `.delete(key)` for the `drive` scheme delegate to `Drive.write(path, data, { signal })` and `Drive.delete(path, signal)`. `AbortSignal` is threaded through the resolver `write(uri, body, signal)` and `remove(uri, signal)` surfaces. OTel `drive_uri.writes_total{scheme:"drive", outcome:"success"|"error"}` and `drive_uri.deletes_total{scheme:"drive", outcome}` populated.
- ADDED: `drive+mesh://` writes/removes — identical delegation to `Drive.write` / `Drive.delete`; the consumer's installed `chunksPlugin` handles encryption + chunk persistence locally. Mesh re-advertisement is NOT triggered by the URI surface (matching the direct-`Drive` contract); the consumer SHALL call `mesh.advertise(fileId, bitset, totalChunks)` after the write resolves if they want gossip propagation. The plugin documents this contract on the sibling `.md` and surfaces it on a span event `drive_uri.mesh_write_local_only` so operators can audit any environment that expects implicit gossip.
- ADDED: `drive+template://` writes/removes — **permanently rejected** with the new code `DRIVE_URI.TEMPLATE_READ_ONLY`. A template-backed drive is a dehydrated, server-signed view; mutating it would require an authority signing key the URI resolver never holds. Removes are rejected for the same reason. `drive_uri.writes_total{scheme:"drive+template", outcome:"error"}` and `drive_uri.deletes_total{scheme:"drive+template", outcome:"error"}` increment on the rejection.
- ADDED: New error code `DRIVE_URI.TEMPLATE_READ_ONLY` via `defineCodes` (registry grows from 7 → 8). Reachable from the `drive+template` adapter's `set` and `delete` overrides; both paths covered by tests.
- MODIFIED: `DRIVE_URI.WRITE_UNSUPPORTED` retained in the registry but narrowed to a **single call site** — `DriveStorageLikeAdapter.clear()` (the explicit `Drive.delete`-this-instead footgun). It is no longer the rejection code for write/remove via `set`/`delete`. The README, sibling `.md`, and spec all describe this narrower role.
- MODIFIED: `InstrumentedDriveAdapter.set` no longer unconditionally throws. It dispatches per the scheme bound at construction: `drive`/`drive+mesh` → delegate; `drive+template` → throw `TEMPLATE_READ_ONLY`. Same for `delete`.
- MODIFIED: Conformance — the `runUriPluginConformance` writes suite is re-run against all three plugins. Expected outcomes: `drive://` and `drive+mesh://` complete the full write/read/exists/list/remove cycle; `drive+template://` continues to reject writes (now with `TEMPLATE_READ_ONLY` rather than `WRITE_UNSUPPORTED`).
- ADDED: `tests/integration/drive-roundtrip-write.test.ts` — write bytes via `resolver.write("drive://primary/x", bytes)`, read back via `resolver.read`, assert equality. No mocks.
- ADDED: `tests/integration/drive-mesh-roundtrip-write.test.ts` — same against a mesh-configured Drive; assert no implicit `advertise` call; explicit `mesh.advertise(...)` after the write produces a verifiable outbound advertisement via the existing transport stub fixture.
## Out of scope
- **Auto-gossip on `drive+mesh://` writes.** Direct `Drive.write` does not auto-advertise today; the URI surface MUST NOT bend that contract. Tracked separately if a future use case proves the policy.
- **Template re-issue.** Permanently scoped out — needs an authority signing key the resolver never possesses. The `TEMPLATE_READ_ONLY` rejection is the contract.
- **Range reads on `drive+mesh://`.** Independent change `add-drive-range-reads`.
- **`DriveLike` hoist to `@suaveplan/types`.** Independent change `hoist-drivelike-to-types`. This change consumes the structural shape from whichever has merged first; it does NOT depend on the hoist landing first.
- **New `Drive.write` options** (atomicity, conditional puts, content-type). Out of scope — would require a `Drive`-tier proposal.
- **Removing `DRIVE_URI.WRITE_UNSUPPORTED`.** Kept for the `clear()` footgun call site (a stable surface code consumers may already assert on).
## Source mapping
- Deferred-text in `openspec/archive/2026-05-16-add-drive-uri/proposal.md` lines 30–38 — the explicit follow-up scope.
- `packages/drive/drive-uri/src/plugins/drive/drive.streams.ts:69-84` — current unconditional `set` rejection.
- `packages/drive/drive-uri/src/adapter/drive-storage-like.ts:75-86` — `set`/`delete` already pass-through internally; only the instrumented override blocks them.
- `packages/drive/drive/src/drive/drive.ts:180-200` — `Drive.write(path, data, options?)` and `Drive.delete(path, signal?)` signatures consumed verbatim.
- `packages/drive/drive-mesh/src/mesh/drive-mesh/drive-mesh.ts:176-191` — `mesh.advertise(fileId, bitset, totalChunks)` — confirms gossip is consumer-controlled, not write-triggered.
- `packages/drive/drive-template/src/` — no surface here is mutable; the only API touching write is `hydrateFrom`, which is a read-side projection.
- `packages/storage/storage-uri/src/resolver/resolver.writes.ts:30-75` — confirms `SuaveplanError` subclasses (including `DriveUriError`) pass through `writeViaPlugin` unchanged, so `TEMPLATE_READ_ONLY` reaches the caller without a `STORAGE_URI.WRITE_UNSUPPORTED` envelope.
- `openspec/specs/drive-uri/spec.md` Requirement at line 181 — current global write rejection, replaced by the new per-scheme matrix.
## Verification approach
Every requirement in the spec delta carries an explicit `**Verified by:**` line referencing planned `it(...)` titles in concrete test files (see delta). The pre-archive gate (§13 of `AGENTS.md`) re-runs the full `runUriPluginConformance` writes suite against all three plugins.
## Migration
| Old | New |
|---|---|
| `resolver.write("drive://…", bytes)` throws `DRIVE_URI.WRITE_UNSUPPORTED` | succeeds, returns `void` |
| `resolver.write("drive+mesh://…", bytes)` throws `DRIVE_URI.WRITE_UNSUPPORTED` | succeeds, returns `void`; gossip remains consumer-controlled |
| `resolver.write("drive+template://…", bytes)` throws `DRIVE_URI.WRITE_UNSUPPORTED` | throws `DRIVE_URI.TEMPLATE_READ_ONLY` |
| `resolver.remove("drive+template://…", …)` succeeded (via inherited `DriveStorageLikeAdapter.delete`) | throws `DRIVE_URI.TEMPLATE_READ_ONLY` (closes a silent footgun) |
| `DRIVE_URI.WRITE_UNSUPPORTED` raised for `set`/`delete` | only raised for `DriveStorageLikeAdapter.clear()` |
Search consumers: `grep -rln "DRIVE_URI.WRITE_UNSUPPORTED\|WRITE_UNSUPPORTED" packages/ | grep -v "/dist/\|/coverage/\|/drive-uri/"`. Recommended replacement: branch on `err.code === "DRIVE_URI.TEMPLATE_READ_ONLY"` for the immutable template surface; allow the success path elsewhere.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/drive-uri` to enumerate.
- No external dependents yet (`drive-uri` is consumed only via integration tests at the time of writing) — [x] no action needed (reason: new consumers will adopt the broader surface; existing surface is purely permissive expansion except for `drive+template://` removes, called out in the Migration table).
### Affected upstreams (none modified)
| Package | Action | Breaking? |
|---|---|---|
| `@suaveplan/drive` | None — `Drive.write` / `Drive.delete` consumed as shipped | no |
| `@suaveplan/drive-mesh` | None — `advertise` remains consumer-triggered | no |
| `@suaveplan/drive-template` | None — no mutable surface exists; `TEMPLATE_READ_ONLY` codifies that fact | no |
| `@suaveplan/storage-uri` | None — `writeViaPlugin` already passes through `SuaveplanError` subclasses | no |

View File

@@ -0,0 +1,143 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: drive-uri (drive-uri)
**Package**: `@suaveplan/drive-uri`
**Status**: PROPOSED
> Every requirement carries a `**Verified by:**` line at authoring time naming planned tests.
## ADDED Requirements
### Requirement: `drive://` SHALL support writes and removes via the resolver surface
The `drive` plugin's instrumented adapter SHALL delegate `set(key, value, signal?)` to `Drive.write(path, data, { signal })` and `delete(key, signal?)` to `Drive.delete(path, signal)`. Successful writes SHALL increment `drive_uri.writes_total{scheme:"drive", outcome:"success"}`; successful removes SHALL increment `drive_uri.deletes_total{scheme:"drive", outcome:"success"}`. The `AbortSignal` passed to `resolver.write(uri, body, signal)` / `resolver.remove(uri, signal)` SHALL be threaded through to the underlying `Drive` call. A pre-aborted signal SHALL short-circuit to `DRIVE_URI.ABORTED` without invoking `Drive.write` / `Drive.delete`.
#### Scenario: write round-trips via Drive.write
- **WHEN** `resolver.write("drive://primary/clip.mov", bytes)` is called against a `drive` plugin bound to a real local-storage `Drive`
- **THEN** the call resolves with `void`, the underlying `Drive` reports the path persisted, and `resolver.read("drive://primary/clip.mov")` returns `bytes`
#### Scenario: remove delegates to Drive.delete
- **WHEN** `resolver.remove("drive://primary/clip.mov")` is called after a successful write
- **THEN** the call resolves with `true` and a subsequent `resolver.read(...)` rejects with `STORAGE_URI.NOT_FOUND`
#### Scenario: AbortSignal threads through writes
- **WHEN** a `resolver.write(...)` is invoked with an `AbortSignal` that aborts mid-call
- **THEN** the call rejects with `DRIVE_URI.ABORTED` and any underlying `Drive.write` invocation observes the abort
**Verified by:** `src/plugins/drive/drive.test.ts::"writes succeed and round-trip via Drive.write"` AND `src/plugins/drive/drive.test.ts::"removes delegate to Drive.delete and return its boolean"` AND `src/plugins/drive/drive.test.ts::"writes propagate AbortSignal to Drive.write"` AND `tests/integration/drive-roundtrip-write.test.ts::"resolver.write then resolver.read round-trips on a real Drive"`
---
### Requirement: `drive+mesh://` SHALL support writes/removes locally without triggering mesh re-advertisement
The `drive+mesh` plugin's instrumented adapter SHALL delegate `set`/`delete` to the underlying `Drive` exactly as the `drive` scheme does. It SHALL NOT call `mesh.advertise(...)` or any equivalent gossip API; gossip propagation remains the consumer's responsibility. On every successful `drive+mesh://` write, the plugin SHALL emit a span event named `drive_uri.mesh_write_local_only` carrying `{ scheme: "drive+mesh", path }` so operators can audit that the contract holds.
#### Scenario: write persists locally without auto-advertise
- **WHEN** `resolver.write("drive+mesh://swarmA/clip.mov", bytes)` is called against a mesh-configured `Drive` with a spy transport
- **THEN** the call resolves with `void`, the bytes are persisted via `Drive.write`, and the spy transport records zero `advertise(...)` calls
#### Scenario: explicit consumer-side advertise still works
- **WHEN** the consumer invokes `mesh.advertise(fileId, bitset, totalChunks)` after the write resolves
- **THEN** the spy transport records one outbound advertisement
#### Scenario: span event marks local-only write
- **WHEN** any successful `drive+mesh://` write completes
- **THEN** the active span carries an event `drive_uri.mesh_write_local_only` with attributes `{ scheme: "drive+mesh", path }`
**Verified by:** `src/plugins/drive-mesh/drive-mesh.test.ts::"writes succeed without triggering mesh.advertise"` AND `src/plugins/drive-mesh/drive-mesh.test.ts::"writes emit drive_uri.mesh_write_local_only span event"` AND `tests/integration/drive-mesh-roundtrip-write.test.ts::"explicit mesh.advertise after write produces an outbound advertisement"`
---
### Requirement: `drive+template://` writes and removes SHALL be permanently rejected with `DRIVE_URI.TEMPLATE_READ_ONLY`
The `drive+template` plugin's instrumented adapter SHALL throw a `DriveUriError` with `code === "DRIVE_URI.TEMPLATE_READ_ONLY"` on every `set` and `delete` call, before any delegation to `Drive`. The error SHALL carry `scheme: "drive+template"`, the offending `path`, and the `templateId` that was bound. The rejection SHALL increment `drive_uri.writes_total{scheme:"drive+template", outcome:"error"}` for writes and `drive_uri.deletes_total{scheme:"drive+template", outcome:"error"}` for removes. The error message SHALL state that template-backed drives require authority re-signing not held by the resolver.
#### Scenario: write against drive+template:// rejects with TEMPLATE_READ_ONLY
- **WHEN** `resolver.write("drive+template://tplA/clip.mov", bytes)` is called
- **THEN** the call rejects with `DriveUriError` whose `code === "DRIVE_URI.TEMPLATE_READ_ONLY"`, `scheme === "drive+template"`, `path === "clip.mov"`, and `templateId === "tplA"`
#### Scenario: remove against drive+template:// rejects with TEMPLATE_READ_ONLY
- **WHEN** `resolver.remove("drive+template://tplA/clip.mov")` is called
- **THEN** the call rejects with `DriveUriError` whose `code === "DRIVE_URI.TEMPLATE_READ_ONLY"`
#### Scenario: rejection increments error counters
- **WHEN** any `drive+template://` write rejection fires
- **THEN** `drive_uri.writes_total{scheme:"drive+template", outcome:"error", code:"DRIVE_URI.TEMPLATE_READ_ONLY"}` increments by 1
**Verified by:** `src/plugins/drive-template/drive-template.test.ts::"writes reject with DRIVE_URI.TEMPLATE_READ_ONLY"` AND `src/plugins/drive-template/drive-template.test.ts::"removes reject with DRIVE_URI.TEMPLATE_READ_ONLY"` AND `src/otel/otel.test.ts::"increments writes_total{outcome:error,code:TEMPLATE_READ_ONLY} on template writes"` AND `tests/integration/template-write-rejection.test.ts::"resolver.write and resolver.remove both reject on template-backed drive"`
---
### Requirement: `DRIVE_URI_CODES` SHALL include `TEMPLATE_READ_ONLY`
The `DRIVE_URI_CODES` registry produced by `defineCodes("DRIVE_URI", […])` SHALL include `TEMPLATE_READ_ONLY` as a distinct, fully-qualified `DRIVE_URI.TEMPLATE_READ_ONLY` code. The `DriveUriErrorCode` union SHALL include it. The code SHALL be reachable from real call sites (the `drive+template` adapter's `set` and `delete` overrides).
#### Scenario: registry exposes TEMPLATE_READ_ONLY
- **WHEN** `DRIVE_URI_CODES.TEMPLATE_READ_ONLY` is read
- **THEN** the value is the string `"DRIVE_URI.TEMPLATE_READ_ONLY"`
#### Scenario: registry remains size-stable elsewhere
- **WHEN** the registry's key count is asserted
- **THEN** it equals 8, comprising the original 7 plus `TEMPLATE_READ_ONLY`
**Verified by:** `src/error-codes/error-codes.test.ts::"includes TEMPLATE_READ_ONLY and totals 8 codes"`
---
### Requirement: `runUriPluginConformance` writes suite SHALL pass for `drive`/`drive+mesh` and reject for `drive+template`
The conformance harness re-exported from `@suaveplan/storage-uri` SHALL be re-run against each of the three plugins. The `drive` and `drive+mesh` plugins SHALL pass the full write/read/exists/list/remove cycle. The `drive+template` plugin SHALL continue to fail the write/remove portions of the suite, but with `DRIVE_URI.TEMPLATE_READ_ONLY` as the rejection code rather than `WRITE_UNSUPPORTED`.
#### Scenario: conformance writes suite passes for drive
- **WHEN** the harness is configured with a real-`Drive`-backed `drive` plugin and asked to exercise the writes suite
- **THEN** every write/read round-trip assertion holds and no test is skipped
#### Scenario: conformance writes suite passes for drive+mesh
- **WHEN** the harness is configured with a mesh-configured `Drive`-backed `drive+mesh` plugin
- **THEN** every write/read round-trip holds; no `advertise` is observed on the spy transport during the harness
#### Scenario: conformance writes suite rejects drive+template with the new code
- **WHEN** the harness is configured with a `drive+template` plugin and exercises any write/remove
- **THEN** every such call rejects with `DRIVE_URI.TEMPLATE_READ_ONLY`
**Verified by:** `src/conformance/conformance.test.ts::"writes suite passes for drive plugin"` AND `src/conformance/conformance.test.ts::"writes suite passes for drive+mesh plugin"` AND `src/conformance/conformance.test.ts::"writes suite rejects drive+template with TEMPLATE_READ_ONLY"`
---
## MODIFIED Requirements
### Requirement: `DRIVE_URI.WRITE_UNSUPPORTED` SHALL only be raised by `DriveStorageLikeAdapter.clear()`
Previously the three plugins rejected every `set` (write) call with `DRIVE_URI.WRITE_UNSUPPORTED`. The code SHALL now be raised by exactly one call site: `DriveStorageLikeAdapter.clear()`, which intentionally refuses to wipe an entire Drive through the URI surface and points the caller at `Drive.delete(path)` (or `resolver.remove(uri)`). The code SHALL remain in `DRIVE_URI_CODES` so consumers that already assert on it for the `clear()` path continue to function unchanged. No other call site in `drive-uri` SHALL raise `WRITE_UNSUPPORTED`.
#### Scenario: clear() still rejects with WRITE_UNSUPPORTED
- **WHEN** any consumer invokes `adapter.clear()` on a `DriveStorageLikeAdapter` (directly or via an unusual resolver extension)
- **THEN** the call rejects with `DriveUriError` whose `code === "DRIVE_URI.WRITE_UNSUPPORTED"`
#### Scenario: set on drive:// no longer raises WRITE_UNSUPPORTED
- **WHEN** `resolver.write("drive://primary/x", bytes)` is called against a real `Drive`
- **THEN** the call resolves with `void` and no `DRIVE_URI.WRITE_UNSUPPORTED` is thrown anywhere in the call chain
#### Scenario: set on drive+template:// raises TEMPLATE_READ_ONLY not WRITE_UNSUPPORTED
- **WHEN** `resolver.write("drive+template://tplA/x", bytes)` is called
- **THEN** the rejection's `code` is `DRIVE_URI.TEMPLATE_READ_ONLY`, never `DRIVE_URI.WRITE_UNSUPPORTED`
**Verified by:** `src/adapter/drive-storage-like.test.ts::"clear() rejects with DRIVE_URI.WRITE_UNSUPPORTED"` AND `src/plugins/drive/drive.test.ts::"writes succeed and round-trip via Drive.write"` AND `src/plugins/drive-template/drive-template.test.ts::"writes reject with DRIVE_URI.TEMPLATE_READ_ONLY"`

View File

@@ -0,0 +1,60 @@
# Tasks — `add-drive-uri-writes`
## 1. Registry
- [ ] 1.1 Add `TEMPLATE_READ_ONLY` to `DRIVE_URI_CODES` in `src/error-codes/error-codes.ts` (8 codes total).
- [ ] 1.2 Update `src/error-codes/error-codes.md` — describe `TEMPLATE_READ_ONLY` and the narrowed role of `WRITE_UNSUPPORTED`.
- [ ] 1.3 Extend `src/error-codes/error-codes.test.ts` to cover the new code and assert `WRITE_UNSUPPORTED` is still present.
## 2. Adapter — `DriveStorageLikeAdapter`
- [ ] 2.1 Widen `set(key, value, signal?)` and `delete(key, signal?)` to accept and forward `AbortSignal` to `Drive.write({ signal })` / `Drive.delete(path, signal)`.
- [ ] 2.2 Update `src/adapter/drive-storage-like.md` for the new signature and note the `TEMPLATE_READ_ONLY` rejection happens in the instrumented override, not here.
- [ ] 2.3 Extend `src/adapter/drive-storage-like.test.ts` — `set` propagates `signal`, `delete` propagates `signal`, pre-aborted signal short-circuits.
## 3. Instrumented adapter — `InstrumentedDriveAdapter`
- [ ] 3.1 Replace the unconditional `set` rejection with per-scheme dispatch: `drive`/`drive+mesh` → delegate to `super.set(key, value, signal)`; `drive+template` → throw `DRIVE_URI.TEMPLATE_READ_ONLY`.
- [ ] 3.2 Override `delete` symmetrically: `drive`/`drive+mesh` → delegate; `drive+template` → throw `TEMPLATE_READ_ONLY`.
- [ ] 3.3 On every successful `drive+mesh://` write, emit span event `drive_uri.mesh_write_local_only` with `{ scheme, path }`.
- [ ] 3.4 Pre-aborted `signal` on `set`/`delete` → throw `DRIVE_URI.ABORTED` before any work.
- [ ] 3.5 OTel counters: increment `drive_uri.writes_total{scheme, outcome}` and `drive_uri.deletes_total{scheme, outcome}` for every success and every rejection.
- [ ] 3.6 Update `src/plugins/drive/drive.streams.md` and `src/plugins/drive-template/drive-template.md`.
## 4. Test suite
- [ ] 4.1 `src/plugins/drive/drive.test.ts` — replace `"rejects writes with DRIVE_URI.WRITE_UNSUPPORTED"` with `"writes succeed and round-trip via Drive.write"` and `"writes propagate AbortSignal to Drive.write"`.
- [ ] 4.2 `src/plugins/drive/drive.test.ts` — add `"removes delegate to Drive.delete and return its boolean"` and `"removes propagate AbortSignal"`.
- [ ] 4.3 `src/plugins/drive-mesh/drive-mesh.test.ts` — add `"writes succeed without triggering mesh.advertise"` and `"writes emit drive_uri.mesh_write_local_only span event"`.
- [ ] 4.4 `src/plugins/drive-template/drive-template.test.ts` — add `"writes reject with DRIVE_URI.TEMPLATE_READ_ONLY"` and `"removes reject with DRIVE_URI.TEMPLATE_READ_ONLY"`.
- [ ] 4.5 `src/otel/otel.test.ts` — `"increments writes_total{outcome:success} on successful drive:// writes"`, `"increments writes_total{outcome:error,code:TEMPLATE_READ_ONLY} on template writes"`, `"increments deletes_total per scheme outcome"`.
- [ ] 4.6 `src/conformance/conformance.test.ts` — re-run `runUriPluginConformance` writes suite against all three plugins. Expected: `drive`/`drive+mesh` pass; `drive+template` continues to fail writes with the new code.
- [ ] 4.7 `tests/integration/drive-roundtrip-write.test.ts` — real local-storage Drive, `resolver.write` then `resolver.read`, assert equality.
- [ ] 4.8 `tests/integration/drive-mesh-roundtrip-write.test.ts` — mesh-configured Drive with a spy transport; assert no implicit `advertise` call; explicit `mesh.advertise(...)` after the write produces a verifiable outbound advertisement.
- [ ] 4.9 `tests/integration/template-write-rejection.test.ts` — wired template-backed Drive, `resolver.write("drive+template://...")` and `resolver.remove(...)` both reject with `TEMPLATE_READ_ONLY`.
## 5. Migration audit
- [ ] 5.1 `grep -rln "DRIVE_URI.WRITE_UNSUPPORTED" packages/ | grep -v "/dist/\|/coverage/\|/drive-uri/"` — every hit is reviewed; non-matching consumers re-routed onto `TEMPLATE_READ_ONLY` where appropriate.
- [ ] 5.2 Update `README.md` Features section to reflect writes/removes via `drive://` and `drive+mesh://`.
## 6. Pre-publish gate (§13)
- [ ] 6.1 Spec reconciliation — walk every `### Requirement` and `#### Scenario` in `specs/drive-uri/spec.md`; confirm the named test exists verbatim.
- [ ] 6.2 `bunx turbo lint typecheck test --filter=@suaveplan/drive-uri` — green.
- [ ] 6.3 `bunx turbo test:coverage --filter=@suaveplan/drive-uri` — 100/100/100/100, including the `TEMPLATE_READ_ONLY` rejection branches.
- [ ] 6.4 `bunx turbo build --filter=@suaveplan/drive-uri` — green.
- [ ] 6.5 Banned-pattern, barrel, test-runner-import, missing-`.md`, bare-version, and secrets scans — all clean.
- [ ] 6.6 Dependent sweep — `bunx turbo test --filter=...@suaveplan/drive-uri` green.
## 7. Root-doc refresh (§13b)
- [ ] 7.1 `bun scripts/folder-reorg/generate-manifest.ts` — refresh `MANIFEST.md`.
- [ ] 7.2 `bun scripts/generate-missing.ts` — refresh `MISSING.yml`.
- [ ] 7.3 Update `STATUS.md` and `SUMMARY.md` paragraphs for `drive-uri` to reflect writes-supported on the two mutable schemes.
- [ ] 7.4 `bunx markdownlint-cli2 --fix` over the touched docs.
## 8. Archive
- [ ] 8.1 Fold `specs/drive-uri/spec.md` (deltas) into `openspec/specs/drive-uri/spec.md`.
- [ ] 8.2 Move `openspec/changes/add-drive-uri-writes/` → `openspec/archive/<yyyy-mm-dd>-add-drive-uri-writes/`.

View File

@@ -0,0 +1,253 @@
# Design: add-installer-upgrade
This document captures the non-normative design for the deferred upgrade flow.
Normative requirements live in `specs/installer/spec.md`.
## Flow diagram
```text
suaveplan-install upgrade <plugin> [--from X] [--to Y] [--dry-run] [--force]
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ Host.upgrade(plugin, options) │
└────────────────────────────────────────────────────────────────────────┘
│
├─► loadState(fs, cwd)
│ state-file: .suaveplan/installer-state.json
│ missing entry → INSTALLER.UPGRADE_NOT_INSTALLED
│
├─► resolveTargetVersion(plugin, cwd)
│ reads node_modules/@<scope>/<pkg>/package.json
│ missing → INSTALLER.UPGRADE_VERSION_NOT_FOUND
│
├─► if installed === target && !force
│ → INSTALLER.UPGRADE_SAME_VERSION (idempotent no-op)
│
├─► preflight
│ ├─ detectLockfile() → UPGRADE_LOCKFILE_MISSING
│ └─ detectDirtyTree() → UPGRADE_DIRTY_TREE (unless --allow-dirty)
│
├─► hashTree(targetDir) → currentHashes (rollback snapshot key)
│
├─► plan(answers, ctx) at fromVersion → fromOps
│ plan(answers, ctx) at toVersion → toOps
│
├─► computeDiff(fromOps, toOps)
│ { added, removed, changed, unchanged }
│
├─► materialiseDiff(diff) → FileOp[]
│ additions → write
│ removals → delete
│ changes → write (overwrite) | patch-json (merge)
│
├─► Host.applyOps(ops) ◄── EXISTING TRANSACTIONAL PATH
│ staged-tmp + atomic rename (INSTALLER-04)
│
├─► runMigrations(selected, ctx)
│ throws → rollback transaction → UPGRADE_MIGRATION_FAILED
│
├─► writeState({ pluginId, version: to, installedAt, fileHashes })
│ atomic write → fsync → rename
│
└─► OTel: span "installer.upgrade" closes with attributes
plugin.id, plugin.version.from, plugin.version.to, dry_run,
files.added, files.removed, files.changed
```
## Atomicity model
The upgrade is **one** transaction, not two. The diff-apply step and the
migration-run step share the same staged-tmp directory created by
`Host.applyOps`. The window is:
1. **Stage** — every `write`/`patch-*` op writes its content to
`<targetDir>/.tmp-<txn>/<path>`. Deletions are recorded as
"move-aside to `<targetDir>/.tmp-<txn>/.deleted/<path>`" — files are
**moved**, not removed, so rollback can restore them. No live files have
been mutated yet.
2. **Migrate** — `MigrationScript`s run against the staged tree (their
`MigrationContext.fs` is rooted at the tmp directory). They can mutate
staged files or emit additional `FileOp`s, which are appended to the
plan and re-staged into the same tmp directory.
3. **Commit** — once every stage step has succeeded, the host issues atomic
`rename(tmp, live)` for every staged path **in dependency order** (parent
dirs before children for `write`, children before parents for `delete`).
4. **Persist state** — only after every rename commits does the host write
the new `installer-state.json` (itself via the same write-fsync-rename
dance scoped to one file).
Any failure in steps 1–3 walks the journal in reverse: staged tmp files are
unlinked; moved-aside files are renamed back; the state-file is untouched.
The user's tree is bit-identical to the pre-upgrade state.
A rollback failure (e.g. fs returns EIO on the restore rename) surfaces as
`InstallerError(INSTALLER.ROLLBACK_FAILED, { cause: <original apply error> })`
exactly as `INSTALLER-04` already prescribes for the `add` flow.
## Snapshot / rollback strategy
The "snapshot" is not a copy of the tree — that would be linear in target
size and unacceptable for a skills bundle. Instead, the snapshot is:
1. **The pre-upgrade `hashTree`** — recorded before staging so the upgrade
has a content-addressed manifest of what existed.
2. **The move-aside journal** — every file the diff intends to delete or
overwrite is moved to `<tmp>/.deleted/<path>` (rename is O(1) on the
same filesystem). Disk usage during upgrade equals
`size(diff.changed ∪ diff.removed)` rather than `size(entire tree)`.
3. **The new staged content** — every `write` lands in `<tmp>/<path>` and
is `rename`d in only on commit.
Rollback walks the journal in reverse: rename moved-aside files back to
their live paths, unlink staged files, rmdir the tmp directory.
## Migration script contract
```ts
// In a plugin: src/upgrade.ts
import type { MigrationTable, MigrationScript } from "@suaveplan/installer/migrations";
const v1_to_v2: MigrationScript = async (ctx) => {
// ctx.fs is rooted at the staged tmp directory.
// ctx.signal honours --abort.
// ctx.logger is the host's structured logger.
// ctx.from = "1.0.0", ctx.to = "2.0.0"
const old = await ctx.fs.readFile("skills/legacy.json");
await ctx.fs.writeFile("skills/legacy.yaml", convert(old));
await ctx.fs.delete("skills/legacy.json");
};
export const upgradeTable: MigrationTable = {
"1.0.0->2.0.0": v1_to_v2,
};
```
Rules:
- Keys are `"<from>-><to>"` semver strings. The selector finds the shortest
path of contiguous edges from the installed version to the target.
- Scripts MUST be pure with respect to outside-the-tmp state. Network
access is permitted (e.g. fetching upstream renames) but discouraged.
- Scripts MAY throw. A throw rolls the transaction back and surfaces
`INSTALLER.UPGRADE_MIGRATION_FAILED` with `{ cause }`.
- Scripts MAY return additional `FileOp[]` that the host appends to the
plan and re-stages before commit.
## CLI behaviour
| Flag | Effect |
|---|---|
| `--dry-run` | Computes diff + migration list, prints the plan, exits before staging. |
| `--force` | Collapses conflict policies to `overwrite` AND bypasses the dirty-tree guard. Equivalent to `--allow-dirty` + per-file overwrite. |
| `--allow-dirty` | Bypasses dirty-tree guard but does NOT change conflict policies. Used when the operator knows their edits are reconcilable. |
| `--no-interactive` | Any prompt op fails with `INSTALLER.APPLY_CONFLICT` (existing semantics). |
| `--from <version>` | Overrides the version detected from the state file. Useful when the state file is missing or stale. |
| `--to <version>` | Overrides the version resolved from `node_modules`. Useful for pin-rollforward simulations. |
| `--answers <file>` | Same as `add` — JSON file supplying answers for any prompts the plan emits. |
## Idempotency
Two notions, both held:
1. **Same-version upgrade is a no-op.** `installed == target` exits with
`INSTALLER.UPGRADE_SAME_VERSION` without staging anything.
2. **Successful upgrade is replayable.** If the user runs the same upgrade
again, the state file already reflects `target`, so step 1 fires. There
is no way to "double-apply" a migration script.
## State file shape
```jsonc
{
"$schema": "installer-state/v1",
"plugins": {
"agent-skills": {
"version": "0.7.0",
"installedAt": "2026-05-17T12:34:56Z",
"targetDir": ".suaveplan/skills",
"fileHashes": {
"skills/crypto/SKILL.md": "blake3:...",
"skills/events/SKILL.md": "blake3:..."
}
}
}
}
```
The schema version (`$schema`) is bumped any time the shape changes; the
loader rejects unknown versions with `INSTALLER.UPGRADE_STATE_INVALID`.
## Coverage strategy
Two fixture plugins live under `src/testing/fixtures/`:
- `upgrade-fixture-plugin@1.0.0` — emits two files
(`a.txt`, `b.txt`) and one `patch-json` op against `data.json`.
- `upgrade-fixture-plugin@2.0.0` — drops `b.txt`, renames `a.txt` →
`a-renamed.txt`, adds `c.txt`, changes the `patch-json` merge, and ships
one migration script `1.0.0->2.0.0` that rewrites `data.json` schema.
Together they exercise every branch in `computeDiff`, every conflict policy
in the diff materialiser, both the migration-success and migration-throw
paths, and both the same-version-noop and version-not-found paths. 100%
istanbul falls out naturally.
## File layout
```text
packages/tooling/installer/src/
state/
state.types.ts
state.schemas.ts
state-file.ts
state-file.md
state-file.spec-conformance.test.ts
hash-tree.ts
hash-tree.md
host/
upgrade/
upgrade.ts
upgrade.md
upgrade.spec-conformance.test.ts
preflight.ts
preflight.md
preflight.spec-conformance.test.ts
dirty-tree.spec-conformance.test.ts
diff.ts
diff.md
apply-diff.spec-conformance.test.ts
migrations.ts
migrations.types.ts
migrations.md
migrations.spec-conformance.test.ts
index.ts
testing/
upgrade-plugin.ts
upgrade-plugin.md
fixtures/
upgrade-fixture-plugin.ts
upgrade-fixture-plugin.md
```
## Decisions and trade-offs
- **Move-aside vs full snapshot.** Move-aside wastes one rename per
affected file but is O(1) per file. A full snapshot would be safer
against concurrent writes but the installer already requires the user
to not be editing files concurrently — `INSTALLER-04` makes the same
trade-off for `add`. Consistency with `add` wins.
- **BLAKE3 over SHA-256.** `@suaveplan/crypto-hash` ships both. BLAKE3 is
~3× faster for the small-file case (skill files average <4 KB) and
parallelisable. No security requirement (this is a change-detection
hash, not a tamper-proof signature).
- **Migration scripts run AFTER diff stage, not before.** Running them
before would mean diffing against a tree the migration script has
already mutated, which makes the "did the user modify this file?"
question ambiguous. The order is: diff first (against the unmodified
staged tree), then migrate.
- **No network resolution.** Resolving "latest" from npm would couple the
installer to a registry shape, force network access in CI, and conflict
with the project's air-gapped use cases. The target version is
whatever resolves from `node_modules`; the operator bumps the dep
with their package manager first.

View File

@@ -0,0 +1,145 @@
# OpenSpec Change: add-installer-upgrade
- **Change ID**: add-installer-upgrade
- **Package**: `@suaveplan/installer`
- **Status**: PROPOSED
- **Date**: 2026-05-17
- **Design**: ./design.md
- **Spec delta**: ./specs/installer/spec.md
## Why
`openspec/archive/2026-04-28-installer-port/proposal.md` §"Out of scope" deferred
the upgrade flow:
> Plugin upgrade flow (`upgrade <plugin>` to bring an existing install up to
> current bundle version) is a follow-up — initial scope is install + reinstall
> (idempotent re-run with conflict policy).
The installer therefore ships `add <plugin>` and `list` but has no answer for a
user who installed `@suaveplan/install-agent-skills@0.5.0` six months ago and
wants the changes that landed in `0.7.0`. Today their only options are
`add agent-skills --force` (which overwrites local edits without diffing) or a
manual `rm -rf` (which destroys their in-flight skill edits). Both are unsafe.
The `add` path is also unable to run plugin-authored migration scripts, so any
breaking layout change in a plugin bundle (e.g. `skills/<id>/SKILL.md` →
`skills/<id>/<id>.md`) silently leaves stale files behind that the user has to
hand-prune.
## What Changes
Add a third top-level installer command, `suaveplan-install upgrade <plugin>`,
that resolves the **installed** plugin version from the on-disk state file,
resolves the **target** version from `node_modules`, computes the file-level
diff between the two bundles (added / removed / changed), and applies it
transactionally with the same staged-tmp + rollback strategy `apply` already
uses for `add`. Pre-flight gates protect against in-flight edits (dirty target
tree) and missing lockfiles. Plugins MAY ship an optional `upgrade.ts`
migration script per from-version → to-version pair; the host invokes them in
order inside the same transaction.
### Surface added
- `suaveplan-install upgrade <plugin>` CLI verb with flags
`--dry-run`, `--force`, `--no-interactive`, `--answers <file>`,
`--from <version>` (override detected), `--to <version>` (target; defaults
to whatever resolves from `node_modules`), `--allow-dirty` (skip dirty-tree
guard — destructive, opt-in only).
- `Host.upgrade(plugin, options)` public entry point alongside `Host.run`.
- A persisted state file (`.suaveplan/installer-state.json`) capturing
`{ pluginId, version, installedAt, fileHashes }` per installed plugin so
diff and dirty-tree detection are deterministic on subsequent upgrades.
- A `MigrationContext` and `MigrationScript` contract under
`@suaveplan/installer/migrations` for plugin-authored scripts.
- 7 new `INSTALLER.*` codes: `UPGRADE_NOT_INSTALLED`,
`UPGRADE_VERSION_NOT_FOUND`, `UPGRADE_SAME_VERSION`, `UPGRADE_DIRTY_TREE`,
`UPGRADE_STATE_INVALID`, `UPGRADE_MIGRATION_FAILED`,
`UPGRADE_LOCKFILE_MISSING`.
### Behaviour guarantees
- Re-running `upgrade <plugin>` when installed == target is a no-op exiting
with `INSTALLER.UPGRADE_SAME_VERSION` (treated as success under `--dry-run`).
- Failure at any point (diff compute, migration script throw, atomic rename
error) rolls the target tree back to the pre-upgrade snapshot. Partial
state is never persisted.
- The state file is updated atomically (`write → fsync → rename`) only after
the transaction commits.
## Source mapping
| Source / archive reference | What it tells us |
|---|---|
| `openspec/archive/2026-04-28-installer-port/proposal.md` §"Out of scope" bullet 2 | The deferred upgrade flow is the scope of this change. |
| `openspec/archive/2026-04-28-installer-port/proposal.md` §"Architecture → Lifecycle" | Reuse the existing `detect → prompts → plan → apply → postInstall` pipeline; upgrade adds a diff-and-migrate stage between `plan` and `apply`. |
| `openspec/specs/installer/spec.md` `INSTALLER-04` (transactional apply) | Upgrade MUST reuse the same staged-tmp + rollback machinery — no parallel transaction model. |
| `openspec/specs/installer/spec.md` `INSTALLER-05` (conflict policy) | Per-file conflict policy already exists; upgrade extends it with a "user-modified-since-install" case detected via the state-file hashes. |
| `openspec/specs/installer/spec.md` `INSTALLER-09` (frozen registry) | New codes MUST extend `defineCodes("INSTALLER", [...])` — no parallel registry. |
| `packages/tooling/installer/src/bin/suaveplan-install.ts` | Existing CLI shape: `cli().command("add <plugin>")…`. The new verb attaches identically: `.command("upgrade <plugin>")`. |
| `packages/tooling/installer/src/host/host.ts` | `Host` class is the orchestration seam; `Host.upgrade` slots in alongside `Host.run`. |
| `packages/tooling/installer/src/host/apply/apply.ts` | Staged-tmp transaction implementation is reused as-is; upgrade composes it. |
| `packages/tooling/installer/src/manifest/discover.ts` | Version resolution from `node_modules` already exists for `add`; upgrade reuses `findPlugin(cwd, pluginId)`. |
| `packages/tooling/installer/src/error-codes.ts` | New codes APPEND to the existing tuple; the registry stays frozen. |
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/installer` to enumerate. Current
dependents:
- `@suaveplan/install-agent-skills` — [x] tests run / [x] docs reviewed /
[ ] no action needed. The skills plugin is the canonical upgrade target;
its existing `runPlugin` harness gains an `upgradePlugin` sibling so its
conformance tests cover both flows. The plugin itself receives a tiny
optional `upgrade.ts` exporting an empty migration table (no breaking
layout changes yet); this proves the migration contract loads even when
the table is empty.
No other dependents currently consume `@suaveplan/installer`.
## Out of scope
- **Downgrade.** `upgrade` only moves forward. A separate
`add-installer-downgrade` change can extend the diff engine in reverse;
the migration contract is intentionally directional (`from`/`to` versions)
so downgrade is a different scope.
- **Cross-plugin coordination.** Upgrading `install-agent-skills` does not
trigger an upgrade of a hypothetical `install-web-server`. Each plugin is
upgraded independently; `peerInstallers` field warnings are a future
packaging change.
- **Background / scheduled upgrade daemon.** Upgrade is operator-initiated
via the CLI; there is no auto-update behaviour.
- **Network resolution.** The "target version" is whatever resolves from
`node_modules`; the installer never talks to npm directly. Bumping the
installed package version remains the user's package-manager step.
## Verification
Every requirement in `specs/installer/spec.md` carries a `**Verified by:**`
line pointing to a conformance test in `src/host/upgrade/` or
`src/state/`. The full set:
- `INSTALLER-UPGRADE-01` Lifecycle order → `src/host/upgrade/upgrade.spec-conformance.test.ts::"runs detect → resolve → diff → migrate → apply → postInstall in order"`
- `INSTALLER-UPGRADE-02` Idempotent same-version no-op → `src/host/upgrade/upgrade.spec-conformance.test.ts::"exits with UPGRADE_SAME_VERSION when installed equals target"`
- `INSTALLER-UPGRADE-03` Dirty-tree guard → `src/host/upgrade/dirty-tree.spec-conformance.test.ts::"throws UPGRADE_DIRTY_TREE when state-file hash diverges from disk"`
- `INSTALLER-UPGRADE-04` Lockfile pre-flight → `src/host/upgrade/preflight.spec-conformance.test.ts::"throws UPGRADE_LOCKFILE_MISSING when no bun.lockb / package-lock.json / pnpm-lock.yaml exists"`
- `INSTALLER-UPGRADE-05` Transactional diff application → `src/host/upgrade/apply-diff.spec-conformance.test.ts::"rolls back snapshot on staged-rename failure"`
- `INSTALLER-UPGRADE-06` Migration script contract → `src/host/upgrade/migrations.spec-conformance.test.ts::"runs upgrade.ts scripts in semver order between from and to"`
- `INSTALLER-UPGRADE-07` Migration failure rollback → `src/host/upgrade/migrations.spec-conformance.test.ts::"rolls back transaction and surfaces UPGRADE_MIGRATION_FAILED on script throw"`
- `INSTALLER-UPGRADE-08` State-file integrity → `src/state/state-file.spec-conformance.test.ts::"atomically writes installer-state.json only on successful commit"`
- `INSTALLER-UPGRADE-09` Error-code registry namespacing → `src/error-codes.spec-conformance.test.ts::"upgrade codes are frozen and namespaced INSTALLER.*"`
- `INSTALLER-UPGRADE-10` Version-not-found surface → `src/host/upgrade/upgrade.spec-conformance.test.ts::"throws UPGRADE_VERSION_NOT_FOUND when plugin not in node_modules"`
- `INSTALLER-UPGRADE-11` Not-installed surface → `src/host/upgrade/upgrade.spec-conformance.test.ts::"throws UPGRADE_NOT_INSTALLED when no state-file entry exists"`
- `INSTALLER-UPGRADE-12` CLI flag wiring → `src/bin/suaveplan-install.spec-conformance.test.ts::"upgrade verb forwards --dry-run, --force, --allow-dirty, --from, --to to Host.upgrade"`
## Acceptance criteria
- `bunx turbo lint typecheck test --filter=@suaveplan/installer` green.
- `bunx turbo test:coverage --filter=@suaveplan/installer` 100% istanbul on
every new module under `src/host/upgrade/`, `src/state/`, and the
upgrade-specific additions to `src/bin/suaveplan-install.ts`.
- Sibling `.md` for every new implementation `.ts` per `openspec/AGENTS.md` §4.
- `openspec validate add-installer-upgrade --strict` green.
- Spec delta folded into `openspec/specs/installer/spec.md` on archive.

View File

@@ -0,0 +1,278 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: installer (upgrade flow)
**Package**: `@suaveplan/installer`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
> Test files are planned paths inside `packages/tooling/installer/`.
## ADDED Requirements
### Requirement: Upgrade lifecycle order
`Host.upgrade(plugin, options)` SHALL execute upgrade stages in the strict
order `loadState → resolveTarget → preflight → hashTree → planFrom → planTo →
diff → materialise → applyOps → runMigrations → commitState`. Optional stages
(`detect`, `prompts`, `postInstall` from the install lifecycle) SHALL still be
honoured before `loadState` and after `commitState` respectively, preserving
plugin composability. Each stage SHALL be wrapped in an OpenTelemetry span
named `installer.upgrade.<stage>` whose parent span `installer.upgrade`
carries attributes `plugin.id`, `plugin.version.from`, `plugin.version.to`,
and `dry_run`.
#### Scenario: pipeline executes in the documented order
- **WHEN** `Host.upgrade(fixturePlugin, { from: "1.0.0", to: "2.0.0" })` runs
against an installed `1.0.0` fixture and a `2.0.0` bundle in `node_modules`
- **THEN** the OTel exporter records spans in the order `loadState`,
`resolveTarget`, `preflight`, `hashTree`, `planFrom`, `planTo`, `diff`,
`materialise`, `applyOps`, `runMigrations`, `commitState`, all parented to
one `installer.upgrade` span carrying `plugin.id`, `plugin.version.from`,
`plugin.version.to`, and `dry_run`.
**Verified by:** `src/host/upgrade/upgrade.spec-conformance.test.ts::"runs detect → resolve → diff → migrate → apply → postInstall in order"`
---
### Requirement: Same-version upgrade is idempotent
`Host.upgrade` SHALL short-circuit when the installed version recorded in
the state file equals the target version resolved from `node_modules`, and
surface `InstallerError(INSTALLER.UPGRADE_SAME_VERSION)`. Callers MAY treat
this code as a success indicator. Re-running the same upgrade after a
successful commit SHALL therefore always exit via this short-circuit. The
short-circuit SHALL NOT fire when `--force` was supplied.
#### Scenario: re-running upgrade after success exits with UPGRADE_SAME_VERSION
- **WHEN** `Host.upgrade` is invoked twice in succession against the same
fixture plugin with no `--force` flag
- **THEN** the first invocation MUST commit and write the state file, and
the second invocation MUST throw `InstallerError` with code
`INSTALLER.UPGRADE_SAME_VERSION` and MUST NOT stage any file
**Verified by:** `src/host/upgrade/upgrade.spec-conformance.test.ts::"exits with UPGRADE_SAME_VERSION when installed equals target"`
---
### Requirement: Dirty-tree pre-flight guard
Before staging any file op, `Host.upgrade` SHALL hash every path recorded in
the state file's `fileHashes` and compare to the recorded value. Any
mismatched hash SHALL surface as
`InstallerError(INSTALLER.UPGRADE_DIRTY_TREE, { context: { paths: string[] } })`
unless `--allow-dirty` or `--force` was supplied. The error context SHALL
enumerate every divergent path so the operator can reconcile manually.
#### Scenario: divergent on-disk hash blocks the upgrade
- **GIVEN** a state file recording `skills/crypto/SKILL.md` at hash `H1`
- **WHEN** the file on disk hashes to `H2` and `Host.upgrade` runs without
`--allow-dirty` or `--force`
- **THEN** an `InstallerError` with code `INSTALLER.UPGRADE_DIRTY_TREE` is
thrown whose `context.paths` includes `skills/crypto/SKILL.md`
**Verified by:** `src/host/upgrade/dirty-tree.spec-conformance.test.ts::"throws UPGRADE_DIRTY_TREE when state-file hash diverges from disk"`
---
### Requirement: Lockfile presence is required
`Host.upgrade` SHALL refuse to run unless at least one of
`bun.lockb`, `bun.lock`, `package-lock.json`, `pnpm-lock.yaml`, or
`yarn.lock` is present in `cwd`. Missing lockfile SHALL surface as
`InstallerError(INSTALLER.UPGRADE_LOCKFILE_MISSING)`. This gate exists to
prevent upgrades against a tree whose dependency graph is unpinned, where
the resolved target version could shift between runs.
#### Scenario: missing lockfile blocks the upgrade
- **WHEN** `Host.upgrade` runs in a `cwd` with no lockfile of any supported
format
- **THEN** an `InstallerError` with code `INSTALLER.UPGRADE_LOCKFILE_MISSING`
is thrown before any file is hashed
**Verified by:** `src/host/upgrade/preflight.spec-conformance.test.ts::"throws UPGRADE_LOCKFILE_MISSING when no bun.lockb / package-lock.json / pnpm-lock.yaml exists"`
---
### Requirement: Transactional diff application with rollback
`Host.upgrade` SHALL materialise the computed diff (`added`, `removed`,
`changed`) into the existing `FileOp` union and feed it through
`Host.applyOps`, reusing the staged-tmp + atomic-rename transaction
described in the canonical `INSTALLER-04` requirement. Removed files SHALL
be moved aside into `<tmp>/.deleted/` rather than unlinked, so rollback can
restore them. Any failure during staging or commit SHALL trigger a
reverse-order rollback that unlinks staged files and renames the
moved-aside files back into place. Rollback failures SHALL surface as
`InstallerError(INSTALLER.ROLLBACK_FAILED, { cause })`.
#### Scenario: rename failure rolls the tree back
- **GIVEN** a fixture plugin whose final `rename` of `a-renamed.txt` is
injected to throw EIO
- **WHEN** `Host.upgrade` runs against an installed `1.0.0` tree containing
`a.txt`
- **THEN** `a.txt` exists at its original content after the throw and
`a-renamed.txt` does not exist, and the throw surfaces as `InstallerError`
with code `INSTALLER.APPLY_FAILED` (or `INSTALLER.ROLLBACK_FAILED` if the
restore also fails) carrying the EIO as `cause`
**Verified by:** `src/host/upgrade/apply-diff.spec-conformance.test.ts::"rolls back snapshot on staged-rename failure"`
---
### Requirement: Migration scripts run in semver order
`Host.upgrade` SHALL, when a plugin ships an `upgrade.ts` exporting a
`MigrationTable`, load the table, select the shortest contiguous chain
of edges from `from` to `to`, and execute each `MigrationScript` in order
inside the same transaction as the diff apply. Each script SHALL receive a
`MigrationContext` carrying the same `fs` (rooted at the staged tmp dir),
the same `AbortSignal`, the host `logger`, and the `from`/`to` semver
strings for the current edge.
#### Scenario: chained migrations execute from-to-target in order
- **GIVEN** a plugin with edges `1.0.0->1.1.0` and `1.1.0->2.0.0`
- **WHEN** `Host.upgrade` runs from `1.0.0` to `2.0.0`
- **THEN** both scripts MUST run, the `1.0.0->1.1.0` script MUST run
first, both MUST receive an `fs` rooted at the same staged tmp
directory, and both MUST observe the same `AbortSignal` instance
**Verified by:** `src/host/upgrade/migrations.spec-conformance.test.ts::"runs upgrade.ts scripts in semver order between from and to"`
---
### Requirement: Migration failure rolls back the transaction
A `MigrationScript` throw SHALL roll back every staged op (including any
ops emitted by earlier migration scripts in the same chain) and SHALL
surface as `InstallerError(INSTALLER.UPGRADE_MIGRATION_FAILED, { cause })`.
The state file SHALL NOT be written when a migration throws. Re-running
the upgrade after a migration failure SHALL therefore see the original
installed version in the state file and start the chain from the
beginning.
#### Scenario: throwing migration leaves tree at pre-upgrade state
- **GIVEN** a plugin whose `1.0.0->2.0.0` migration throws on invocation
- **WHEN** `Host.upgrade` runs from `1.0.0` to `2.0.0`
- **THEN** the throw surfaces as `InstallerError` with code
`INSTALLER.UPGRADE_MIGRATION_FAILED`, the live tree is bit-identical to
its pre-upgrade content, and the state file still records version
`1.0.0`
**Verified by:** `src/host/upgrade/migrations.spec-conformance.test.ts::"rolls back transaction and surfaces UPGRADE_MIGRATION_FAILED on script throw"`
---
### Requirement: State file is written atomically on commit only
`Host.upgrade` SHALL persist the new `installer-state.json` entry only
after every diff op and migration script has committed successfully. The
write SHALL use the write-fsync-rename pattern: serialise to
`<cwd>/.suaveplan/installer-state.json.tmp-<txn>`, `fsync` the descriptor,
then `rename` over the live path. Reads SHALL Zod-validate the loaded
state; validation failures SHALL surface as
`InstallerError(INSTALLER.UPGRADE_STATE_INVALID, { cause })`.
#### Scenario: state file appears only after successful commit
- **WHEN** `Host.upgrade` aborts mid-apply (any failure)
- **THEN** the file at `.suaveplan/installer-state.json` is unchanged from
its pre-upgrade content (either still recording the old version or
still absent), and no `.tmp-*` sibling remains on disk
**Verified by:** `src/state/state-file.spec-conformance.test.ts::"atomically writes installer-state.json only on successful commit"`
---
### Requirement: Upgrade error codes are namespaced and frozen
The `INSTALLER_CODES` registry SHALL include `UPGRADE_NOT_INSTALLED`,
`UPGRADE_VERSION_NOT_FOUND`, `UPGRADE_SAME_VERSION`, `UPGRADE_DIRTY_TREE`,
`UPGRADE_STATE_INVALID`, `UPGRADE_MIGRATION_FAILED`, and
`UPGRADE_LOCKFILE_MISSING` in addition to the existing nine codes. The
registry SHALL remain frozen (`Object.isFrozen(INSTALLER_CODES) === true`).
Every new value SHALL start with `INSTALLER.`. `InstallerError` SHALL
remain the only error class thrown by the upgrade path.
#### Scenario: registry contains the upgrade codes and stays frozen
- **WHEN** the registry is inspected at module-load time
- **THEN** `INSTALLER_CODES.UPGRADE_NOT_INSTALLED` equals
`"INSTALLER.UPGRADE_NOT_INSTALLED"`, every other upgrade code is
present with the same `INSTALLER.` prefix, and
`Object.isFrozen(INSTALLER_CODES)` returns `true`
**Verified by:** `src/error-codes.spec-conformance.test.ts::"upgrade codes are frozen and namespaced INSTALLER.*"`
---
### Requirement: Missing target version surfaces a typed error
`Host.upgrade` SHALL surface
`InstallerError(INSTALLER.UPGRADE_VERSION_NOT_FOUND, { context: { pluginId, requested } })`
when the requested target version (whether supplied via `--to` or resolved
from `node_modules`) does not exist.
The error SHALL be thrown before any pre-flight check runs so callers can
distinguish "plugin not installed at target" from "plugin tree dirty".
#### Scenario: requesting an unresolvable target version fails early
- **GIVEN** an installed plugin at `1.0.0` and `node_modules` containing
only `1.0.0`
- **WHEN** `Host.upgrade(plugin, { to: "9.9.9" })` runs
- **THEN** an `InstallerError` with code
`INSTALLER.UPGRADE_VERSION_NOT_FOUND` MUST be thrown and the
`context.requested` field MUST equal `"9.9.9"`
**Verified by:** `src/host/upgrade/upgrade.spec-conformance.test.ts::"throws UPGRADE_VERSION_NOT_FOUND when plugin not in node_modules"`
---
### Requirement: Upgrading an uninstalled plugin surfaces a typed error
`Host.upgrade` SHALL surface
`InstallerError(INSTALLER.UPGRADE_NOT_INSTALLED, { context: { pluginId } })`
when no entry for the requested `pluginId` exists in the state file.
The error SHALL be thrown before any version resolution runs so the
operator gets the most specific guidance ("run `add` first") without
network or filesystem cost.
#### Scenario: upgrade against empty state file fails fast
- **GIVEN** an empty `.suaveplan/installer-state.json` (no `plugins` entry
for the requested id)
- **WHEN** `Host.upgrade(plugin)` runs
- **THEN** an `InstallerError` with code
`INSTALLER.UPGRADE_NOT_INSTALLED` MUST be thrown before
`resolveTargetVersion` is called
**Verified by:** `src/host/upgrade/upgrade.spec-conformance.test.ts::"throws UPGRADE_NOT_INSTALLED when no state-file entry exists"`
---
### Requirement: CLI verb forwards every upgrade flag
`suaveplan-install upgrade <plugin>` SHALL parse and forward the flags
`--dry-run`, `--force`, `--no-interactive`, `--answers <file>`,
`--from <version>`, `--to <version>`, and `--allow-dirty` to
`Host.upgrade`. The verb SHALL share the same `InstallerContext`
construction path as `add`, so the underlying `fs`, `logger`, `cli`,
`templates`, and `signal` are identical. `--dry-run` SHALL print the
computed diff (counts of added/removed/changed plus the migration chain)
and exit with code 0 before any staging occurs.
#### Scenario: each upgrade flag reaches Host.upgrade
- **WHEN** `main(["upgrade", "agent-skills", "--dry-run", "--force", "--allow-dirty", "--from", "1.0.0", "--to", "2.0.0", "--answers", "a.json"])`
runs with a recording `Host.upgrade` stub
- **THEN** the stub records one call whose options object contains
`{ dryRun: true, force: true, allowDirty: true, from: "1.0.0", to: "2.0.0", answers: <parsed JSON> }`
**Verified by:** `src/bin/suaveplan-install.spec-conformance.test.ts::"upgrade verb forwards --dry-run, --force, --allow-dirty, --from, --to to Host.upgrade"`

View File

@@ -0,0 +1,134 @@
# Tasks: add-installer-upgrade
## 1. Error codes
- [ ] 1.1 Append `UPGRADE_NOT_INSTALLED`, `UPGRADE_VERSION_NOT_FOUND`,
`UPGRADE_SAME_VERSION`, `UPGRADE_DIRTY_TREE`, `UPGRADE_STATE_INVALID`,
`UPGRADE_MIGRATION_FAILED`, `UPGRADE_LOCKFILE_MISSING` to the
`defineCodes("INSTALLER", [...])` tuple in
`packages/tooling/installer/src/error-codes.ts`.
- [ ] 1.2 Extend `src/error-codes.spec-conformance.test.ts` with an
`INSTALLER-UPGRADE-09` block asserting the codes are present, frozen,
and prefixed `INSTALLER.`.
## 2. State file (`src/state/`)
- [ ] 2.1 `src/state/state.types.ts` — `InstallerState`, `InstalledPlugin`,
`FileHash` types.
- [ ] 2.2 `src/state/state.schemas.ts` — Zod schemas for boundary validation.
- [ ] 2.3 `src/state/state-file.ts` — `loadState(fs, cwd)`,
`writeState(fs, cwd, state)` (atomic write → fsync → rename),
`updatePlugin(state, entry)`. Failure → `INSTALLER.UPGRADE_STATE_INVALID`.
- [ ] 2.4 `src/state/hash-tree.ts` — `hashTree(fs, dir)` → `Record<path, hex>`
using BLAKE3 via `@suaveplan/crypto-hash`.
- [ ] 2.5 `src/state/state-file.spec-conformance.test.ts` —
`INSTALLER-UPGRADE-08`.
- [ ] 2.6 `.md` siblings for every implementation file.
## 3. Pre-flight checks (`src/host/upgrade/preflight.ts`)
- [ ] 3.1 `detectLockfile(fs, cwd)` returning `"bun" | "npm" | "pnpm" | "yarn"`
or throwing `INSTALLER.UPGRADE_LOCKFILE_MISSING`.
- [ ] 3.2 `detectDirtyTree(state, currentHashes)` returning the list of
user-modified paths. Throws `INSTALLER.UPGRADE_DIRTY_TREE` unless
`--allow-dirty` or `--force` was supplied.
- [ ] 3.3 `src/host/upgrade/preflight.spec-conformance.test.ts` —
`INSTALLER-UPGRADE-04`.
- [ ] 3.4 `src/host/upgrade/dirty-tree.spec-conformance.test.ts` —
`INSTALLER-UPGRADE-03`.
## 4. Diff engine (`src/host/upgrade/diff.ts`)
- [ ] 4.1 `computeDiff(fromOps, toOps)` returning
`{ added: FileOp[], removed: FileOp[], changed: FileOp[], unchanged: FileOp[] }`.
- [ ] 4.2 `materialiseDiff(diff, options)` translating the diff into the
existing `FileOp` union: removals → `{ kind: "delete" }`, additions →
`{ kind: "write" }`, changes → `{ kind: "write", conflict: "overwrite" }`
(or `merge` for `patch-json` ops).
- [ ] 4.3 Sibling `diff.md` covering algorithm and tie-breaking rules.
## 5. Migration runner (`src/host/upgrade/migrations.ts`)
- [ ] 5.1 `MigrationScript`, `MigrationContext`, `MigrationTable` types in
`src/host/upgrade/migrations.types.ts`.
- [ ] 5.2 `loadMigrationTable(plugin)` dynamic-imports the plugin's
`./upgrade.js` if declared in the manifest; absent table → no-op.
- [ ] 5.3 `selectMigrations(table, from, to)` returns the ordered semver path.
- [ ] 5.4 `runMigrations(selected, ctx)` runs each script inside an
`AbortSignal`-propagating sandbox. Throw → `INSTALLER.UPGRADE_MIGRATION_FAILED`.
- [ ] 5.5 `src/host/upgrade/migrations.spec-conformance.test.ts` —
`INSTALLER-UPGRADE-06`, `INSTALLER-UPGRADE-07`.
## 6. Upgrade orchestration (`src/host/upgrade/upgrade.ts`)
- [ ] 6.1 `Host.upgrade(plugin, options)` method:
1. Load state-file entry; throw `UPGRADE_NOT_INSTALLED` if absent.
2. Resolve target version; throw `UPGRADE_VERSION_NOT_FOUND` if
`node_modules` resolution fails.
3. Short-circuit with `UPGRADE_SAME_VERSION` when installed === target
and not `--force`.
4. Run preflight (lockfile + dirty-tree).
5. Hash current tree; compute snapshot for rollback.
6. Run `plugin.plan` for both versions (or load cached bundle manifests).
7. `computeDiff` → `materialiseDiff` → `Host.applyOps` (existing
transactional path).
8. Run migrations between the same staged-tmp transaction window.
9. On success, `writeState` atomically.
- [ ] 6.2 OTel span `installer.upgrade` wrapping the whole flow with
`plugin.id`, `plugin.version.from`, `plugin.version.to`, `dry_run`.
- [ ] 6.3 `src/host/upgrade/upgrade.spec-conformance.test.ts` —
`INSTALLER-UPGRADE-01`, `INSTALLER-UPGRADE-02`, `INSTALLER-UPGRADE-10`,
`INSTALLER-UPGRADE-11`.
- [ ] 6.4 `src/host/upgrade/apply-diff.spec-conformance.test.ts` —
`INSTALLER-UPGRADE-05`.
## 7. CLI wiring (`src/bin/suaveplan-install.ts`)
- [ ] 7.1 Add `app.command("upgrade <plugin>")` with flags `--dry-run`,
`--force`, `--no-interactive`, `--answers <file>`, `--from <version>`,
`--to <version>`, `--allow-dirty`.
- [ ] 7.2 `runUpgrade(pluginId, flags)` mirrors `runAdd` but calls
`host.upgrade(...)`.
- [ ] 7.3 `src/bin/suaveplan-install.spec-conformance.test.ts` —
`INSTALLER-UPGRADE-12`.
## 8. Testing helpers (`src/testing/`)
- [ ] 8.1 `src/testing/upgrade-plugin.ts` — `upgradePlugin(plugin, { fromState, toBundle, answers? })`
mirroring the existing `runPlugin` harness for upgrade tests.
- [ ] 8.2 `src/testing/fixtures/upgrade-fixture-plugin.ts` — two-version
fixture plugin (`1.0.0` + `2.0.0`) with one migration script, used by
every upgrade conformance test for end-to-end coverage at 100%.
## 9. Docs
- [ ] 9.1 Sibling `.md` for every new implementation `.ts` per
`openspec/AGENTS.md` §4 (Purpose, Features, Basic, Advanced, API, Notes).
- [ ] 9.2 Update root `packages/tooling/installer/README.md` Quickstart to
include the `upgrade` verb and link the new module docs.
## 10. Quality gates
- [ ] 10.1 `bunx turbo lint typecheck test --filter=@suaveplan/installer` green.
- [ ] 10.2 `bunx turbo test:coverage --filter=@suaveplan/installer` 100%
istanbul on every new module.
- [ ] 10.3 No banned patterns (`openspec/AGENTS.md` §2) in new files.
- [ ] 10.4 No `biome-ignore` in new files (`openspec/AGENTS.md` §2a).
- [ ] 10.5 File-size check: every `src/host/upgrade/*.ts` ≤ 300 lines,
every `*.test.ts` ≤ 500 lines.
- [ ] 10.6 Sibling `.md` audit returns zero gaps for new files.
## 11. Dependent sweep
- [ ] 11.1 `bunx turbo test --filter=...@suaveplan/installer` green
(validates `@suaveplan/install-agent-skills` against the new surface).
- [ ] 11.2 Add empty `upgrade.ts` migration table export to
`@suaveplan/install-agent-skills` and wire it through its manifest so
the migration-loader path is exercised even with zero migrations.
## 12. Archive
- [ ] 12.1 Fold `specs/installer/spec.md` delta into
`openspec/specs/installer/spec.md`.
- [ ] 12.2 Move `openspec/changes/add-installer-upgrade/` to
`openspec/archive/<yyyy-mm-dd>-add-installer-upgrade/`.

View File

@@ -0,0 +1,129 @@
# Design — `usePasskeyAutofill` (`@suaveplan/react-auth`)
Non-normative. Architecture, lifecycle, and rationale. The normative contract lives in `specs/react-auth/spec.md`.
## Package shape
`@suaveplan/react-auth` is created by this change. It is a lightweight browser-tier package at `packages/auth/react-auth/`. Tier is `browser` (React 19 peer dep, DOM types fine). It holds exactly this hook and the optional `<PasskeyAutofillField>` wrapper today; future auth-related React hooks land in follow-up changes under the same package.
The hook lives at `src/use-passkey-autofill/use-passkey-autofill.ts`; the component at `src/passkey-autofill-field/passkey-autofill-field.tsx`; errors at `src/errors/errors.ts`. Each has a sibling `.md`, a `.test.ts` (or `.test.tsx`), and stories per CLAUDE.md.
## Conditional UI background
WebAuthn Level 3 §5.1.4 defines `mediation: "conditional"`. Browsers that support it surface available platform credentials in the autocomplete sheet of any `<input>` with `autocomplete="username webauthn"`. The feature is gated on `PublicKeyCredential.isConditionalMediationAvailable()` — a static method that does not exist on Safari < 17, Firefox < 119, or any SSR environment. Calling `navigator.credentials.get({ publicKey, mediation: "conditional" })` without that check throws `NotSupportedError` on older browsers, which `@suaveplan/webauthn` maps to `WEBAUTHN.NOT_SUPPORTED`.
The hook MUST therefore feature-detect before dispatching. Because feature detection is async and DOM-only, it can only run inside a `useEffect`.
## Hook signature
```ts
interface UsePasskeyAutofillOptions {
rpId: string;
challenge: Uint8Array;
onCredential: (result: AuthenticateResult) => void;
onError?: (error: SuaveplanError) => void;
signal?: AbortSignal;
allowCredentials?: PublicKeyCredentialDescriptor[];
userVerification?: UserVerificationRequirement;
timeout?: number;
expectedOrigin?: string;
expectedRpIdHash?: Uint8Array;
}
interface UsePasskeyAutofillReturn {
ready: boolean;
error: SuaveplanError | null;
abort: () => void;
}
function usePasskeyAutofill(opts: UsePasskeyAutofillOptions): UsePasskeyAutofillReturn;
```
`ready` flips to `true` after `isConditionalMediationAvailable()` resolves `true` AND the conditional `authenticate` Promise is in-flight. `error` carries the most recent translated error (cleared when a new attempt starts). `abort` is a stable `useCallback` that fires the internal `AbortController`.
## Lifecycle — AbortController ownership
The hook owns exactly one `AbortController` per "session" (one mount, one option-identity). The controller is composed with the caller's `options.signal` via a small `composeSignals(...signals)` helper (any aborts → composed aborts).
| Event | Behavior |
|---|---|
| Mount (client) | Run feature detect → dispatch `authenticate({ mediation: "conditional", signal: composed })`. |
| Mount (server) | No-op. The `useEffect` never runs. |
| Unmount | Call `controller.abort()`. The underlying `navigator.credentials.get` rejects with `AbortError`, mapped to `WEBAUTHN.ABORTED` → `REACT_AUTH.ABORTED`. |
| Caller `signal` aborts | Composed controller aborts → same as unmount path. |
| Caller calls returned `abort()` | Same as above. State remains mounted; consumer may re-trigger by changing option identity. |
| `<input>` blur (via `<PasskeyAutofillField>`) | Consumer-controlled. The component calls `abort()` on `onBlur` IF no credential has been delivered yet. The hook alone does not subscribe to blur (it does not touch the DOM). |
| Option identity changes (e.g. fresh `challenge`) | Effect re-runs: old controller aborts, new controller created, new request dispatched. |
| `onCredential` fires | Controller is left intact (the request is already settled); `error` cleared. |
| `onError` fires | Controller aborted; `error` stored; consumer may remount or change challenge to retry. |
The hook MUST NOT touch the DOM directly — it works alongside an `<input autocomplete="username webauthn">` rendered by the consumer. The browser surfaces the autofill UI based on the autocomplete token; the hook just keeps the conditional `authenticate` request pending so the browser has something to deliver into.
## SSR safety
- `typeof window === "undefined"` short-circuits before any `navigator` access.
- `isConditionalMediationAvailable()` is called inside `useEffect` (which never runs server-side under React 19 SSR).
- The hook returns `{ ready: false, error: null, abort: noop }` on the server render, then re-renders on hydration.
## Error translation
```text
WEBAUTHN.NOT_SUPPORTED → REACT_AUTH.AUTOFILL_UNAVAILABLE
WEBAUTHN.ABORTED → REACT_AUTH.ABORTED
all other WEBAUTHN.* → REACT_AUTH.AUTOFILL_FAILED (cause-chained)
Zod schema rejection at entry → REACT_AUTH.INVALID_OPTIONS (thrown synchronously)
SSR / non-DOM environment → REACT_AUTH.SSR_NO_OP (surfaced via onError only)
```
Every translation preserves the original `SuaveplanError` via `cause`, so consumers can introspect `error.cause?.code` for the underlying `WEBAUTHN.*` code.
## Testing approach
- Real `navigator.credentials.get` stub at the jsdom layer — install a fake `PublicKeyCredential` with `isConditionalMediationAvailable` and a `navigator.credentials.get` that resolves a fixture `AuthenticateResult` or rejects with a chosen `DOMException`. Tests live in `src/use-passkey-autofill/use-passkey-autofill.test.tsx` using `@suaveplan/testing-dom` render utilities and `@suaveplan/testing/runner` for the runner-neutral imports (CLAUDE.md rule 11).
- For environments lacking `PublicKeyCredential` entirely (deliberately stripped), assert `REACT_AUTH.AUTOFILL_UNAVAILABLE` is reported via `onError` and `ready` stays `false`.
- Lifecycle is verified by mounting, asserting an in-flight request, unmounting, and asserting the request rejected with `REACT_AUTH.ABORTED`.
- 100% coverage per CLAUDE.md / openspec rule 7 — no carve-outs.
## File layout
```text
packages/auth/react-auth/
README.md
package.json
tsconfig.json
vitest.config.ts
src/
index.ts # explicit named re-exports (rule 6a)
use-passkey-autofill/
index.ts
use-passkey-autofill.ts
use-passkey-autofill.md
use-passkey-autofill.test.tsx
use-passkey-autofill.stories.tsx
use-passkey-autofill.types.ts
use-passkey-autofill.schemas.ts
passkey-autofill-field/
index.ts
passkey-autofill-field.tsx
passkey-autofill-field.md
passkey-autofill-field.test.tsx
passkey-autofill-field.stories.tsx
passkey-autofill-field.types.ts
errors/
index.ts
errors.ts
errors.md
errors.test.ts
utils/
compose-signals.ts
compose-signals.md
compose-signals.test.ts
```
## Decisions
- **Hook-first, component-optional.** The `<PasskeyAutofillField>` wrapper exists for the common case but is never a requirement; advanced consumers wire the hook to their own input.
- **No global state, no context provider.** Multiple `usePasskeyAutofill` instances may coexist (e.g. a multi-tenant login page); each owns its own controller.
- **Errors are translated, not leaked.** Consumers see `REACT_AUTH.*` codes. The underlying `WEBAUTHN.*` cause is preserved on `error.cause` for introspection.
- **`onCredential` is required, `onError` is optional.** A consumer that does not pass `onError` accepts that errors silently no-op (with `error` still populated on the return value).
- **Stable `abort` reference.** Returned via `useCallback` so consumers can put it in dependency arrays without thrashing.

View File

@@ -0,0 +1,85 @@
# `@suaveplan/react-auth` — `usePasskeyAutofill` React hook
**Package**: `@suaveplan/react-auth` (NEW)
**Status**: PROPOSED
**Type**: new-package
**Date**: 2026-05-17
- **Design**: ./design.md
- **Spec delta**: ./specs/react-auth/spec.md
## Why
The archived `enterprise-webauthn` change deferred a React hook target named `usePasskeyAutofill` to a follow-up `@suaveplan/react-auth` change (see `openspec/archive/2026-05-11-enterprise-webauthn/proposal.md` lines 85-86, 117, 191 and `tasks.md` §2.7.3 / §0 "Out of scope"). The underlying capability — `authenticate({ mediation: "conditional" })` plus `isConditionalMediationAvailable()` — shipped on `@suaveplan/webauthn` and is exported from `packages/auth/webauthn/src/authenticate/authenticate.ts` and `packages/auth/webauthn/src/index.ts`, so the consumer-side hook is now unblocked.
Without this hook, every genesis React app that wants WebAuthn Level 3 Conditional UI ("passkey autofill" — the browser surfaces credentials in the username `<input>` autocomplete sheet) has to hand-roll the same wiring: SSR guard, feature detect, `AbortController` lifecycle, unmount cancellation, blur cancellation, and error-code translation. Hand-rolled versions get the abort lifecycle wrong (they leak credential requests across mounts) or skip the `isConditionalMediationAvailable()` check (which throws on Safari < 17 and every SSR environment). Shipping the hook once, in a dedicated package, removes that footgun.
`@suaveplan/react-auth` does not exist yet. This change creates it as a lightweight browser-tier package with a single React 19 peer dep, exporting the hook and a tiny optional `<PasskeyAutofillField>` wrapper that renders an `<input autocomplete="username webauthn">` with the hook pre-wired. The package is deliberately scoped to the autofill surface only; future auth-related React hooks (passkey-as-master-key-unlock, OAuth callback, magic-link landing) land in follow-up changes under the same package.
## What changes
### New package: `@suaveplan/react-auth`
Browser-tier package at `packages/auth/react-auth/`. React 19 peer dep, `@suaveplan/webauthn` workspace dep.
#### Hook
- ADDED: `usePasskeyAutofill(options: UsePasskeyAutofillOptions): UsePasskeyAutofillReturn` — declarative hook around `authenticate({ mediation: "conditional" })`. Owns its own `AbortController`; aborts on unmount, on a fresh option-change remount, and (optionally) on the consumer-supplied `<input>`'s `blur` when no credential has been delivered.
- ADDED: `UsePasskeyAutofillOptions` — `{ rpId: string; challenge: Uint8Array; onCredential: (result: AuthenticateResult) => void; onError?: (error: SuaveplanError) => void; signal?: AbortSignal; allowCredentials?: PublicKeyCredentialDescriptor[]; userVerification?: UserVerificationRequirement; timeout?: number; expectedOrigin?: string; expectedRpIdHash?: Uint8Array }`. All option fields are surfaced as a named interface (per CLAUDE.md rule 11 — no inline shapes).
- ADDED: `UsePasskeyAutofillReturn` — `{ ready: boolean; error: SuaveplanError | null; abort: () => void }`. `ready` is `true` once `isConditionalMediationAvailable()` resolves `true` AND the conditional `authenticate` call has been dispatched. `error` is the most recent translated error or `null`. `abort()` is a stable callback that cancels the in-flight request without unmounting the component.
#### Optional component wrapper
- ADDED: `<PasskeyAutofillField>` — a thin presentational `<input autocomplete="username webauthn">` wrapper that wires the hook to the input and forwards `onBlur` to the hook's `abort`. Accepts `inputProps` so the consumer can style and label the input freely. The component is opt-in: callers may instead use the hook directly with their own `<input>` markup.
- ADDED: `PasskeyAutofillFieldProps` — named interface for the component's prop shape.
#### Errors / observability / boundaries
- ADDED: `defineCodes("REACT_AUTH", [...])` registry covering `REACT_AUTH.AUTOFILL_UNAVAILABLE` (browser missing `isConditionalMediationAvailable`), `REACT_AUTH.AUTOFILL_FAILED` (underlying `WEBAUTHN.*` cause), `REACT_AUTH.ABORTED` (caller-driven abort), `REACT_AUTH.SSR_NO_OP` (hook ran on server — reported via `onError` only when `onError` is supplied, otherwise silently no-ops), `REACT_AUTH.INVALID_OPTIONS` (Zod boundary rejection of `UsePasskeyAutofillOptions`). Every code is thrown by exactly one production path.
- ADDED: `UsePasskeyAutofillOptionsSchema` — Zod schema validated at hook entry; mismatches throw `REACT_AUTH.INVALID_OPTIONS` synchronously (so React surfaces it via the nearest error boundary at mount time, not silently in an effect).
- ADDED: `AbortSignal` chaining — caller-supplied `options.signal` is composed with the hook's internal controller so either source aborts the underlying `navigator.credentials.get`. Internal controller fires on unmount and on `abort()`.
#### SSR + feature detection
- ADDED: `useEffect`-gated entry point — the hook performs no work on the server render. On the first client mount it calls `PublicKeyCredential.isConditionalMediationAvailable?.()` via `@suaveplan/webauthn`'s `isConditionalMediationAvailable` helper. If the static method is missing or returns `false`, the hook stays `ready === false`, emits `REACT_AUTH.AUTOFILL_UNAVAILABLE` via `onError` (if supplied), and never dispatches `authenticate`.
- ADDED: stable `abort` reference — returned `abort` is wrapped with `useCallback` and a controller ref, so consumers can put it in `useEffect` cleanup arrays without re-running the effect on every render.
#### Storybook
- ADDED: `src/use-passkey-autofill/use-passkey-autofill.stories.tsx` — default story (hook wired into a dummy form), playground story (props as controls), variant stories (unavailable browser, abort-on-blur, error-state). Per CLAUDE.md "Every React component needs `.stories.tsx`".
- ADDED: `src/passkey-autofill-field/passkey-autofill-field.stories.tsx` — default / playground / variants.
### Subpath exports
- `.` — root barrel: `usePasskeyAutofill`, `UsePasskeyAutofillOptions`, `UsePasskeyAutofillReturn`, `PasskeyAutofillField`, `PasskeyAutofillFieldProps`, error registry.
- `./use-passkey-autofill/use-passkey-autofill` — hook + types.
- `./passkey-autofill-field/passkey-autofill-field` — component + props.
- `./errors` — `REACT_AUTH` code registry.
### Dependencies
- `@suaveplan/webauthn workspace:^` — `authenticate`, `isConditionalMediationAvailable`, `AuthenticateResult`.
- `@suaveplan/error workspace:^` — `defineCodes`, `SuaveplanError`.
- `@suaveplan/types workspace:^` — shared option bundles.
- `react ^19.0.0` (peer) — hook surface.
- `zod catalog:forms` — boundary validation.
- `tslib catalog:build`.
## Out of scope
- **Server-side challenge issuance / verification** — `@suaveplan/api-schemas/auth` and `@suaveplan/vault` own that. The hook expects the caller to have already obtained `{ rpId, challenge }` from the server.
- **Non-autofill WebAuthn React hooks** — `usePasskeyRegister`, `usePasskeyAuthenticate` (modal), `useVaultUnlock` land in follow-up changes under `@suaveplan/react-auth`.
- **WebAuthn Level 2 polyfill** — Conditional UI is L3-only; older browsers surface `REACT_AUTH.AUTOFILL_UNAVAILABLE` and the hook no-ops.
- **DOM manipulation** — the hook does not render or query DOM elements directly. The consumer renders the `<input autocomplete="username webauthn">`. The optional `<PasskeyAutofillField>` is a thin convenience wrapper, not a requirement.
## Impact
### Affected dependents
This is a NEW package — no existing dependents. Run `bunx turbo ls --filter=...@suaveplan/react-auth` after landing to confirm. The hook will be adopted by `@suaveplan/web-app` and any genesis-original app that ships a login page, in their own follow-up changes.
- No dependents (verified by `bunx turbo ls --filter=...@suaveplan/react-auth` post-landing — currently empty).
## Migration
No prior surface — additive only. There is no obsolete contract for consumers to migrate from. The `enterprise-webauthn` change explicitly scoped this hook out with the note "implementation lives in the `react-auth` change folder; this package only ships the underlying `authenticate({ mediation: "conditional" })` surface" — this change fulfils that deferral.

View File

@@ -0,0 +1,186 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: react-auth (add-react-auth-passkey-autofill)
**Package**: `@suaveplan/react-auth`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: usePasskeyAutofill hook dispatches conditional WebAuthn
The package SHALL export a `usePasskeyAutofill` React hook that, on the first client mount, feature-detects WebAuthn Conditional UI via `isConditionalMediationAvailable()` and dispatches `authenticate({ mediation: "conditional" })` via `@suaveplan/webauthn`. The hook SHALL deliver a successful `AuthenticateResult` via the caller's `onCredential` callback.
#### Scenario: conditional authenticate is invoked when supported
- **WHEN** the hook mounts in a browser whose `PublicKeyCredential.isConditionalMediationAvailable()` resolves `true`
- **THEN** the hook calls `authenticate` with `mediation: "conditional"` and the caller-supplied `rpId`, `challenge`, `allowCredentials`, `userVerification`, `timeout`, `expectedOrigin`, and `expectedRpIdHash`
- **AND** the returned `ready` flag flips to `true` once the request is in-flight
#### Scenario: successful credential is delivered to onCredential
- **WHEN** the underlying `authenticate` Promise resolves with an `AuthenticateResult`
- **THEN** `onCredential` is invoked once with that result
- **AND** the hook's `error` state remains `null`
**Verified by:** `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"dispatches authenticate with mediation conditional when available"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"delivers AuthenticateResult to onCredential on success"`
---
### Requirement: Hook is SSR-safe and feature-detects before dispatch
The hook SHALL perform zero work during server rendering and SHALL never call `navigator.credentials.get` without first confirming `PublicKeyCredential.isConditionalMediationAvailable()` resolves `true`. When Conditional UI is unavailable, the hook SHALL stay `ready === false` and report `REACT_AUTH.AUTOFILL_UNAVAILABLE` via the caller's `onError` (when supplied).
#### Scenario: server render performs no DOM access
- **WHEN** the hook is rendered in an environment where `typeof window === "undefined"`
- **THEN** the hook returns `{ ready: false, error: null, abort }` without touching `navigator`, `document`, or `PublicKeyCredential`
#### Scenario: missing Conditional UI surfaces AUTOFILL_UNAVAILABLE
- **WHEN** the hook mounts in a browser where `PublicKeyCredential.isConditionalMediationAvailable` is undefined OR resolves `false`
- **THEN** the hook never calls `authenticate`
- **AND** `ready` remains `false`
- **AND** `onError` (if supplied) is invoked with a `SuaveplanError` whose `code === "REACT_AUTH.AUTOFILL_UNAVAILABLE"`
**Verified by:** `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"performs no work during SSR render"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"reports AUTOFILL_UNAVAILABLE when isConditionalMediationAvailable is missing"`
---
### Requirement: AbortController is owned by the hook and aborts on unmount, signal, and abort()
The hook SHALL own exactly one internal `AbortController` per effect run, composed with the caller's `options.signal` if supplied. The composed signal SHALL be forwarded into the underlying `authenticate` call. The hook SHALL abort the in-flight request when the component unmounts, when the caller's `signal` aborts, or when the consumer invokes the returned `abort()` callback.
#### Scenario: unmount aborts the in-flight authenticate call
- **WHEN** the hook is mounted with an in-flight `authenticate` request and the component unmounts
- **THEN** the underlying `navigator.credentials.get` rejects with `AbortError`
- **AND** the hook's `error` (read before unmount completes) carries a `SuaveplanError` whose `code === "REACT_AUTH.ABORTED"` with the `WEBAUTHN.ABORTED` cause preserved
#### Scenario: caller-supplied AbortSignal aborts the request
- **WHEN** the caller passes `options.signal` and that controller aborts after mount
- **THEN** the in-flight `authenticate` rejects with `AbortError`
- **AND** the hook reports `REACT_AUTH.ABORTED` via `onError` if supplied
#### Scenario: returned abort() cancels without unmount
- **WHEN** the consumer calls the returned `abort()` while a request is in-flight
- **THEN** the request is cancelled
- **AND** the component remains mounted
- **AND** `ready` returns to `false`
#### Scenario: option-identity change cancels old request and dispatches new
- **WHEN** `options.challenge` reference changes between renders
- **THEN** the old controller aborts and a new `authenticate` is dispatched against the new challenge
**Verified by:** `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"aborts the in-flight request on unmount"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"propagates caller AbortSignal to authenticate"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"returned abort cancels without unmounting"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"re-dispatches when challenge identity changes"`
---
### Requirement: WebAuthn errors are translated into the REACT_AUTH code registry
The package SHALL export a `defineCodes("REACT_AUTH", ...)` registry covering `AUTOFILL_UNAVAILABLE`, `AUTOFILL_FAILED`, `ABORTED`, `SSR_NO_OP`, and `INVALID_OPTIONS`. Every error surfaced by the hook SHALL be a `SuaveplanError` whose `code` is one of these values; the underlying `WEBAUTHN.*` error SHALL be preserved on `cause` for introspection.
#### Scenario: WEBAUTHN.NOT_SUPPORTED maps to REACT_AUTH.AUTOFILL_UNAVAILABLE
- **WHEN** the underlying `authenticate` rejects with `WEBAUTHN.NOT_SUPPORTED`
- **THEN** the hook surfaces a `SuaveplanError` with `code === "REACT_AUTH.AUTOFILL_UNAVAILABLE"` and `cause.code === "WEBAUTHN.NOT_SUPPORTED"`
#### Scenario: WEBAUTHN.ABORTED maps to REACT_AUTH.ABORTED
- **WHEN** the underlying request rejects with `WEBAUTHN.ABORTED`
- **THEN** the surfaced error has `code === "REACT_AUTH.ABORTED"` with the same cause preserved
#### Scenario: any other WEBAUTHN.* maps to REACT_AUTH.AUTOFILL_FAILED
- **WHEN** the underlying request rejects with `WEBAUTHN.SIGNATURE_INVALID` (or any other non-cancel, non-not-supported `WEBAUTHN.*` code)
- **THEN** the surfaced error has `code === "REACT_AUTH.AUTOFILL_FAILED"` with the underlying cause preserved
**Verified by:** `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"translates WEBAUTHN.NOT_SUPPORTED to REACT_AUTH.AUTOFILL_UNAVAILABLE"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"translates WEBAUTHN.ABORTED to REACT_AUTH.ABORTED"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"translates other WEBAUTHN errors to REACT_AUTH.AUTOFILL_FAILED"` AND `src/errors/errors.test.ts::"every REACT_AUTH code is reachable from a production path"`
---
### Requirement: Hook options are validated by Zod at entry
The hook SHALL validate `UsePasskeyAutofillOptions` against a Zod schema at the start of every effect run. Schema mismatches SHALL throw `REACT_AUTH.INVALID_OPTIONS` synchronously so that React's nearest error boundary catches the failure at mount time rather than silently ignoring it inside an effect.
#### Scenario: invalid rpId throws INVALID_OPTIONS
- **WHEN** the caller passes `rpId: ""` (empty string) or omits a required field
- **THEN** the hook throws a `SuaveplanError` with `code === "REACT_AUTH.INVALID_OPTIONS"` during render
- **AND** no `authenticate` call is dispatched
#### Scenario: invalid challenge type throws INVALID_OPTIONS
- **WHEN** `challenge` is not a `Uint8Array`
- **THEN** the same error is thrown synchronously
**Verified by:** `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"throws INVALID_OPTIONS when rpId is empty"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"throws INVALID_OPTIONS when challenge is not Uint8Array"`
---
### Requirement: Hook does not touch the DOM directly
The hook SHALL NOT call `document.querySelector`, attach event listeners to inputs, or render any DOM. The autofill UI is surfaced by the browser based on an `<input autocomplete="username webauthn">` element rendered by the consumer; the hook only keeps the conditional `authenticate` request pending so the browser has something to deliver into.
#### Scenario: hook makes no document or input access
- **WHEN** the hook is mounted with a spy installed on `document.querySelector` and `HTMLInputElement.prototype.addEventListener`
- **THEN** neither spy is invoked during the hook's lifecycle
**Verified by:** `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"does not touch document or input elements"`
---
### Requirement: Returned abort callback has a stable reference
The hook SHALL return an `abort` callback whose reference identity is stable across renders (i.e. wrapped with `useCallback` against a controller ref), so consumers can place it in `useEffect` dependency arrays or memoization keys without triggering re-runs.
#### Scenario: abort reference is identical across re-renders
- **WHEN** the parent component re-renders while the hook's options are unchanged
- **THEN** `Object.is(prevReturn.abort, nextReturn.abort) === true`
**Verified by:** `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"returned abort reference is stable across re-renders"`
---
### Requirement: PasskeyAutofillField component wraps the hook with an input
The package SHALL export an optional `<PasskeyAutofillField>` component that renders an `<input autocomplete="username webauthn">` element, wires it to `usePasskeyAutofill`, and (by default) calls the hook's `abort()` when the input fires `blur` AND no credential has been delivered yet. Consumers SHALL be able to forward arbitrary `inputProps` to style and label the input.
#### Scenario: input renders with the correct autocomplete token
- **WHEN** `<PasskeyAutofillField rpId="example.test" challenge={c} onCredential={...} />` is rendered
- **THEN** the rendered `<input>` has `autocomplete="username webauthn"`
#### Scenario: blur cancels the request when no credential delivered
- **WHEN** the user blurs the input before any credential is delivered
- **THEN** the hook's `abort()` is invoked and the in-flight request rejects with `REACT_AUTH.ABORTED`
#### Scenario: blur after credential delivery does not abort
- **WHEN** `onCredential` has already fired
- **THEN** a subsequent blur does NOT invoke `abort()`
**Verified by:** `src/passkey-autofill-field/passkey-autofill-field.test.tsx::"renders input with autocomplete username webauthn"` AND `src/passkey-autofill-field/passkey-autofill-field.test.tsx::"blur cancels in-flight request"` AND `src/passkey-autofill-field/passkey-autofill-field.test.tsx::"blur after credential delivery is a no-op"`
---
### Requirement: Subpath exports cover hook, component, and errors
The package SHALL expose subpath exports at `.`, `./use-passkey-autofill/use-passkey-autofill`, `./passkey-autofill-field/passkey-autofill-field`, and `./errors`, each tree-shakeable, so consumers can import the hook, the component, or the error registry independently.
#### Scenario: every documented subpath resolves
- **WHEN** a consumer imports each documented subpath
- **THEN** every import resolves to the declared symbols
**Verified by:** `src/index.test.ts::"every documented subpath resolves"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,104 @@
# Tasks — `@suaveplan/react-auth` `usePasskeyAutofill`
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes.
## 0. Blocking prerequisite
- [x] 0.1 `@suaveplan/webauthn` exports `authenticate({ mediation: "conditional" })` and `isConditionalMediationAvailable()` — verified at `packages/auth/webauthn/src/authenticate/authenticate.ts` and `packages/auth/webauthn/src/index.ts`.
## 1. Spec authoring
- [x] 1.1 Read `openspec/archive/2026-05-11-enterprise-webauthn/proposal.md` + `tasks.md` to confirm the `usePasskeyAutofill` deferral
- [x] 1.2 Read `openspec/AGENTS.md` §13 and §14
- [x] 1.3 Draft `proposal.md` from `openspec/templates/proposal.md`
- [x] 1.4 Draft `design.md` covering hook signature, lifecycle, SSR safety, error translation
- [x] 1.5 Draft `specs/react-auth/spec.md` from `openspec/templates/spec-delta.md`
- [x] 1.6 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
## 2. Package scaffold
- [ ] 2.1 Create `packages/auth/react-auth/` with `package.json` (browser tier, React 19 peer, `@suaveplan/webauthn workspace:^`, `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `zod catalog:forms`, `tslib catalog:build`, `@vitest/coverage-istanbul catalog:testing`)
- [ ] 2.2 `tsconfig.json` extending `@suaveplan/typescript-config/browser`
- [ ] 2.3 `vitest.config.ts` via `createTestConfig` with jsdom environment and 100/100/100/100 thresholds
- [ ] 2.4 `package.json` `exports` map covering `.`, `./use-passkey-autofill/use-passkey-autofill`, `./passkey-autofill-field/passkey-autofill-field`, `./errors`
- [ ] 2.5 `"sideEffects": false`, `"browser"` field set
## 3. Implementation
### 3.1 Errors
- [ ] 3.1.1 `src/errors/errors.ts` — `defineCodes("REACT_AUTH", ["AUTOFILL_UNAVAILABLE", "AUTOFILL_FAILED", "ABORTED", "SSR_NO_OP", "INVALID_OPTIONS"])`
### 3.2 Utilities
- [ ] 3.2.1 `src/utils/compose-signals.ts` — composes N `AbortSignal`s into one (any aborts → composed aborts); handles already-aborted signals
### 3.3 Hook
- [ ] 3.3.1 `src/use-passkey-autofill/use-passkey-autofill.types.ts` — named `UsePasskeyAutofillOptions`, `UsePasskeyAutofillReturn` interfaces
- [ ] 3.3.2 `src/use-passkey-autofill/use-passkey-autofill.schemas.ts` — `UsePasskeyAutofillOptionsSchema` Zod schema; throws `REACT_AUTH.INVALID_OPTIONS` on parse failure
- [ ] 3.3.3 `src/use-passkey-autofill/use-passkey-autofill.ts` — hook body: SSR no-op, feature-detect, dispatch, AbortController ownership, stable `abort` callback, error translation
- [ ] 3.3.4 Effect re-runs on option-identity change; old controller aborts cleanly
### 3.4 Component wrapper
- [ ] 3.4.1 `src/passkey-autofill-field/passkey-autofill-field.types.ts` — named `PasskeyAutofillFieldProps`
- [ ] 3.4.2 `src/passkey-autofill-field/passkey-autofill-field.tsx` — wraps hook + `<input autocomplete="username webauthn">`; forwards `onBlur` → `abort()`
### 3.5 Barrels
- [ ] 3.5.1 `src/index.ts` — explicit named re-exports (no `export *`)
- [ ] 3.5.2 Per-feature `index.ts` files — explicit named re-exports
## 4. Tests (runner-neutral imports — `@suaveplan/testing/runner`)
- [ ] 4.1 `src/use-passkey-autofill/use-passkey-autofill.test.tsx` — happy path, SSR no-op, feature-unavailable, abort on unmount, abort on caller signal, abort via returned callback, option-identity change re-runs, error translation for every `WEBAUTHN.*` code, Zod rejection at entry
- [ ] 4.2 `src/passkey-autofill-field/passkey-autofill-field.test.tsx` — renders input with correct autocomplete, forwards `inputProps`, blur calls `abort` only when no credential delivered
- [ ] 4.3 `src/errors/errors.test.ts` — every `REACT_AUTH.*` code reachable from a production path
- [ ] 4.4 `src/utils/compose-signals.test.ts` — any-aborts semantics, already-aborted inputs, no leaks
- [ ] 4.5 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for async)
## 5. Stories
- [ ] 5.1 `src/use-passkey-autofill/use-passkey-autofill.stories.tsx` — default, playground, variants (unavailable, error, abort-on-blur)
- [ ] 5.2 `src/passkey-autofill-field/passkey-autofill-field.stories.tsx` — default, playground, variants
## 6. Documentation
- [ ] 6.1 Sibling `.md` for `use-passkey-autofill.ts`, `passkey-autofill-field.tsx`, `errors.ts`, `compose-signals.ts` (≥200 words, basic + advanced examples, full API reference)
- [ ] 6.2 `README.md` at package root with Modules table
- [ ] 6.3 Add `@suaveplan/react-auth` row to `CLAUDE.md` under an Auth/React section
## 7. Pre-archive verification (the 5-gate triple)
- [ ] 7.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info
- [ ] 7.2 `bunx turbo lint typecheck test --filter=@suaveplan/react-auth` — green
- [ ] 7.3 `bunx turbo test:coverage --filter=@suaveplan/react-auth` — 100/100/100/100
- [ ] 7.4 `bun scripts/openspec-conformance.ts @suaveplan/react-auth` — exit 0, no `error` findings
- [ ] 7.5 `bunx turbo build --filter=@suaveplan/react-auth` — green
## 8. Pre-archive 7-row checklist (AGENTS.md §13)
- [ ] 8.1 Generics threaded; `expectTypeOf` assertions on hook return type
- [ ] 8.2 Zod schema wired at hook entry boundary
- [ ] 8.3 Every `REACT_AUTH.*` code thrown by ≥1 production path
- [ ] 8.4 Strict-mode behaviors tested (feature-available vs unavailable)
- [ ] 8.5 Hook lifecycle tested under React 19 strict-mode double-mount
- [ ] 8.6 AbortSignal composition tested for all sources (caller signal, returned `abort`, unmount)
- [ ] 8.7 No retroactive tick
## 9. Banned-pattern + barrel + runner-import scans
- [ ] 9.1 `grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\.skip\|\.only" packages/auth/react-auth/src/ --include="*.ts" --include="*.tsx"` — zero output
- [ ] 9.2 `grep -rn "^\s*export\s\+\(type\s\+\)\?\*\s\+from" packages/auth/react-auth/src/ --include="index.ts"` — zero output (feature barrels must use named exports)
- [ ] 9.3 `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/auth/react-auth/src/ --include="*.ts" --include="*.tsx"` — zero output
## 10. Archive
- [ ] 10.1 Fold `specs/react-auth/spec.md` into `openspec/specs/react-auth/spec.md`
- [ ] 10.2 Move `openspec/changes/add-react-auth-passkey-autofill/` → `openspec/archive/<yyyy-mm-dd>-add-react-auth-passkey-autofill/`
- [ ] 10.3 Refresh `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts`
- [ ] 10.4 Refresh `MISSING.yml` via `bun scripts/generate-missing.ts`
- [ ] 10.5 Update `STATUS.md` — add `@suaveplan/react-auth` row in `Completed`
- [ ] 10.6 Update `SUMMARY.md` with per-package paragraph
- [ ] 10.7 `bunx markdownlint-cli2 --fix` and stage results

View File

@@ -0,0 +1,173 @@
# Design — @suaveplan/storage-mux
Non-normative companion to `proposal.md` and `specs/storage-mux/spec.md`.
## 1. Role in the family
```text
@suaveplan/storage AbstractStorage core (key/value, in-memory)
├─ @suaveplan/storage-cdn CdnAdapter contract
│ ├─ @suaveplan/storage-cdn-bunnycdn
│ └─ @suaveplan/storage-cdn-rackspace
├─ @suaveplan/storage-s3
├─ @suaveplan/storage-http
├─ @suaveplan/storage-file
└─ @suaveplan/storage-mux Multi-backend composition (THIS PACKAGE)
└─ wraps N StorageLike, exposes 1 StorageLike
```
`storage-mux` is a **composition** package, not a transport. It accepts any list of `StorageLike` (cdn / s3 / http / file / memory / other mux instances) and produces a single `StorageLike`. Recursion is intentional — a sharded-then-failed-over deployment is `Mux(round-robin, [Mux(failover, shard1), Mux(failover, shard2)])`.
## 2. File layout
```text
src/
index.ts # explicit named re-exports
error-codes.ts # defineCodes("STORAGE_MUX", [...]) + StorageMuxError
types/
backend-entry.types.ts # BackendEntry interface
mux-options.types.ts # StorageMuxOptions, MuxPolicy union
mux-options.schemas.ts # Zod boundary schemas
mux/
mux.ts # StorageMux extends AbstractStorage
mux.md
mux.test.ts
mux.spec-conformance.test.ts # runStorageConformance × 4 policies
policies/
selector.types.ts # BackendSelector interface
failover/
failover.ts
failover.md
failover.test.ts
round-robin/
round-robin.ts
round-robin.md
round-robin.test.ts
sticky-by-key/
sticky-by-key.ts # stable hash → backend index
sticky-by-key.md
sticky-by-key.test.ts
shield/
shield.ts # reads cascade, writes fan-out
shield.md
shield.test.ts
health/
health.ts # BackendHealth tracker + cooldown
health.md
health.test.ts
observability/
events.ts # event topic constants + emit helpers
events.md
events.test.ts
otel.ts # span attribute helpers
otel.md
otel.test.ts
error-codes/
error-codes.test.ts # reachability sweep
```
## 3. Policy state machines
### 3.1 `failover`
```text
┌──────────────────────────────────────────┐
▼ │
[idx=0] ─ call backend[idx] ── ok ──► return │
│ │
├── transient error ─────► idx++ ──────────┘
│
├── non-transient error ──► throw (surface as-is)
│
└── idx == backends.length ─► throw STORAGE_MUX.ALL_BACKENDS_DOWN
(cause = last transient error)
```
Per-attempt: `runWithRetry(retryStrategy, () => backend[idx].op(...))` so transient errors get the configured retry inside a single backend before falling through.
### 3.2 `round-robin`
Atomic counter modulo `healthyBackends.length`. On `ALL_BACKENDS_DOWN`, returns the failover error. Down backends skipped via `BackendHealth.isUp(name)`.
### 3.3 `sticky-by-key`
```ts
const h = stableHash(key) % healthyBackends.length;
return healthyBackends[h];
```
Hash is FNV-1a over UTF-8 bytes (universal, no crypto dep). When a backend goes down, the modulo redistributes — same key may move to a different backend; this is acceptable for cache workloads and documented.
### 3.4 `shield`
```text
read: backend[0] (origin) ?? backend[1..n] (edge mirrors) ── first success wins
write: Promise.allSettled(backend[0..n].set(...))
├── all ok ─► return
├── primary ok, mirror failed ─► return + emit WRITE_FANOUT_PARTIAL event
└── primary failed ─► throw (cause = primary error)
```
Distinct from `failover` because `shield` reads **must** hit origin first (origin is canonical), and writes **must** fan out (mirrors only serve what was written to them). `failover` is symmetric — any backend can serve any operation.
## 4. Error classification
Which errors trigger failover vs surface directly:
| Error shape | Classification | Triggers fall-through? | Code surfaced |
|---|---|---|---|
| `AbortError` / `signal.aborted` | non-transient | NO | `STORAGE_MUX.BACKEND_ABORTED` |
| `STORAGE.KEY_NOT_FOUND` (from `get`/`stat`) | semantic | NO | rethrow as-is |
| `STORAGE.INVALID_KEY` | semantic | NO | rethrow as-is |
| Network timeout (configurable `backendTimeoutMs`) | transient | YES | wrapped, last in cause chain |
| `ECONNRESET` / `ETIMEDOUT` / fetch network error | transient | YES | wrapped |
| HTTP 5xx from backend | transient | YES | wrapped |
| HTTP 4xx (except 408/429) | non-transient | NO | rethrow |
| HTTP 408 / 429 | transient | YES | wrapped |
| `STORAGE_CDN.CREDENTIAL_EXPIRED` | non-transient | NO | rethrow (caller must rotate) |
| Unknown thrown value (non-Error) | transient | YES | wrapped |
Classification lives in `src/policies/classify.ts`, pure function over `(error: unknown) => "transient" | "semantic" | "abort"`. Tested independently.
## 5. Backend-isolation guarantees
A slow primary MUST NOT block a fallback. Mechanism:
1. **Per-backend timeout** — every backend call wrapped in `Promise.race([call, timeoutPromise(backendTimeoutMs)])`. Default 30 s, configurable per-mux.
2. **AbortSignal forwarding** — the per-attempt signal is linked (`AbortSignal.any([userSignal, timeoutSignal])`) and passed to the backend. A well-behaved backend respects it; a misbehaved backend will eventually reject via the timeout race.
3. **`shield` reads cascade sequentially with timeout** — origin gets `backendTimeoutMs`; on timeout/error, the mux moves to mirror[0], etc. The next backend's call starts before the previous backend's underlying promise has settled (it is left to its own cleanup).
4. **`shield` writes are `Promise.allSettled`** — one slow mirror cannot starve the others. Caller sees the result when all settle OR `backendTimeoutMs * 2` elapses (guard against a permanently-hung mirror).
The mux NEVER waits for a hung promise indefinitely — `Promise.race` with a clock-driven timeout is the contract.
## 6. Health tracking
`BackendHealth` per-backend state:
```ts
interface BackendState {
status: "up" | "down";
consecutiveFailures: number;
cooldownUntil: number; // Clock.now() millis
lastError?: unknown;
}
```
Transitions:
- **up → down**: triggered by any classified-transient error. `cooldownUntil = now + cooldownMs * 2 ** min(consecutiveFailures, 6)` (exponential capped at 64× base). Emits `storage-mux.backend.down`.
- **down → up**: triggered when `now >= cooldownUntil` AND the next selection call probes the backend AND the probe succeeds. Resets `consecutiveFailures`. Emits `storage-mux.backend.recovered`.
- **active healthcheck**: if `BackendEntry.healthcheck` is provided AND `healthcheckIntervalMs` is set, the mux schedules `Clock.setTimeout` to invoke it; failure marks the backend down without waiting for a real request.
`Clock` is injected (`@suaveplan/types#Clock`), so tests use `FakeClock` for deterministic cooldown / healthcheck advancement.
## 7. Tier purity
Universal — no `node:*`, no `bun:*`, no DOM. Hash uses pure-JS FNV-1a. Timeouts use injected `Clock`. AbortSignal is standard Web API. Conformance harness runs under Node, Bun, browsers, Workers.
## 8. Trade-offs
- **Strategy classes vs. switch statement.** Chose strategy classes (`BackendSelector` interface). Each policy is a separate file ≤ 250 lines, individually tested, and new policies (e.g. `weighted-random` later) plug in without editing the core `StorageMux`.
- **FNV-1a vs. xxhash for `sticky-by-key`.** FNV-1a is 20 lines of pure JS with no dep; xxhash needs a wasm or noble dep. For backend selection (low-cardinality output), FNV's distribution is sufficient. If a future workload needs better distribution, the hash function is injectable via options.
- **`shield` writes fan out vs. primary-only.** Fan-out is the right default because mirrors that don't see writes can't serve reads. Callers who want primary-only writes should use `failover` instead — the policy choice is the API.
- **Recursive Mux.** Allowed because `StorageMux` is itself a `StorageLike`. The conformance harness verifies recursion does not break invariants. The cost — debugging a deeply-nested mux tree — is mitigated by per-call OTel span attributes naming every backend hop.

View File

@@ -0,0 +1,87 @@
# Add @suaveplan/storage-mux — multi-backend storage failover / shielding mux
**Package**: `@suaveplan/storage-mux`
**Status**: PROPOSED
**Type**: new-package (genesis-original)
- **Design**: ./design.md
- **Spec delta**: ./specs/storage-mux/spec.md
## Why
`@suaveplan/storage-cdn` (archived 2026-05-16) explicitly deferred origin shielding and multi-origin failover as out-of-scope, naming `storage-mux` as the future home (`openspec/archive/2026-05-16-add-storage-cdn/proposal.md` "Out of scope": *"Origin shielding / multi-origin failover. A failover/shielding layer is a separate concern composed via `storage-mux` (future) or application logic."*). The deferral was correct — failover semantics are orthogonal to CDN-specific surface (purge/signing/edgeUrl) and belong in a composable layer over the bare `AbstractStorage` contract.
`storage-mux` is a **universal-tier composition layer**: it accepts an ordered list of `StorageLike` backends and a policy (`failover` / `round-robin` / `sticky-by-key` / `shield`) and exposes a single `StorageLike` to consumers. Backends can be any storage adapter — `storage-cdn-*`, `storage-s3`, `storage-http`, `storage-file`, in-memory — making the mux the canonical primitive for CDN failover, origin shielding, mirror reads, and shard distribution.
### ≥2-consumer justification (per `openspec/AGENTS.md` §0 step 6)
Three forward-declared scenarios consume `storage-mux` directly:
1. **CDN failover for user-uploaded content** — primary `storage-cdn-bunnycdn` with `storage-cdn-rackspace` fallback. When BunnyCDN edge degrades, reads transparently fall through. The `failover` policy.
2. **Cache mirroring for `storage-plugin-cache`** — local `storage-file` cache layered in front of remote `storage-s3` origin via the `shield` policy. Hits the fast tier first, falls through to origin on miss, writes to both.
3. **Drive chunk shielding** — `drive-plugin-chunks` reads chunks from a regional mirror set with `sticky-by-key` so the same chunk always hits the same backend for cache warmth, plus failover when a mirror is down.
All three scenarios share the same primitive (ordered backend list + policy + health), differing only in policy. Without a shared mux, each consumer reinvents the failover state machine and the registry of `STORAGE_MUX.*` codes would be scattered.
## What changes
- ADDED: `StorageMux` class extending `AbstractStorage` from `@suaveplan/storage`. Wraps an ordered backend list and a policy and exposes the full `StorageLike` surface (`get`, `set`, `setPartial`, `has`, `delete`, `list`, `stat`, `getStream`, `setStream`).
- ADDED: `MuxPolicy` discriminated union — `"failover" | "round-robin" | "sticky-by-key" | "shield"`. Each policy is implemented as a strategy class behind a common `BackendSelector` interface.
- ADDED: `BackendEntry` interface — `{ name: string; storage: StorageLike; weight?: number; healthcheck?: (signal: AbortSignal) => Promise<void> }`.
- ADDED: `BackendHealth` tracker — passive degrade (mark down on transient error, exponential cooldown via injected `Clock`) + optional active polling via `healthcheck` callbacks scheduled on `Clock`.
- ADDED: `StorageMuxOptions` — `{ backends: BackendEntry[]; policy: MuxPolicy; retry?: RetryStrategy; clock?: Clock; events?: EventBusLike; logger?: LoggerLike; tracer?: TracerLike; cooldownMs?: number; healthcheckIntervalMs?: number }`.
- ADDED: `STORAGE_MUX_CODES` via `defineCodes("STORAGE_MUX", [...])` — 8 codes: `ALL_BACKENDS_DOWN`, `NO_BACKENDS_CONFIGURED`, `HEALTHCHECK_FAILED`, `BACKEND_TIMEOUT`, `INVALID_POLICY`, `INVALID_WEIGHT`, `WRITE_FANOUT_PARTIAL`, `BACKEND_ABORTED`.
- ADDED: `StorageMuxError extends SuaveplanError<StorageMuxErrorCode>` with `backendName`, `attemptIndex`, `attemptCount` discriminants.
- ADDED: Retry composition — every backend call routed through `@suaveplan/retry` (`runWithRetry` + injected strategy). The mux never reimplements backoff; it composes.
- ADDED: AbortSignal threaded through every backend call. A caller-aborted operation must surface `BACKEND_ABORTED`, not `ALL_BACKENDS_DOWN`.
- ADDED: Observability — `EventBusLike` emissions on backend down/recovery/failover; OTel span attributes per call (`mux.backend.name`, `mux.attempt.index`, `mux.policy`).
- ADDED: Conformance harness integration — `runStorageConformance` (from `@suaveplan/storage`) executed against a `StorageMux` configured with N `MemoryStorage` backends and each policy.
## Out of scope
- **Provider-specific backends.** The mux composes `StorageLike` values handed in by the caller; it does not instantiate CDN clients or know provider authentication.
- **Cross-backend replication / consistency.** Writes under `shield` are best-effort fan-out with `WRITE_FANOUT_PARTIAL` surfacing partial failures — there is no two-phase commit, no quorum, no vector clocks. Stronger consistency is `@suaveplan/vault-sync` territory.
- **Geo-aware routing.** Backend selection is by ordered list + policy. Geographic affinity belongs in a higher-level router that constructs the backend list per-request.
- **Adaptive policy switching.** The policy is fixed at construction; the mux never silently switches `failover` → `round-robin` based on load.
## Source mapping
Genesis-original. No upstream in `../suaveplan/`. The design draws from:
- `openspec/archive/2026-05-16-add-storage-cdn/proposal.md` — the explicit deferral naming `storage-mux`.
- Industry conventions: HAProxy / Envoy passive+active health checks; HashiCorp Consul service-mesh failover; AWS Route 53 health-check failover routing; NGINX `upstream` `max_fails` / `fail_timeout`. The four policies (`failover`, `round-robin`, `sticky-by-key`, `shield`) are the union of what those tools offer in the storage-IO dimension.
### Requirements with no source line
| Requirement | Justification |
|---|---|
| Pluggable `RetryStrategy` via `@suaveplan/retry` | Reimplementing retry in the mux would duplicate `retry`'s state machine and diverge from the rest of the monorepo. Composition keeps the contract uniform. |
| Backend isolation (slow primary cannot block fallback) | A timeout-aware race or per-backend abort is required because a hung primary that never rejects starves the failover chain. The design uses `Promise.race` against a clock-driven timeout, not a parallel call — parallel-call shielding (`shield` writes) is opt-in. |
| `WRITE_FANOUT_PARTIAL` distinct from `ALL_BACKENDS_DOWN` | `shield` writes intentionally fan out to mirrors; one mirror down does not fail the operation, but the caller deserves a signal. A separate code lets callers choose to surface or swallow it. |
| `sticky-by-key` uses a stable hash, not a Map cache | Cache memory grows unbounded under high-cardinality keys. Hashing keeps the policy O(1) memory regardless of key population. |
## Verification approach
- `src/mux/mux.test.ts` — construction validates non-empty backend list, policy whitelist, weights.
- `src/policies/failover/failover.test.ts` — fall-through on transient error; surface non-transient; respect AbortSignal; `ALL_BACKENDS_DOWN` after exhausting list.
- `src/policies/round-robin/round-robin.test.ts` — even distribution under N calls; skip down backends.
- `src/policies/sticky-by-key/sticky-by-key.test.ts` — same key → same backend across calls; redistribute when backend down.
- `src/policies/shield/shield.test.ts` — reads hit primary first then mirrors; writes fan out; `WRITE_FANOUT_PARTIAL` on partial mirror failure.
- `src/health/health.test.ts` — passive degrade + exponential cooldown; active polling via injected `Clock`; recovery emits event.
- `src/mux/mux.spec-conformance.test.ts` — `runStorageConformance` passes with 3× `MemoryStorage` backends under every policy.
- `src/error-codes/error-codes.test.ts` — every `STORAGE_MUX_*` code reachable from a real call site.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/storage-mux` to enumerate. The package is new — no current dependents.
- No current dependents (verified by `bunx turbo ls --filter=...@suaveplan/storage-mux` returning the package itself).
- Forward-declared consumers: `@suaveplan/storage-plugin-cache` (shield mode), `@suaveplan/drive-plugin-chunks` (sticky-by-key), application code composing `storage-cdn-bunnycdn` + `storage-cdn-rackspace` (failover).
### Affected upstreams
- `@suaveplan/storage` — consumed via `AbstractStorage` + `StorageLike` + `runStorageConformance`. No augmentation. [x] no action needed.
- `@suaveplan/retry` — consumed via `RetryStrategy` + `runWithRetry`. No augmentation. [x] no action needed.
- `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/types` — standard foundation deps. [x] no action needed.

View File

@@ -0,0 +1,256 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: storage-mux (storage-mux)
**Package**: `@suaveplan/storage-mux`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: StorageMux extends AbstractStorage
The package SHALL provide a `StorageMux` class extending `AbstractStorage` from `@suaveplan/storage`. The class SHALL accept `StorageMuxOptions` containing an ordered non-empty `backends: BackendEntry[]` and a `policy: MuxPolicy`. The class SHALL implement every method of the `StorageLike` surface (`get`, `set`, `setPartial`, `has`, `delete`, `list`, `stat`, `getStream`, `setStream`) by delegating to a `BackendSelector` chosen from the configured policy.
#### Scenario: subclass is an instance of AbstractStorage
- **WHEN** a `StorageMux` is constructed with at least one `BackendEntry`
- **THEN** the instance is `instanceof AbstractStorage` AND exposes every `StorageLike` method
#### Scenario: empty backend list is rejected at construction
- **WHEN** a caller invokes `new StorageMux({ backends: [], policy: "failover" })`
- **THEN** the constructor throws `StorageMuxError` with `code === "STORAGE_MUX.NO_BACKENDS_CONFIGURED"`
**Verified by:** `src/mux/mux.test.ts::"is instance of AbstractStorage and exposes StorageLike surface"` AND `src/mux/mux.test.ts::"rejects empty backend list with NO_BACKENDS_CONFIGURED"`
---
### Requirement: MuxPolicy discriminated union with four policies
The package SHALL declare `MuxPolicy` as the string union `"failover" | "round-robin" | "sticky-by-key" | "shield"`. Each policy SHALL be implemented as a `BackendSelector` strategy class in its own subdirectory under `src/policies/`. The `StorageMuxOptions.policy` field SHALL be validated by a Zod schema that rejects any value outside this whitelist with `STORAGE_MUX.INVALID_POLICY`.
#### Scenario: unknown policy string is rejected
- **WHEN** a caller invokes `new StorageMux({ backends, policy: "magic" as MuxPolicy })`
- **THEN** the constructor throws `StorageMuxError` with `code === "STORAGE_MUX.INVALID_POLICY"`
**Verified by:** `src/mux/mux.test.ts::"rejects unknown policy with INVALID_POLICY"`
---
### Requirement: failover policy falls through on transient errors and surfaces semantic errors
Under the `failover` policy the mux SHALL invoke `backends[0]` first. On a transient classified error (per the error-classification table in `design.md` §4) the mux SHALL advance to `backends[1]`, then `backends[2]`, and so on. On a semantic error (`STORAGE.KEY_NOT_FOUND`, `STORAGE.INVALID_KEY`, 4xx other than 408/429, `CREDENTIAL_EXPIRED`) the mux SHALL surface the error directly without advancing. When every backend has been tried and the last attempt was transient, the mux SHALL throw `StorageMuxError` with `code === "STORAGE_MUX.ALL_BACKENDS_DOWN"`, the prior error as `cause`, and `attemptCount` equal to the number of backends tried.
#### Scenario: transient error advances to next backend
- **WHEN** `backends[0].get(key)` rejects with a 503 and `backends[1].get(key)` resolves with bytes
- **THEN** the mux returns `backends[1]`'s bytes AND emits a `storage-mux.failover` event naming both backends
#### Scenario: semantic KEY_NOT_FOUND is not retried across backends
- **WHEN** `backends[0].get(key)` throws `STORAGE.KEY_NOT_FOUND`
- **THEN** the mux rethrows `KEY_NOT_FOUND` without invoking `backends[1]`
#### Scenario: exhausting all backends throws ALL_BACKENDS_DOWN with cause chain
- **WHEN** every backend rejects with a transient error
- **THEN** the mux throws `StorageMuxError` with `code === "STORAGE_MUX.ALL_BACKENDS_DOWN"` AND `cause` is the last backend's error AND `attemptCount === backends.length`
**Verified by:** `src/policies/failover/failover.test.ts::"falls through to next backend on transient 503"` AND `src/policies/failover/failover.test.ts::"surfaces semantic KEY_NOT_FOUND without fall-through"` AND `src/policies/failover/failover.test.ts::"throws ALL_BACKENDS_DOWN after exhausting list"`
---
### Requirement: round-robin policy distributes across healthy backends
Under the `round-robin` policy the mux SHALL maintain a monotonic counter and select `healthyBackends[counter mod healthyBackends.length]` for each call. Backends marked down by `BackendHealth` SHALL be skipped. When zero backends are healthy the mux SHALL throw `STORAGE_MUX.ALL_BACKENDS_DOWN`.
#### Scenario: ten calls across three healthy backends distribute evenly
- **WHEN** the caller issues 9 `get` calls against a mux with 3 healthy backends
- **THEN** each backend receives exactly 3 calls
#### Scenario: down backend is skipped
- **WHEN** `backends[1]` is marked down and the caller issues 4 calls
- **THEN** `backends[0]` and `backends[2]` each receive 2 calls AND `backends[1]` receives 0
**Verified by:** `src/policies/round-robin/round-robin.test.ts::"distributes nine calls evenly across three backends"` AND `src/policies/round-robin/round-robin.test.ts::"skips backends marked down"`
---
### Requirement: sticky-by-key policy is deterministic and skips down backends
Under the `sticky-by-key` policy the mux SHALL compute `index = fnv1a(utf8(key)) mod healthyBackends.length` and route every operation for that key to the same backend. The hash SHALL be pure-JS FNV-1a (no native or crypto dep). When a backend goes down its keys SHALL be redistributed across the remaining healthy backends via the same formula recomputed against the shrunken set.
#### Scenario: same key resolves to same backend across 100 calls
- **WHEN** the caller issues 100 `get("alpha")` calls against a mux with 3 healthy backends
- **THEN** exactly one backend receives all 100 calls
#### Scenario: removed backend triggers redistribution
- **WHEN** the backend currently serving `"alpha"` is marked down
- **THEN** subsequent `get("alpha")` calls land on one of the remaining healthy backends AND the choice is stable until the health set changes again
**Verified by:** `src/policies/sticky-by-key/sticky-by-key.test.ts::"routes the same key to the same backend across 100 calls"` AND `src/policies/sticky-by-key/sticky-by-key.test.ts::"redistributes keys when a backend goes down"`
---
### Requirement: shield policy cascades reads and fans out writes
Under the `shield` policy the mux SHALL treat `backends[0]` as the origin and `backends[1..n]` as mirrors. For read operations (`get`, `has`, `stat`, `getStream`) the mux SHALL invoke `backends[0]` first and on transient error or timeout fall through to mirrors in order. For write operations (`set`, `setPartial`, `delete`, `setStream`) the mux SHALL invoke `backends[0]` AND every mirror via `Promise.allSettled`. If the origin write rejects the mux SHALL throw with the origin error as `cause`. If the origin write succeeds but one or more mirror writes reject the mux SHALL emit `storage-mux.fanout-partial` carrying every failed mirror name AND return successfully (the operation is considered durable once the origin accepts it). The mux SHALL additionally surface a `StorageMuxError` with `code === "STORAGE_MUX.WRITE_FANOUT_PARTIAL"` when the caller opts in via `StorageMuxOptions.throwOnFanoutPartial === true`.
#### Scenario: read falls through to mirror after origin timeout
- **WHEN** `backends[0].get(key)` hangs past `backendTimeoutMs` and `backends[1].get(key)` resolves with bytes
- **THEN** the mux returns `backends[1]`'s bytes
#### Scenario: write succeeds when origin accepts and mirror rejects
- **WHEN** `backends[0].set` resolves and `backends[1].set` rejects with a transient error
- **THEN** the mux returns successfully AND emits `storage-mux.fanout-partial` naming `backends[1]`
#### Scenario: write fails when origin rejects
- **WHEN** `backends[0].set` rejects with any error
- **THEN** the mux throws AND `cause` is the origin error AND mirror outcomes are not consulted
**Verified by:** `src/policies/shield/shield.test.ts::"reads fall through to mirror after origin timeout"` AND `src/policies/shield/shield.test.ts::"write succeeds when origin accepts and one mirror rejects"` AND `src/policies/shield/shield.test.ts::"write fails when origin rejects regardless of mirror outcomes"`
---
### Requirement: backend isolation — slow primary cannot block fallback
The mux SHALL wrap every backend call in a `Promise.race` against a `Clock`-driven timeout of `backendTimeoutMs` (default 30000, configurable per-mux). When the race rejects due to timeout the mux SHALL classify the timeout as transient AND proceed to the next backend per policy. The underlying backend promise MAY remain pending; the mux SHALL NOT await it. The mux SHALL link the user `AbortSignal` and the timeout signal via `AbortSignal.any` and pass the combined signal to every backend call so well-behaved backends abort promptly.
#### Scenario: hung primary does not delay fallback beyond backendTimeoutMs
- **WHEN** `backends[0].get` returns a never-resolving promise and `backends[1].get` resolves in 10ms
- **THEN** the caller receives `backends[1]`'s value in less than `backendTimeoutMs + 50ms` measured against the injected `FakeClock`
**Verified by:** `src/mux/mux.test.ts::"hung primary does not block fallback past backendTimeoutMs"`
---
### Requirement: AbortSignal propagation surfaces BACKEND_ABORTED
When the caller's `AbortSignal` aborts during a mux operation, the mux SHALL surface a `StorageMuxError` with `code === "STORAGE_MUX.BACKEND_ABORTED"` rather than falling through to subsequent backends. The mux SHALL NOT classify a caller-driven abort as a transient backend failure.
#### Scenario: caller abort surfaces BACKEND_ABORTED and does not advance
- **WHEN** the caller passes a pre-aborted signal to `mux.get(key, signal)`
- **THEN** the mux throws `StorageMuxError` with `code === "STORAGE_MUX.BACKEND_ABORTED"` AND `backends[1].get` is NEVER invoked
**Verified by:** `src/mux/mux.test.ts::"caller abort surfaces BACKEND_ABORTED without falling through"`
---
### Requirement: BackendHealth — passive degrade with exponential cooldown
The package SHALL maintain a `BackendHealth` tracker per backend. A backend SHALL transition from `up` to `down` on the first classified-transient error. The cooldown SHALL be `cooldownMs * 2 ** min(consecutiveFailures, 6)` (exponential, capped at 64× base, default `cooldownMs` 1000). A `down` backend SHALL be skipped by every selector. Once `Clock.now() >= cooldownUntil` the next operation SHALL probe the backend; success transitions it back to `up` and resets `consecutiveFailures`.
#### Scenario: failing backend cools down for 2000ms after second failure
- **WHEN** a backend rejects twice in succession (default `cooldownMs` 1000)
- **THEN** the backend is `down` AND remains skipped until `FakeClock` advances by 2000ms
#### Scenario: successful probe after cooldown restores backend and emits recovered event
- **WHEN** `FakeClock` advances past `cooldownUntil` AND the next probe succeeds
- **THEN** the backend is `up` AND a `storage-mux.backend.recovered` event is emitted AND `consecutiveFailures` is reset to 0
**Verified by:** `src/health/health.test.ts::"exponential cooldown after consecutive failures"` AND `src/health/health.test.ts::"successful probe after cooldown restores backend and emits recovered"`
---
### Requirement: Optional active healthchecks via injected Clock
When `BackendEntry.healthcheck` is provided AND `StorageMuxOptions.healthcheckIntervalMs` is set, the mux SHALL schedule the healthcheck via the injected `Clock.setTimeout` (or `Clock.setInterval`) and invoke it with an `AbortSignal`. A rejecting healthcheck SHALL mark the backend `down` AND emit `storage-mux.backend.down` carrying `STORAGE_MUX.HEALTHCHECK_FAILED` as the cause. The mux SHALL stop scheduling when the mux is disposed.
#### Scenario: rejecting active healthcheck marks backend down
- **WHEN** an active healthcheck rejects with a network error
- **THEN** the backend transitions to `down` AND a `storage-mux.backend.down` event is emitted AND the failure cause is `STORAGE_MUX.HEALTHCHECK_FAILED`
**Verified by:** `src/health/health.test.ts::"rejecting active healthcheck marks backend down with HEALTHCHECK_FAILED"`
---
### Requirement: Retry composition via @suaveplan/retry
Every per-backend invocation SHALL be wrapped by `runWithRetry(retryStrategy, ...)` from `@suaveplan/retry` using the strategy supplied in `StorageMuxOptions.retry` (defaulting to `exponentialBackoff()` from `@suaveplan/retry/strategies/exponential/exponential`). The mux SHALL NOT reimplement backoff, jitter, or attempt counting; all retry behavior MUST be expressed by composing the retry strategy.
#### Scenario: backend that fails twice then succeeds is retried in place before fall-through
- **WHEN** `backends[0].get` rejects twice then resolves on the third attempt under a 3-attempt retry strategy
- **THEN** the mux returns `backends[0]`'s value AND `backends[1].get` is NEVER invoked
**Verified by:** `src/policies/failover/failover.test.ts::"retries in place before falling through to next backend"`
---
### Requirement: STORAGE_MUX error registry — 8 codes reachable
The package SHALL declare `STORAGE_MUX_CODES` via `defineCodes("STORAGE_MUX", [...])` with exactly these 8 codes: `ALL_BACKENDS_DOWN`, `NO_BACKENDS_CONFIGURED`, `HEALTHCHECK_FAILED`, `BACKEND_TIMEOUT`, `INVALID_POLICY`, `INVALID_WEIGHT`, `WRITE_FANOUT_PARTIAL`, `BACKEND_ABORTED`. The package SHALL declare `StorageMuxError extends SuaveplanError<StorageMuxErrorCode>` with optional `backendName`, `attemptIndex`, and `attemptCount` discriminants. Every code SHALL be reachable from a real call site exercised by tests in this package.
#### Scenario: every declared code is reachable from a real call site
- **WHEN** the test suite is run
- **THEN** every code in `STORAGE_MUX_CODES` has at least one test that exercises the call site that throws or carries it
**Verified by:** `src/error-codes/error-codes.test.ts::"every STORAGE_MUX code is reachable from a real call site"`
---
### Requirement: Observability — EventBusLike events on lifecycle transitions
The mux SHALL emit events through an injected `EventBusLike` (from `@suaveplan/types`) on the following lifecycle transitions: `storage-mux.backend.down` (passive or active failure), `storage-mux.backend.recovered` (probe success after cooldown), `storage-mux.failover` (failover policy advances to a new backend), `storage-mux.fanout-partial` (shield-policy mirror write rejected while origin succeeded). When no `EventBusLike` is provided the mux SHALL operate normally without emitting (silent fallback).
#### Scenario: failover emits storage-mux.failover with both backend names
- **WHEN** the failover policy advances from `backends[0]` to `backends[1]` due to a 503
- **THEN** exactly one `storage-mux.failover` event is emitted carrying both backend names AND the underlying error
**Verified by:** `src/observability/events.test.ts::"failover emits storage-mux.failover with from and to backend names"`
---
### Requirement: Observability — OTel span attributes per backend call
Every backend call SHALL be wrapped in an OTel span carrying the attributes `mux.backend.name` (string), `mux.attempt.index` (number, 0-based), and `mux.policy` (string). When no tracer is provided (`TracerLike` from `@suaveplan/types`) the mux SHALL operate normally without recording spans.
#### Scenario: failover attempt records two spans with attempt indices 0 and 1
- **WHEN** the failover policy invokes `backends[0]` (which rejects) then `backends[1]` (which succeeds)
- **THEN** the test tracer records two spans with `mux.attempt.index` equal to `0` and `1` respectively AND matching `mux.backend.name`
**Verified by:** `src/observability/otel.test.ts::"records one span per backend attempt with attempt index and backend name"`
---
### Requirement: StorageLike conformance under every policy
The mux SHALL pass `runStorageConformance` (from `@suaveplan/storage`) when configured with three `MemoryStorage` backends under each of the four policies (`failover`, `round-robin`, `sticky-by-key`, `shield`). The conformance suite SHALL include `setPartial` sparse-extension and overwrite semantics, capability-honest reporting, and AbortSignal propagation.
#### Scenario: conformance suite passes under failover, round-robin, sticky-by-key, and shield
- **WHEN** `runStorageConformance(mux)` is invoked four times — once per policy — against the same three-MemoryStorage backend set
- **THEN** every conformance assertion passes for every policy
**Verified by:** `src/mux/mux.spec-conformance.test.ts::"passes runStorageConformance under failover"` AND `src/mux/mux.spec-conformance.test.ts::"passes runStorageConformance under round-robin"` AND `src/mux/mux.spec-conformance.test.ts::"passes runStorageConformance under sticky-by-key"` AND `src/mux/mux.spec-conformance.test.ts::"passes runStorageConformance under shield"`
---
### Requirement: Tier purity — universal, no Node or DOM imports
The package SHALL NOT import from `node:*`, `bun:*`, or any DOM-only API. Hashing SHALL be pure-JS (FNV-1a). Timeouts SHALL use the injected `Clock`. The package SHALL run unmodified under Node 18+, Bun, Deno, modern browsers, and Cloudflare Workers.
#### Scenario: tier-purity grep finds no Node or DOM imports
- **WHEN** `grep -rnE "from ['\"](node:|bun:)" packages/storage/storage-mux/src/` is run
- **THEN** the output is empty
**Verified by:** `src/mux/mux.spec-conformance.test.ts::"src tree contains no node: or bun: imports"`

View File

@@ -0,0 +1,92 @@
# Tasks — storage-mux
> Every checkbox MUST reflect work actually done in this change.
## 1. Spec authoring
- [ ] 1.1 Read `openspec/specs/storage/spec.md` and confirm `AbstractStorage` surface
- [ ] 1.2 Read `openspec/AGENTS.md` §0c, §13, §14
- [ ] 1.3 Read archived `openspec/archive/2026-05-16-add-storage-cdn/` for the deferral and family conventions
- [ ] 1.4 Read `packages/flow/retry/src/retry/retry.ts` to confirm `runWithRetry` + `RetryStrategy` surface
- [ ] 1.5 Draft `proposal.md`, `design.md`, `specs/storage-mux/spec.md`, `tasks.md`
- [ ] 1.6 Numbered Requirement/Scenario checklist per §0c step 6
## 2. Scaffolding
- [ ] 2.1 Create `packages/storage/storage-mux/` mirroring `packages/storage/storage-cdn/` conventions
- [ ] 2.2 `package.json` v0.0.0, `"sideEffects": false`, subpath exports per feature
- [ ] 2.3 Deps: `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/retry`, `@suaveplan/storage`, `@suaveplan/types`, `@opentelemetry/api`, `zod`, `tslib`
- [ ] 2.4 DevDeps: `@suaveplan/testing`, `@vitest/coverage-istanbul`, `fast-check`, `mitata`, `typescript`, `vite`, `vite-plugin-dts`, `vitest`
- [ ] 2.5 `tsconfig`, `vite.config.ts`, `vitest.config.ts` via `createTestConfig`
- [ ] 2.6 `README.md` root with Modules section linking every `.md`
## 3. Error registry
- [ ] 3.1 `src/error-codes.ts` — `defineCodes("STORAGE_MUX", [...])` with 8 codes (`ALL_BACKENDS_DOWN`, `NO_BACKENDS_CONFIGURED`, `HEALTHCHECK_FAILED`, `BACKEND_TIMEOUT`, `INVALID_POLICY`, `INVALID_WEIGHT`, `WRITE_FANOUT_PARTIAL`, `BACKEND_ABORTED`)
- [ ] 3.2 `src/error-codes.ts` — `StorageMuxError extends SuaveplanError<StorageMuxErrorCode>` with `backendName`, `attemptIndex`, `attemptCount` discriminants
## 4. Types & schemas
- [ ] 4.1 `src/types/backend-entry.types.ts` — `BackendEntry` interface (`name`, `storage`, `weight?`, `healthcheck?`)
- [ ] 4.2 `src/types/mux-options.types.ts` — `MuxPolicy` discriminated string union, `StorageMuxOptions`
- [ ] 4.3 `src/types/mux-options.schemas.ts` — Zod schemas validating non-empty backends, policy whitelist, weight ≥ 0
## 5. Selectors / policies
- [ ] 5.1 `src/policies/selector.types.ts` — `BackendSelector` interface (`select(op, key?, healthy): BackendEntry`)
- [ ] 5.2 `src/policies/classify.ts` + `.md` + `.test.ts` — pure error classification per design §4 table
- [ ] 5.3 `src/policies/failover/failover.ts` + `.md` + `.test.ts` — fall-through + retry composition
- [ ] 5.4 `src/policies/round-robin/round-robin.ts` + `.md` + `.test.ts` — atomic counter, skip down
- [ ] 5.5 `src/policies/sticky-by-key/sticky-by-key.ts` + `.md` + `.test.ts` — FNV-1a hash, deterministic
- [ ] 5.6 `src/policies/shield/shield.ts` + `.md` + `.test.ts` — read cascade, write fan-out, partial signal
## 6. Health
- [ ] 6.1 `src/health/health.ts` + `.md` + `.test.ts` — `BackendHealth` tracker, exponential cooldown, recovery probe, active healthcheck via injected `Clock`
## 7. Mux core
- [ ] 7.1 `src/mux/mux.ts` + `.md` — `StorageMux extends AbstractStorage`, per-op routing through `BackendSelector` + `runWithRetry`, per-backend timeout race, AbortSignal linking
- [ ] 7.2 `src/mux/mux.test.ts` — construction validation, policy dispatch, abort surfacing
- [ ] 7.3 `src/mux/mux.spec-conformance.test.ts` — `runStorageConformance` × 4 policies × 3 `MemoryStorage` backends
## 8. Observability
- [ ] 8.1 `src/observability/events.ts` + `.md` + `.test.ts` — event topic constants (`storage-mux.backend.down`, `.recovered`, `.failover`, `.fanout-partial`), `emit*` helpers consuming `EventBusLike`
- [ ] 8.2 `src/observability/otel.ts` + `.md` + `.test.ts` — span attribute helpers (`mux.backend.name`, `mux.attempt.index`, `mux.policy`)
## 9. Barrel + exports
- [ ] 9.1 `src/index.ts` — explicit named re-exports per §6a (NO `export *`)
- [ ] 9.2 `package.json` subpath exports match every feature directory
## 10. Tests
- [ ] 10.1 `src/error-codes/error-codes.test.ts` — every `STORAGE_MUX_*` code reachable from a real call site
- [ ] 10.2 All test imports via `@suaveplan/testing/runner` (§7)
- [ ] 10.3 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for async health)
- [ ] 10.4 Backend isolation test: hung-promise primary does NOT block fallback within `backendTimeoutMs`
## 11. Documentation
- [ ] 11.1 Sibling `.md` for every implementation `.ts` (≥ 200 words, all required sections)
- [ ] 11.2 `README.md` documents each policy with a usage example
## 12. Gates
- [ ] 12.1 `bun run check`
- [ ] 12.2 `bunx turbo lint typecheck test --filter=@suaveplan/storage-mux`
- [ ] 12.3 `bunx turbo test:coverage --filter=@suaveplan/storage-mux` (100/100/100/100)
- [ ] 12.4 `bun scripts/lint/check-test-runner-imports.ts`
- [ ] 12.5 Tier-purity grep `grep -rnE "from ['\"](node:|bun:)" packages/storage/storage-mux/src/` returns empty
- [ ] 12.6 Banned-pattern scan returns empty
- [ ] 12.7 `bun scripts/openspec-conformance.ts @suaveplan/storage-mux`
## 13. Manifest & archival
- [ ] 13.1 `bun scripts/folder-reorg/generate-manifest.ts`
- [ ] 13.2 `bun scripts/generate-missing.ts`
- [ ] 13.3 Update `STATUS.md` and `SUMMARY.md`
- [ ] 13.4 `bunx markdownlint-cli2 --fix`
- [ ] 13.5 Fold `specs/storage-mux/spec.md` → `openspec/specs/storage-mux/spec.md`
- [ ] 13.6 Move change folder → `openspec/archive/<yyyy-mm-dd>-add-storage-mux/`

View File

@@ -0,0 +1,249 @@
# Design: add-storage-s3-multipart
Non-normative. The normative contract is `./specs/storage-s3/spec.md`.
## Goals
1. Large writes (> 16 MiB by default, configurable) flow through S3 multipart
without any caller code change.
2. Streaming input (`ReadableStream<Uint8Array>`) is chunked into parts on the
fly — no whole-stream buffering.
3. Parts upload in bounded parallel for WAN throughput; failures cancel
in-flight workers and clean up the open `UploadId`.
4. Error provenance is preserved at stage granularity (init / part / complete
/ abort) so callers and dashboards can attribute failures.
5. Zero changes to `StorageLike`; multipart is invisible to consumers who don't
need to know.
## Trigger threshold
S3 enforces a minimum part size of **5 MiB** (except the final part). Maximum
parts per upload is **10 000**. Maximum object size is **5 TiB**.
The default trigger is **16 MiB**. Rationale:
- Below 16 MiB a single PUT is faster (one round trip, no MPU bookkeeping).
- Above 16 MiB the latency of the three MPU control calls amortises against
the parallelism win.
- 16 MiB / 5 MiB part = 4 parts at the floor, which matches the default
concurrency of 4 — every part can start in parallel.
Caller override via `options.multipart.threshold`. Minimum legal value is
5 MiB (anything smaller would force the first part below the AWS floor).
## API surface
Public surface is unchanged. The new opt-in shape is added to `S3Options` (and
to a per-call `StorageOptions`-shaped overlay for `set` only, so single-call
overrides are possible without reconfiguring the adapter):
```ts
export interface MultipartTuning {
/** Bytes; default 16 MiB; minimum 5 MiB. */
readonly threshold?: number;
/** Bytes; default 8 MiB; minimum 5 MiB; maximum 5 GiB per AWS. */
readonly partSize?: number;
/** Concurrent part workers; default 4; minimum 1; maximum 32. */
readonly concurrency?: number;
}
// Added to S3Options
readonly multipart?: MultipartTuning;
```
`setMultipart` keeps its existing signature (it is already public) and becomes
a thin wrapper over the new engine.
## Internal engine
One private method, `#runMultipart`, owns the entire CreateMPU → parallel-parts
→ CompleteMPU lifecycle. Both `set` (when over threshold) and `setMultipart`
delegate to it.
### Pseudo-shape
```ts
type PartInput = { number: number; body: Uint8Array };
async #runMultipart(
fullKey: string,
parts: AsyncIterable<PartInput>,
tuning: Required<MultipartTuning>,
signal: AbortSignal | undefined,
): Promise<void> {
const uploadId = await this.#initMultipart(fullKey, signal);
const completed: { ETag: string; PartNumber: number }[] = [];
try {
await this.#runPartWorkers(
fullKey, uploadId, parts, tuning.concurrency, completed, signal,
);
completed.sort((a, b) => a.PartNumber - b.PartNumber);
await this.#completeMultipart(fullKey, uploadId, completed, signal);
} catch (err) {
await this.#abortMultipart(fullKey, uploadId).catch(/* logged */);
throw err;
}
}
```
### Part chunker
A generator `#chunkStream(stream, partSize)` reads from
`ReadableStream<Uint8Array>` and yields `PartInput` records exactly `partSize`
bytes long, plus a final shorter part. Memory ceiling is `partSize` + the
single in-flight buffer from `reader.read()`.
For the byte-array `set` path, the chunker is bypassed: parts are sliced
directly from the input `Uint8Array` and yielded synchronously. This avoids
an unnecessary `ReadableStream` allocation for the common case.
### Worker pool
`#runPartWorkers` keeps `tuning.concurrency` pending `UploadPart` promises in
flight. As one resolves, its `{ ETag, PartNumber }` is pushed to `completed`
and the next part is drawn from the iterator. The pool uses a structured
`Promise.race`-based scheduler — when any worker rejects, the pool aborts
remaining workers via a shared `AbortController` linked to the caller's
`signal`, then re-throws the first error.
### Abort propagation
The engine creates an internal `AbortController` (`linked`). Its signal is
passed to every `S3Client.send` call. `linked` is aborted when either:
- the caller-supplied `signal` fires (via `signal.addEventListener("abort")`),
- any part worker rejects.
The `AbortMultipartUploadCommand` is sent with a **fresh** signal (no abort
inheritance) so cleanup still runs after the caller aborts. Cleanup failures
are caught, wrapped in `MULTIPART_ABORT_FAILED`, logged at warn level, and
swallowed — re-throwing them would mask the original error.
### Error mapping
| Stage | Code |
|---|---|
| `CreateMultipartUpload` failure or missing `UploadId` | `MULTIPART_INIT_FAILED` |
| `UploadPart` failure (after retry) | `MULTIPART_PART_FAILED` (with `partNumber` in context) |
| `CompleteMultipartUpload` failure | `MULTIPART_COMPLETE_FAILED` |
| `AbortMultipartUpload` failure during cleanup | `MULTIPART_ABORT_FAILED` (logged, not thrown) |
All four are registered via `defineCodes("S3", [...] as const)` alongside the
existing entries. The legacy `MULTIPART_FAILED` code remains in the registry
(backwards-compat alias; no new call site raises it) so any consumer that
asserts on it does not see an `unknown code` lookup failure.
## Resumability — out of scope
Resumable uploads require persisting `{ key, uploadId, completedParts[] }`
across process restarts. That introduces a storage dependency, a recovery
protocol, and a garbage-collection contract for orphaned `UploadId`s — none
of which fit inside `StorageLike`. Callers needing resume should layer a
dedicated coordinator over `S3.setMultipart`, using `S3Client` directly for
`ListPartsCommand` discovery.
## Streaming input
`setStream(key, stream, opts?)` is the new internal helper, exposed via the
existing `setMultipart` public method (which already accepts a
`ReadableStream`). The byte-array `set` path constructs a synchronous part
iterator from the input buffer instead of routing through `ReadableStream` —
two ingress paths, one engine.
## Test strategy
Per CLAUDE.md rule 2 (no mocks): the multipart engine is tested end-to-end
against a real S3-compatible service via testcontainers. The genesis monorepo
already has `@suaveplan/testing-integration-garage-s3`
(`packages/testing/testing-integration-garage-s3/src/fixture/`) which spins
up Garage (S3-compatible) in a container with credentials wired into the
fixture's `env-bridge`. This change extends — does NOT replace — that fixture.
Why Garage over MinIO: Garage is already present in the monorepo fixture set,
fully supports S3 multipart (CreateMPU/UploadPart/CompleteMPU/AbortMPU), and
runs faster in CI containers than MinIO for the multi-part shapes this test
plan exercises.
If a behaviour is **only** triggerable via a specific MinIO quirk (e.g. SlowDown
throttling pattern) it will be added as a second fixture; for the requirements
in this change, Garage covers every observable shape.
### Test plan (in `tests/integration/`)
1. `set` below threshold → single `PutObject`, no MPU calls (assert via
`ListMultipartUploadsCommand` returning empty list mid-test).
2. `set` above threshold with default tuning → parts visible mid-flight via
`ListPartsCommand`, completes, GET round-trips byte-for-byte.
3. `setStream` over threshold with caller-supplied 5 MiB part size → exact
part count = `ceil(total / partSize)`.
4. Concurrency cap honoured — inject a slow part by interposing a custom
`S3ClientFactory` that wraps `UploadPart` with a measured delay; assert
the in-flight count never exceeds `concurrency`.
5. Mid-upload abort via `AbortSignal` → throws, `ListMultipartUploadsCommand`
shows zero open uploads for the key (i.e. cleanup ran).
6. Part failure → injected via a wrapping `S3ClientFactory` that fails part
3 deterministically; final error is `MULTIPART_PART_FAILED` with
`context.partNumber === 3`; cleanup ran.
7. Cleanup failure path — `AbortMultipartUpload` injected to fail; original
error still surfaces, `MULTIPART_ABORT_FAILED` appears in the logger sink.
8. `setPartial` with `offset === 0` and a > threshold payload → uses the
multipart engine; `capabilities.partialWrites` stays `false`.
9. `setPartial` with non-zero offset → still uses `defaultSetPartial`
(read-modify-write) regardless of size.
10. Part-size validation: `partSize < 5 MiB` → constructor throws
`S3StorageError(MULTIPART_INIT_FAILED, ...)` at config time, not at
upload time.
`vitest.integration.config.ts` already exists. Tests live under
`tests/integration/multipart.test.ts` so the unit suite (`src/**/*.test.ts`)
stays fast and the integration sweep is opt-in via the existing turbo task.
### Coverage
100/100/100/100 on the new module per project gate, including:
- the `linked` abort listener removal on success,
- the cleanup-after-failure branch,
- the cleanup-failure swallow branch,
- the `partSize < 5 MiB` validation guard.
## File layout
```text
packages/storage/storage-s3/src/
├── s3/
│ ├── s3.ts # set() routes to engine over threshold
│ ├── s3.md # updated: threshold + tuning + cap honesty
│ ├── s3.test.ts
│ └── s3-branches.test.ts
├── multipart/ # NEW
│ ├── multipart-engine.ts # #runMultipart, init/complete/abort helpers
│ ├── multipart-engine.md
│ ├── multipart-engine.test.ts
│ ├── part-chunker.ts # ReadableStream → AsyncIterable<PartInput>
│ ├── part-chunker.md
│ ├── part-chunker.test.ts
│ ├── worker-pool.ts # bounded-concurrency scheduler with abort
│ ├── worker-pool.md
│ ├── worker-pool.test.ts
│ └── index.ts # explicit named re-exports (rule 10/6a)
├── constants/
│ └── index.ts # DEFAULT_MULTIPART_THRESHOLD, _CONCURRENCY
├── error-codes.ts # +4 codes; MULTIPART_FAILED retained
└── types/
└── s3.types.ts # MultipartTuning interface
packages/storage/storage-s3/tests/integration/
└── multipart.test.ts # uses testing-integration-garage-s3
```
## Decisions & trade-offs
| Decision | Alternative | Why |
|---|---|---|
| Auto-promote inside `set` | Force callers to pick `setMultipart` | Public surface = `StorageLike`; large-write performance shouldn't depend on knowing about S3 specifics. |
| Default threshold 16 MiB | 100 MiB (AWS guideline) | At 16 MiB four parallel parts amortise control overhead; lower threshold is safer for chunked-drive workloads where many medium objects benefit. |
| `setPartial` whole-object multipart | Always r-m-w | Random-access patches genuinely require r-m-w (S3 has no byte-range PUT); whole-object writes via `setPartial` deserve the fast path. |
| `capabilities.partialWrites = false` | Flip to `true` | S3 multipart is whole-object only — flipping the flag would lie to capability-aware consumers (e.g. `drive-plugin-chunks` sparse-file layout). |
| Bounded `Promise.race` scheduler | `p-limit` / `p-queue` external dep | One small file, zero new catalog entries, abort handling tightly coupled to the engine. |
| Garage testcontainer | MinIO / LocalStack / mocked SDK | Real-service per rule 2; Garage already in `testing-integration-garage-s3`. |

View File

@@ -0,0 +1,177 @@
# OpenSpec Change: add-storage-s3-multipart
- **Change ID**: add-storage-s3-multipart
- **Package**: `@suaveplan/storage-s3` (modify)
- **Status**: PROPOSED
- **Date**: 2026-05-17
- **Design**: ./design.md
- **Spec delta**: ./specs/storage-s3/spec.md
## Why
`S3.set` issues a single `PutObjectCommand` for every payload, which AWS hard-caps
at 5 GiB and which leaves WAN throughput on the table for any object above
~16 MiB; `S3.setPartial` falls back to a read-modify-write that pulls the entire
object into process memory for every write. The
`storage-set-partial` change (archived 2026-04-28) explicitly scoped out the
native multipart implementation, leaving `storage-s3` as the only adapter
without a real large-write path. `S3.setMultipart` exists but is a separate
public method with serial parts and a single coarse error code, so neither
`set` nor `setPartial` benefit from it. This change closes that gap so
large writes and chunk-driven sparse-file workloads (e.g.
`@suaveplan/drive-plugin-chunks`) run on the same fast multipart path as
hand-written `setMultipart` callers, without changing the `StorageLike` surface.
## What Changes
- `S3.set` auto-promotes to multipart when `value.byteLength` exceeds a
configurable threshold (default 16 MiB); below it, behaviour is unchanged.
- `S3.setPartial` delegates to the multipart engine when `offset === 0` and
the write exceeds the threshold; non-zero-offset patches continue to use
the read-modify-write fallback.
- New internal engine (`multipart/multipart-engine.ts`, `part-chunker.ts`,
`worker-pool.ts`) handles `CreateMultipartUpload` → bounded-parallel
`UploadPart` (default concurrency 4) → `CompleteMultipartUpload`, with
full abort propagation and guaranteed `AbortMultipartUpload` cleanup.
- `S3.setMultipart` becomes a thin wrapper over the engine; its public
signature is preserved.
- `S3_CODES` gains `MULTIPART_INIT_FAILED`, `MULTIPART_PART_FAILED`,
`MULTIPART_COMPLETE_FAILED`, `MULTIPART_ABORT_FAILED`; legacy
`MULTIPART_FAILED` remains registered as a back-compat alias.
- New subpath exports: `./multipart/multipart-engine`, `./multipart/part-chunker`,
`./multipart/worker-pool`.
- Integration tests run against the existing
`@suaveplan/testing-integration-garage-s3` Garage testcontainer fixture —
no mocks.
## Summary
Wire native S3 multipart upload into the `set` / `setPartial` happy path so large
writes use `CreateMultipartUpload` + parallel `UploadPart` + `CompleteMultipartUpload`
instead of either a single 5 GiB-capped `PutObject` or the read-modify-write
`defaultSetPartial` fallback. Multipart is an **internal optimization**: the public
`StorageLike` surface is unchanged; callers may tune behaviour via an opt-in
`{ multipart: { partSize, concurrency, threshold } }` option bag.
This is the deferral noted in
`openspec/archive/2026-04-28-storage-set-partial/proposal.md` ("Out of scope —
`storage-s3` multipart-upload native impl — follow-up") and the
`storage-s3` row of that change's backend wiring table (`setPartial` →
`defaultSetPartial`, `partialWrites: false`).
## Current state
- `S3.set` (`packages/storage/storage-s3/src/s3/s3.ts:233`) issues a single
`PutObjectCommand` regardless of payload size. AWS rejects single PUTs > 5 GiB
and recommends multipart for objects > ~100 MiB for throughput.
- `S3.setPartial` (`s3/s3.ts:480`) delegates straight to
`AbstractStorage#defaultSetPartial`, which reads the whole object, mutates a
`Uint8Array` in process memory, then re-PUTs it. O(N) memory and bandwidth per
call, unusable for sparse-chunk drive layouts on real-world object sizes.
- `S3.setMultipart` (`s3/s3.ts:602`) already implements a correct CreateMPU +
serial UploadPart + CompleteMPU loop, but: (a) it is a separate public method
callers must opt into by name, so `set` / `setPartial` never benefit from it;
(b) part uploads are strictly serial, leaving WAN throughput on the table;
(c) only a single `MULTIPART_FAILED` code exists, hiding which stage failed.
- `S3.capabilities.partialWrites` is `false`. Once multipart is wired through
`setPartial` for whole-object writes, the flag stays `false` (S3 multipart
is **not** random-access — it cannot patch a byte range of an existing object)
but consumers gain a documented large-object path via the same method.
## What changes
1. **Auto-promote `set` to multipart above a threshold.** When
`value.byteLength > options.multipart?.threshold ?? DEFAULT_MULTIPART_THRESHOLD`
(default 16 MiB), `S3.set` performs CreateMultipartUpload → parallel
UploadPart → CompleteMultipartUpload internally instead of `PutObject`. Below
the threshold, behaviour is unchanged.
2. **Streaming input path.** Add an internal helper `setStream(key, stream, opts)`
that chunks a `ReadableStream<Uint8Array>` into parts on the fly without
buffering the whole stream. `setMultipart` becomes a thin wrapper that
delegates to this helper. The byte-array `set` path uses the same engine via
a `ReadableStream.from`-style adapter so there is one canonical implementation.
3. **Bounded parallel parts.** Parts are uploaded with a worker-pool of
`options.multipart?.concurrency ?? DEFAULT_MULTIPART_CONCURRENCY` (default 4)
concurrent `UploadPart` calls. Order is preserved in the final
`MultipartUpload.Parts` list by part number.
4. **Abort + cleanup.** Any failure or `AbortSignal` firing during parts MUST
propagate to in-flight part workers (cancelling their underlying
`S3Client.send` via `abortSignal`) and MUST issue exactly one
`AbortMultipartUploadCommand` for the open `UploadId`. Cleanup is best-effort
(logged, not re-thrown) but is mandatory — leaked MPUs accrue storage cost.
5. **Finer error codes.** `MULTIPART_FAILED` is split via `defineCodes` into
`MULTIPART_INIT_FAILED`, `MULTIPART_PART_FAILED`, `MULTIPART_COMPLETE_FAILED`,
`MULTIPART_ABORT_FAILED`. `MULTIPART_FAILED` is retained as a
backwards-compatibility alias on the registry (raised only by call sites
that pre-date this change and were not migrated; the four new codes cover
every multipart call site post-change).
6. **`setPartial` for whole-object writes.** When `offset === 0` and the call
semantically writes the entire object (caller intent expressed via
`bytes.byteLength` being the whole object), `setPartial` delegates to the
same multipart engine. Random-access patches (non-zero offset) continue to
use `defaultSetPartial`'s read-modify-write — multipart cannot patch a byte
range and `capabilities.partialWrites` remains `false` to advertise this
honestly.
## Out of scope
- **Resumable uploads** — persisting `UploadId` + completed-part list across
process restarts. Callers needing resume should use a dedicated surface
(future `drive-plugin-resume` or equivalent); this change keeps the
`StorageLike` contract simple.
- **Random-access patching via multipart.** S3 multipart cannot replace an
arbitrary byte range of an existing object. `capabilities.partialWrites`
stays `false`.
- **Server-side encryption / SSE-KMS option passthrough.** Tracked separately.
- **Copy-part (`UploadPartCopy`) for large server-side copies.** Tracked
separately under `S3.copy` enhancements.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/storage-s3` to enumerate. The
change is additive on the public surface (`set` / `setPartial` signatures
unchanged; new options are opt-in; new error codes are siblings of an existing
one), so the blast radius is small:
- `@suaveplan/storage` — [ ] tests run / [ ] docs reviewed / [x] no action needed
(reason: `storage-s3` consumes `AbstractStorage` from `storage` and adds new
behaviour internally; no `storage` surface changes.)
- `@suaveplan/drive-plugin-chunks` — [x] tests run / [x] docs reviewed / [ ] no action needed
(reason: declared consumer of `setPartial` via `DriveStorage`; benefits
directly from the large-write path. Verify conformance suite still passes.)
- `@suaveplan/drive-plugin-cache` — [ ] tests run / [ ] docs reviewed / [x] no action needed
(reason: cache layer round-trips byte ranges, never crosses the multipart
threshold in practice; surface unchanged.)
- `@suaveplan/storage-cdn` / `storage-cdn-*` — [ ] tests run / [ ] docs reviewed / [x] no action needed
(reason: CDN adapters do not consume `storage-s3` directly.)
`storage-s3` is not a foundation package per §14a, but it sits under `drive-*`
chunk plumbing — the dependent sweep (§13 step 10,
`bunx turbo test --filter=...@suaveplan/storage-s3`) MUST be run before archive.
## Migration
No public API changes; existing callers see no behaviour difference below the
multipart threshold. Callers who explicitly assert on
`S3StorageError.code === "S3.MULTIPART_FAILED"` should widen the check to the
new four-code set:
```ts
import { S3_CODES } from "@suaveplan/storage-s3";
const multipartCodes = new Set<string>([
S3_CODES.MULTIPART_INIT_FAILED,
S3_CODES.MULTIPART_PART_FAILED,
S3_CODES.MULTIPART_COMPLETE_FAILED,
S3_CODES.MULTIPART_ABORT_FAILED,
S3_CODES.MULTIPART_FAILED, // legacy alias, still emitted by no call site post-change
]);
if (err instanceof S3StorageError && multipartCodes.has(err.code)) { … }
```
Search command for consumers:
```bash
grep -rln "MULTIPART_FAILED" packages/ | grep -v "/dist/\|/coverage/\|/storage-s3/"
```

View File

@@ -0,0 +1,235 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: storage-s3 (add-storage-s3-multipart)
**Package**: `@suaveplan/storage-s3`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
> Test paths name files this change will create; they do not exist yet.
## ADDED Requirements
### Requirement: Auto-promote `set` to multipart above threshold
`S3.set(key, value, options?, signal?)` MUST internally upload `value` via S3
multipart (`CreateMultipartUpload` → parallel `UploadPart` →
`CompleteMultipartUpload`) when `value.byteLength` exceeds the configured
threshold (`S3Options.multipart.threshold`, default 16 MiB). At or below the
threshold, behaviour MUST remain a single `PutObjectCommand`. The public
signature, return type, and observable success semantics MUST NOT change.
#### Scenario: large byte array triggers multipart
- **WHEN** a caller invokes `s3.set("k", new Uint8Array(20 * 1024 * 1024))`
against an `S3` instance with default tuning
- **THEN** the underlying client issues exactly one
`CreateMultipartUploadCommand`, one or more `UploadPartCommand`, exactly one
`CompleteMultipartUploadCommand`, and zero `PutObjectCommand` for that key
#### Scenario: small byte array uses single PUT
- **WHEN** a caller invokes `s3.set("k", new Uint8Array(1024))`
- **THEN** the underlying client issues exactly one `PutObjectCommand` and
zero `CreateMultipartUploadCommand` for that key
**Verified by:** `tests/integration/multipart.test.ts::"large byte array triggers multipart"`
AND `tests/integration/multipart.test.ts::"small byte array uses single PUT"`
---
### Requirement: Streaming input is chunked without whole-stream buffering
`S3.setMultipart(key, stream, opts?)` MUST consume a
`ReadableStream<Uint8Array>` and emit `UploadPart` requests of exactly
`opts.partSize ?? S3Options.multipart.partSize ?? DEFAULT_PART_SIZE` bytes
each (except the final part, which MAY be shorter). At no point MAY the
implementation buffer more than two parts' worth of bytes in memory
(in-flight read + accumulating chunker buffer).
#### Scenario: stream of N bytes produces ceil(N / partSize) parts
- **WHEN** a caller invokes `s3.setMultipart("k", stream)` where `stream`
yields 25 MiB total and `partSize` is 5 MiB
- **THEN** the upload completes with exactly 5 `UploadPartCommand` calls and
the round-tripped GET equals the input byte-for-byte
**Verified by:** `src/multipart/part-chunker.test.ts::"emits exact part-size chunks then a ragged final part"`
AND `tests/integration/multipart.test.ts::"stream over threshold produces expected part count"`
---
### Requirement: Parts upload with bounded parallelism
The multipart engine MUST upload parts with at most
`S3Options.multipart.concurrency ?? DEFAULT_MULTIPART_CONCURRENCY` (default 4)
`UploadPartCommand` calls in flight simultaneously. Concurrency MUST be at
least 1 and at most 32; values outside this range MUST be clamped or rejected
at construction time. The final `MultipartUpload.Parts` list passed to
`CompleteMultipartUploadCommand` MUST be sorted ascending by `PartNumber`
regardless of completion order.
#### Scenario: concurrency cap is honoured
- **WHEN** a caller invokes `setMultipart` with 12 parts and concurrency 4,
using an injected `S3ClientFactory` that records the count of pending
`UploadPart` promises at each tick
- **THEN** the recorded in-flight count never exceeds 4 and the final
`CompleteMultipartUploadCommand.MultipartUpload.Parts` array is sorted by
`PartNumber`
**Verified by:** `src/multipart/worker-pool.test.ts::"never exceeds the configured concurrency"`
AND `tests/integration/multipart.test.ts::"concurrency cap is honoured under injected part latency"`
---
### Requirement: Abort propagation cancels in-flight parts
The multipart engine MUST react to a caller-supplied `AbortSignal` firing
mid-upload by cancelling every in-flight `UploadPartCommand` (via a linked
abort signal passed to `S3Client.send`), MUST stop drawing further parts
from the input iterator, and MUST issue exactly one
`AbortMultipartUploadCommand` for the open `UploadId` before re-throwing.
#### Scenario: caller aborts mid-upload
- **WHEN** a caller starts `setMultipart` on a 50 MiB stream then calls
`controller.abort()` after the second part begins
- **THEN** the call rejects, the underlying client receives at least one
`AbortMultipartUploadCommand` for the key, and a subsequent
`ListMultipartUploadsCommand` for the bucket returns no open uploads
for that key
**Verified by:** `tests/integration/multipart.test.ts::"caller abort cancels parts and cleans up"`
---
### Requirement: Per-stage error codes
Multipart failures MUST raise `S3StorageError` with a code matching the failed
stage: `S3.MULTIPART_INIT_FAILED` when `CreateMultipartUpload` fails or
returns no `UploadId`; `S3.MULTIPART_PART_FAILED` when an `UploadPart` call
fails after retries, with `partNumber` populated in the error `context`;
`S3.MULTIPART_COMPLETE_FAILED` when `CompleteMultipartUpload` fails;
`S3.MULTIPART_ABORT_FAILED` when the cleanup `AbortMultipartUpload` itself
fails (this code is logged at warn level and MUST NOT replace the original
thrown error). The legacy code `S3.MULTIPART_FAILED` MUST remain registered
via `defineCodes` as a backwards-compatibility alias but MUST NOT be raised
by any post-change call site.
#### Scenario: part failure surfaces stage-specific code
- **WHEN** a wrapping `S3ClientFactory` makes the third `UploadPartCommand`
reject deterministically and the caller invokes `setMultipart` on a stream
with at least three parts
- **THEN** the call rejects with `S3StorageError` whose `code` is
`S3.MULTIPART_PART_FAILED` and whose `context.partNumber` is `3`
#### Scenario: cleanup failure does not mask original error
- **WHEN** an `UploadPart` rejection triggers cleanup, and the injected
`AbortMultipartUploadCommand` also rejects
- **THEN** the call still rejects with the original
`S3.MULTIPART_PART_FAILED`, and a logger record at warn level carries
the `S3.MULTIPART_ABORT_FAILED` code
**Verified by:** `src/multipart/multipart-engine.test.ts::"maps stage failures to dedicated codes"`
AND `tests/integration/multipart.test.ts::"cleanup failure is logged and does not mask original error"`
---
### Requirement: `setPartial` whole-object writes use the multipart engine
`S3.setPartial(key, offset, bytes, signal?)` MUST delegate to the multipart
engine when `offset === 0` and `bytes.byteLength` exceeds the configured
multipart threshold. For all other cases (non-zero offset, or below
threshold) it MUST continue to use `AbstractStorage#defaultSetPartial`'s
read-modify-write fallback. `S3.capabilities.partialWrites` MUST remain
`false` to honestly advertise that random-access byte-range patching is not
natively supported by S3 multipart.
#### Scenario: zero-offset large write takes the multipart path
- **WHEN** a caller invokes
`s3.setPartial("k", 0, new Uint8Array(20 * 1024 * 1024))`
- **THEN** the underlying client issues `CreateMultipartUploadCommand` +
`UploadPartCommand`(s) + `CompleteMultipartUploadCommand`, and zero
`GetObjectCommand` or `PutObjectCommand` for the key
#### Scenario: non-zero-offset write keeps read-modify-write
- **WHEN** a caller invokes `s3.setPartial("k", 1024, new Uint8Array(512))`
on an existing 4 KiB object
- **THEN** the underlying client issues `GetObjectCommand` followed by
`PutObjectCommand` and zero `CreateMultipartUploadCommand`
#### Scenario: capability flag stays honest
- **WHEN** a caller reads `s3.capabilities.partialWrites`
- **THEN** the value is `false`
**Verified by:** `tests/integration/multipart.test.ts::"setPartial at offset zero uses multipart"`
AND `tests/integration/multipart.test.ts::"setPartial at non-zero offset uses read-modify-write"`
AND `src/s3/s3.test.ts::"capabilities.partialWrites stays false"`
---
### Requirement: Part-size and tuning validation
`S3` MUST validate `S3Options.multipart.partSize` against the AWS-mandated
minimum of 5 MiB at construction time and throw
`S3StorageError(S3.MULTIPART_INIT_FAILED, ...)` with a message naming the
offending value. `S3Options.multipart.threshold` MUST be greater than or
equal to `MIN_PART_SIZE` (5 MiB). `S3Options.multipart.concurrency` MUST be
in the inclusive range `[1, 32]`; out-of-range values MUST throw at
construction time. These checks MUST run before any network call is issued.
#### Scenario: sub-floor part size is rejected at construction
- **WHEN** a caller constructs `new S3({ ..., multipart: { partSize: 1024 } })`
- **THEN** the constructor throws `S3StorageError` whose `code` is
`S3.MULTIPART_INIT_FAILED` and whose message names the value `1024`
**Verified by:** `src/s3/s3.test.ts::"rejects sub-floor partSize at construction time"`
AND `src/s3/s3.test.ts::"rejects out-of-range concurrency at construction time"`
---
### Requirement: New multipart subpath exports
`@suaveplan/storage-s3/package.json` MUST expose subpath exports for the new
multipart modules so consumers can tree-shake: `./multipart/multipart-engine`,
`./multipart/part-chunker`, `./multipart/worker-pool`. The root barrel
(`src/index.ts`) MUST re-export them under a `multipart` namespace
(`export * as multipart from "./multipart/index.js"`); feature barrels
(`src/multipart/index.ts`) MUST use explicit named re-exports per
project rule §6a — no `export *`.
#### Scenario: subpath import resolves
- **WHEN** a consumer writes
`import { runMultipart } from "@suaveplan/storage-s3/multipart/multipart-engine"`
- **THEN** the import resolves to the engine implementation without pulling
the full `S3` class into the consumer's bundle
**Verified by:** `src/multipart/multipart-engine.test.ts::"is reachable via the dedicated subpath export"`
---
### Requirement: Multipart engine is verified against a real S3-compatible service
The package MUST exercise the multipart engine end-to-end (init, parallel
parts, complete, abort, cleanup, error mapping) against a real S3-compatible
service via testcontainers per project rule 2 (no mocks). The genesis
monorepo's `@suaveplan/testing-integration-garage-s3` fixture MUST be the
host. No unit test MAY substitute a mocked AWS SDK for these behaviours.
#### Scenario: integration suite runs against Garage container
- **WHEN** the integration test config (`vitest.integration.config.ts`) is
executed via `bunx turbo test:integration --filter=@suaveplan/storage-s3`
- **THEN** every test in `tests/integration/multipart.test.ts` uses the
Garage-backed `S3Client` from `@suaveplan/testing-integration-garage-s3`
rather than a mocked client, and the suite exits zero
**Verified by:** `tests/integration/multipart.test.ts::"integration suite is wired to the Garage testcontainer fixture"`

View File

@@ -0,0 +1,107 @@
# Tasks: add-storage-s3-multipart
## 1. Error codes + constants
- [ ] 1.1 Extend `S3_CODES` via `defineCodes` with `MULTIPART_INIT_FAILED`,
`MULTIPART_PART_FAILED`, `MULTIPART_COMPLETE_FAILED`,
`MULTIPART_ABORT_FAILED`. Retain `MULTIPART_FAILED` as a documented
legacy alias (no new call site raises it).
- [ ] 1.2 Add `DEFAULT_MULTIPART_THRESHOLD = 16 * 1024 * 1024` and
`DEFAULT_MULTIPART_CONCURRENCY = 4` to `src/constants/index.ts`.
Verify `DEFAULT_PART_SIZE` floor (≥ 5 MiB) and add a constant
`MIN_PART_SIZE = 5 * 1024 * 1024`.
- [ ] 1.3 Add `MultipartTuning` interface to `src/types/s3.types.ts`; extend
`S3Options` with `multipart?: MultipartTuning`.
## 2. Internal engine
- [ ] 2.1 Create `src/multipart/part-chunker.ts` + sibling `.md` + `.test.ts` —
`chunkStream(stream, partSize): AsyncIterable<PartInput>`; covers exact
multiples, ragged final part, empty stream, abort mid-read.
- [ ] 2.2 Create `src/multipart/worker-pool.ts` + sibling `.md` + `.test.ts` —
bounded-concurrency scheduler using `Promise.race` + a linked
`AbortController`; covers cap enforcement, first-failure aborts peers,
caller-abort cancels in-flight workers.
- [ ] 2.3 Create `src/multipart/multipart-engine.ts` + sibling `.md` +
`.test.ts` — `runMultipart(client, bucket, key, parts, tuning, signal)`
with `initMultipart` / `completePartsList` / `abortMultipart` helpers;
maps each stage failure to its dedicated code.
- [ ] 2.4 Create `src/multipart/index.ts` — explicit named re-exports only
(rule 10 / §6a). No `export *`.
## 3. Wire into `S3.set` + `S3.setPartial`
- [ ] 3.1 In `s3/s3.ts`, branch `S3.set` on
`value.byteLength > (this.options.multipart?.threshold ?? DEFAULT_MULTIPART_THRESHOLD)`
to call the engine via a synchronous part iterator built from the
buffer; below the threshold keep the existing `PutObjectCommand`.
- [ ] 3.2 In `S3.setPartial`, when `offset === 0` AND
`bytes.byteLength > threshold`, delegate to the engine (whole-object
write); otherwise keep `defaultSetPartial`. Document in
`s3/s3.md` that `capabilities.partialWrites` stays `false` and explain
why.
- [ ] 3.3 Rewrite `S3.setMultipart` as a thin wrapper over the new engine
using `chunkStream` for the input `ReadableStream`. Preserve public
signature; preserve `setMultipart` subpath export.
- [ ] 3.4 Validate `partSize >= MIN_PART_SIZE` at construction time; throw
`S3StorageError(MULTIPART_INIT_FAILED, ...)` with a clear message.
## 4. Subpath exports + barrels
- [ ] 4.1 Add `./multipart/multipart-engine`, `./multipart/part-chunker`,
`./multipart/worker-pool` entries to `package.json` `exports`.
- [ ] 4.2 Update `src/index.ts` to re-export the new multipart helpers via
a namespace export (`export * as multipart from "./multipart/index.js"`)
so consumers can reach them without breaking the root barrel rule.
## 5. Docs
- [ ] 5.1 Update `README.md` Modules + Features sections to mention auto
multipart, tuning, and the four new error codes.
- [ ] 5.2 Update `s3/s3.md` (Implementation Notes) — threshold rationale,
capability honesty, abort/cleanup contract.
- [ ] 5.3 Add `multipart/multipart-engine.md`, `part-chunker.md`,
`worker-pool.md` per §4 (≥200 words, basic + advanced examples, API
reference, implementation notes).
## 6. Integration tests (real Garage container)
- [ ] 6.1 Confirm `@suaveplan/testing-integration-garage-s3` fixture surface
covers MPU operations end-to-end; if a missing capability is found
(e.g. mid-flight `ListPartsCommand` exposure), extend the fixture in
the same change.
- [ ] 6.2 Add `tests/integration/multipart.test.ts` covering the 10 cases
in `design.md#test-plan`. No mocks — use a wrapping `S3ClientFactory`
to inject controlled delays / failures around the real Garage client.
- [ ] 6.3 Wire integration suite into `vitest.integration.config.ts` (already
present in the package).
## 7. Verify (per §13 pre-ship sequence)
- [ ] 7.1 Spec reconciliation (§13 step 0): every `### Requirement` in
`specs/storage-s3/spec.md` has matching impl + tests; every
`**Verified by:**` path resolves verbatim.
- [ ] 7.2 `bunx turbo lint typecheck test --filter=@suaveplan/storage-s3`
green.
- [ ] 7.3 `bunx turbo test:coverage --filter=@suaveplan/storage-s3`
100/100/100/100.
- [ ] 7.4 Integration sweep: `bunx turbo test:integration --filter=@suaveplan/storage-s3`
green against the Garage testcontainer.
- [ ] 7.5 Dependent sweep:
`bunx turbo test --filter=...@suaveplan/storage-s3` green.
- [ ] 7.6 Banned-pattern / barrel-shape / runner-import scans (§13 steps
4 / 4a / 4b) — zero output.
- [ ] 7.7 `bun scripts/openspec-conformance.ts @suaveplan/storage-s3` PASS.
- [ ] 7.8 Run `openspec validate add-storage-s3-multipart --strict` — must
report PASS.
## 8. Archive
- [ ] 8.1 Refresh root docs per §13b — `MANIFEST.md`, `MISSING.yml`,
`STATUS.md`, `SUMMARY.md`; run `bunx markdownlint-cli2 --fix`.
- [ ] 8.2 Fold `specs/storage-s3/spec.md` into
`openspec/specs/storage-s3/spec.md` (this is the first change to
establish that canonical capability file — create the folder under
`openspec/specs/` only at this archival step, never before).
- [ ] 8.3 Move `openspec/changes/add-storage-s3-multipart/` →
`openspec/archive/<yyyy-mm-dd>-add-storage-s3-multipart/`.

View File

@@ -0,0 +1,161 @@
# Design — `@suaveplan/webauthn-mds3`
## 1. Sibling package vs. in-tree module
**Decision:** sibling package `@suaveplan/webauthn-mds3` at `packages/auth/webauthn-mds3/`.
`@suaveplan/webauthn` is browser-and-universal mixed-tier and intentionally slim. Bundling MDS3 would drag four heavy concerns into it: a `FetchLike` network surface, a long-lived refresh loop (a state machine `webauthn` deliberately avoids), JWS chain verification (`@suaveplan/jws` + `@suaveplan/x509`), and a multi-megabyte FIDO Alliance metadata blob in test fixtures. Every consumer that only needs `register` / `authenticate` would pay that cost. A sibling keeps the integration opt-in, lets MDS3 evolve on its own release cadence (the FIDO Alliance ships schema bumps independently of WebAuthn), and lets server-side relying-party deployments depend on `webauthn-mds3` without touching the browser-tier surface at all.
The seam back into `webauthn` is a single injected interface (`MetadataResolver`) — type-only at the `webauthn` side via `import type`, so the runtime dep graph stays acyclic.
## 2. Package layout
```text
packages/auth/webauthn-mds3/
package.json # subpath exports per §6
README.md
src/
constants/
fido-root.ts + .md # pinned root CA DER + SHA-256, defaults
errors/
error-codes.ts + .md # defineCodes("WEBAUTHN_MDS3", [...])
errors.test.ts
schemas/
metadata-blob.schemas.ts + .md # Zod for the whole MDS3 v3 surface
fetch/
fetch-blob.ts + .md + .test.ts
verify/
verify-blob.ts + .md + .test.ts
cache/
metadata-cache.ts + .md + .test.ts
refresher/
metadata-refresher.ts + .md + .test.ts
resolver/
types.ts + .md # MetadataResolver, MetadataLookup
memory.ts + .md + .test.ts
cached.ts + .md + .test.ts
observability/
metrics.ts + .md + .test.ts
__fixtures__/
mds3-sample-blob.jwt
mds3-corrupted-blob.jwt
index.ts
__benchmarks__/
verify-blob.bench.ts
resolve-aaguid.bench.ts
index.ts # explicit named re-exports (rule 10)
```
## 3. Key types
```ts
// src/resolver/types.ts
export interface MetadataLookup {
readonly statement: MetadataStatement;
readonly currentStatus: AuthenticatorStatus;
readonly statusReports: readonly StatusReport[];
readonly attestationRootCertificates: readonly Uint8Array[]; // DER
readonly aaguid?: Uint8Array; // 16 bytes
readonly attestationCertificateKeyIds?: readonly Uint8Array[]; // SHA-1 SKIs
readonly lastUpdate: number; // epoch ms
}
export interface MetadataResolver {
resolveByAaguid(aaguid: Uint8Array): Promise<MetadataLookup | undefined>;
resolveByAttestationCertificateKeyId(
keyId: Uint8Array,
): Promise<MetadataLookup | undefined>;
}
```
```ts
// new optional field on @suaveplan/webauthn's VerifyAttestationInput
export interface VerifyAttestationInput {
// ...existing fields...
readonly metadata?: import("@suaveplan/webauthn-mds3").MetadataResolver;
readonly revokedStatuses?: ReadonlySet<AuthenticatorStatus>; // override default
}
// new optional field on AttestationVerifyResult
export interface AttestationVerifyResult {
readonly trusted: boolean;
readonly attestationType: AttestationType;
readonly trustPath: readonly Uint8Array[];
readonly metadata?: MetadataLookup; // present only when resolver injected
}
```
## 4. Refresh policy
| Trigger | Behaviour |
|---|---|
| First start (no cached blob) | Fetch immediately, verify, cache, schedule next at `nextUpdate - jitter` |
| Scheduled tick | Fetch, verify, write-through cache; on verify-fail keep prior payload and emit `webauthn_mds3.refresh_total{outcome=verify_failed}` |
| `nextUpdate` absent | Fall back to `intervalMs` (default 24h) |
| Concurrent call while refresh in flight | Throw `REFRESHER_ALREADY_RUNNING` (no silent dedup — caller-visible) |
| `signal.aborted` | Cancel pending refresh, throw `REFRESHER_ABORTED` |
| Jitter | Uniform random in `[-5min, +5min]` to avoid thundering herd against the MDS endpoint |
The refresher is decoupled from the resolver — a resolver can be backed by a cache that some other process refreshes (e.g. a cron job, a Cloudflare Cron Trigger). `createCachedResolver` accepts an optional `refresher` that it `start()`s on first `resolve*` call.
## 5. Cache contract
The cache is a thin wrapper around `StorageLike<MetadataBlobPayload>` from `@suaveplan/types`:
- Key namespace `webauthn-mds3:blob:v1` — bump the trailing version on any schema migration.
- Single key per environment (one blob = one MDS3 endpoint). Multi-tenancy is the consumer's problem — wrap the `StorageLike` with a tenant-prefixing decorator.
- Read failures surface `CACHE_READ_FAILED` with the underlying cause attached; the resolver MAY treat a missing cache entry as "fetch now" if a refresher is wired.
- Writes are atomic from the resolver's perspective — the cache writes the verified payload only after `verifyMetadataBlob` resolves successfully.
## 6. Attestation-time lookup flow
```text
verifyAttestation(input):
1. result = await registry.get(input.attestationObject.fmt)(...)
2. if input.metadata === undefined: return result // strict back-compat
3. lookup =
(fmt === "fido-u2f" || attestedCredentialData?.aaguid is all-zero)
? input.metadata.resolveByAttestationCertificateKeyId(ski(result.trustPath[0]))
: input.metadata.resolveByAaguid(attestedCredentialData.aaguid)
4. if lookup === undefined: return { ...result, metadata: undefined }
5. revoked = (input.revokedStatuses ?? DEFAULT_REVOCATION_STATUSES).has(lookup.currentStatus)
6. return { ...result, trusted: result.trusted && !revoked, metadata: lookup }
```
`DEFAULT_REVOCATION_STATUSES = new Set(["REVOKED", "USER_KEY_REMOTE_COMPROMISE", "USER_KEY_PHYSICAL_COMPROMISE", "ATTESTATION_KEY_COMPROMISE", "USER_VERIFICATION_BYPASS"])`.
`fido-u2f` authenticators are MDS3-keyed by attestation-cert SKI (FIDO MDS3 §3.1.6) because they predate AAGUID. The attestation cert SKI is computed inside `webauthn-mds3` so `webauthn` doesn't need an X.509 parser at runtime.
## 7. Security model
| Concern | Mitigation |
|---|---|
| Blob substitution (attacker serves a benign blob omitting a compromised authenticator) | Pin the FIDO Alliance root CA in `constants/fido-root.ts`; verify the JWS `x5c` chain terminates at the pinned root; reject any chain ending elsewhere |
| Algorithm downgrade | Allow-list RS256 / ES256 / ES384 only; reject `none`, `HS256`, and any other alg with `BLOB_ALG_DISALLOWED` |
| Stale blob masking a known-compromised authenticator | Reject when `now > nextUpdate + grace`; the refresher pulls new blobs on schedule; OTel exposes `webauthn_mds3.blob_age_ms` for monitoring |
| Replay of a previous blob (`no` rollback) | Refuse to overwrite the cached blob with a payload whose `no` field is lower than the cached `no` |
| Schema drift | All blob fields pass through Zod (`MetadataBlobPayloadSchema`); unknown fields are tolerated but typed-out — additive FIDO Alliance extensions don't crash the verifier |
| TOCTOU between resolve and trust decision | The trust decision happens inline in `verifyAttestation`; the resolver returns a `MetadataLookup` snapshot, not a live handle, so concurrent refreshes can't flip the result mid-flight |
| `FetchLike` impersonation in tests | Production wires a real `fetch`; tests inject deterministic stubs returning fixture bytes — no live network, no `vi.mock` (per genesis rule 2) |
## 8. Companion delta to `@suaveplan/webauthn`
Because the `VerifyAttestationInput` and `AttestationVerifyResult` shapes gain optional fields and the post-verifier flow grows two trust-altering steps, the integration is captured as a `## MODIFIED Requirements` block in `specs/webauthn/spec.md` rather than left implicit. The modified requirement keeps every original scenario (the back-compat path) and adds two new scenarios (resolver-injected augmentation; revoked-status downgrade), each with a `**Verified by:**` line pointing into the existing `verify-attestation.test.ts` (extended in task 13.4).
## 9. Tier purity
`@suaveplan/webauthn-mds3` is **universal-tier**:
- No `globalThis.fetch` reference — `FetchLike` is injected.
- No `localStorage` / `IndexedDB` — `StorageLike` is injected.
- No `Date.now()` — `Clock` is injected.
- No DOM types in the public surface — `MetadataLookup` carries `Uint8Array` not `Blob`.
- `tests/dep-graph.test.ts` (in the package) enforces zero DOM imports.
This means server-side relying parties (Bun, Node, Workers, Deno) and browser-tier consumers can both depend on it without polyfills.
## 10. What this change deliberately does NOT do
- Does **not** ship an MDS3 publishing tool (creating blobs is for the FIDO Alliance only).
- Does **not** ship a CRL / OCSP poller for attestation roots — that's a separate trust-chain concern outside the FIDO metadata contract.
- Does **not** wire MDS3 into the `vault` unlock path — `verifyAssertion` (the unlock path) does not consume attestation, so MDS3 has no role there.

View File

@@ -0,0 +1,126 @@
# `@suaveplan/webauthn-mds3` — FIDO Metadata Service v3 integration
**Package**: `@suaveplan/webauthn-mds3` (NEW)
**Status**: PROPOSED
**Type**: new-package (genesis-original)
**Date**: 2026-05-17
**Path**: `packages/auth/webauthn-mds3/`
**Design**: ./design.md
**Spec delta**: ./specs/webauthn-mds3/spec.md
**Companion delta**: ./specs/webauthn/spec.md (`## MODIFIED Requirements`)
## Why
The `enterprise-webauthn` change (archived 2026-05-11) shipped `verifyAttestation` returning a `trustPath: X509[]` plus an `attestationType`, but explicitly deferred FIDO Metadata Service v3 (MDS3) integration to a follow-up:
> "MDS3 (FIDO Metadata Service) integration — `verifyAttestation` returns the `trustPath: X509[]` and `attestationType` so callers can perform MDS3 lookups against their own metadata source. Bundling MDS3 fetch + caching expands scope into a separate package and is a follow-up."
> — `openspec/archive/2026-05-11-enterprise-webauthn/proposal.md`, "Out of scope"
Without MDS3 a relying party cannot answer the production-critical questions every passkey deployment eventually has to answer: is this AAGUID a known certified authenticator, is its certification still valid, is it on the revocation list, what attestation root CAs are trusted for this AAGUID, and what attachment / user-verification / key-protection statuses did the FIDO Alliance certify it at. Today every genesis consumer that wants those answers has to hand-roll the MDS3 blob fetch, JWS verification, FIDO root-CA pin, AAGUID lookup, and refresh schedule — exactly the duplication this monorepo exists to eliminate.
This change introduces a sibling package, `@suaveplan/webauthn-mds3`, that owns the MDS3 wire protocol end-to-end (download, JWS verify against the pinned FIDO Alliance root, cache, periodic refresh, AAGUID-keyed lookup) and exposes an injectable `MetadataResolver` that `@suaveplan/webauthn`'s attestation pipeline consumes through a new optional seam. Absent injection, the existing `verifyAttestation` contract is preserved byte-for-byte (strict back-compat).
## What changes
### New package: `@suaveplan/webauthn-mds3`
Universal-tier package at `packages/auth/webauthn-mds3/`. No browser-only or Node-only APIs — `FetchLike` and `StorageLike` are injected from `@suaveplan/types`.
#### MDS3 blob download + JWS verification
- ADDED: `fetchMetadataBlob({ fetch, url? }): Promise<RawBlob>` — fetches the MDS3 JWT (`application/jwt`) from `url` (default `https://mds.fidoalliance.org/`) using the injected `FetchLike` so consumers can layer in retries, proxies, or a Cloudflare-Workers `fetch`.
- ADDED: `verifyMetadataBlob({ jws, trustAnchors? }): Promise<MetadataBlobPayload>` — verifies the compact-JWS signature via `@suaveplan/jws` using the FIDO Alliance MDS3 root CA chain. The root CA fingerprint is pinned in `constants/fido-root.ts`; consumers can override via `trustAnchors` for staging / private metadata services. Verifies `x5c` chain to root, validates not-before / not-after, enforces RS256 / ES256 alg allow-list, decodes the JSON payload, and Zod-validates it against `MetadataBlobPayloadSchema` (FIDO MDS3 §3.1.7).
- ADDED: `MetadataBlobPayload`, `MetadataStatement`, `AuthenticatorStatus`, `StatusReport`, `BiometricStatusReport`, `EcdaaTrustAnchor` — Zod schemas tracking the published MDS3 v3 schema verbatim.
#### Cache + scheduled refresh
- ADDED: `MetadataCache` — `StorageLike<MetadataBlobPayload>`-backed cache with `legalHeader`, `no`, `nextUpdate`, and `entries` preserved. Cache key namespace `webauthn-mds3:blob:v1`.
- ADDED: `createMetadataRefresher({ fetch, storage, clock, logger, intervalMs?, signal? }): MetadataRefresher` — schedules refresh at `payload.nextUpdate - jitter`; falls back to `intervalMs` (default 24h) when the blob omits a `nextUpdate`. Honors `AbortSignal`; never starts a second refresh while one is in flight; emits OTel `webauthn_mds3.refresh_total{outcome}` counter.
#### AAGUID lookup API
- ADDED: `MetadataResolver` interface — `resolveByAaguid(aaguid: Uint8Array): Promise<MetadataLookup | undefined>` and `resolveByAttestationCertificateKeyId(keyId: Uint8Array): Promise<MetadataLookup | undefined>` (U2F authenticators are MDS3-keyed by attestation cert SKI, not AAGUID — FIDO MDS3 §3.1.6).
- ADDED: `MetadataLookup` — `{ statement, currentStatus, statusReports, attestationRootCertificates: Uint8Array[], aaguid?, attestationCertificateKeyIds?, lastUpdate }`.
- ADDED: `createMemoryResolver({ blob }): MetadataResolver` — synchronous in-memory resolver over a verified payload. Used by tests and by consumers that ship their own refresh loop.
- ADDED: `createCachedResolver({ cache, refresher? }): MetadataResolver` — production resolver that reads the cache and optionally drives a refresher.
#### Integration seam back into `@suaveplan/webauthn`
- ADDED (in `@suaveplan/webauthn`): optional `metadata?: MetadataResolver` field on `VerifyAttestationInput`. When supplied, after the per-format verifier returns a `trustPath`, `verifyAttestation` resolves the AAGUID (or attestation-cert SKI for `fido-u2f`) and, when a `MetadataLookup` is returned, augments the result with `metadata: MetadataLookup` and downgrades `trusted` to `false` when `currentStatus` is `REVOKED`, `USER_KEY_REMOTE_COMPROMISE`, `USER_KEY_PHYSICAL_COMPROMISE`, `ATTESTATION_KEY_COMPROMISE`, or any other FIDO `AuthenticatorStatus` in the configurable revocation set.
- ADDED (in `@suaveplan/webauthn`): `AttestationVerifyResult.metadata?: MetadataLookup` field. Absent when no resolver is injected — strict back-compat.
#### Errors
- ADDED: `WEBAUTHN_MDS3.*` codes via `defineCodes`: `BLOB_FETCH_FAILED`, `BLOB_MALFORMED`, `BLOB_SIGNATURE_INVALID`, `BLOB_CHAIN_UNTRUSTED`, `BLOB_EXPIRED`, `BLOB_ALG_DISALLOWED`, `BLOB_SCHEMA_MISMATCH`, `CACHE_READ_FAILED`, `CACHE_WRITE_FAILED`, `RESOLVER_LOOKUP_FAILED`, `REFRESHER_ALREADY_RUNNING`, `REFRESHER_ABORTED`.
#### Test strategy
- Vectors of the FIDO Alliance MDS3 sample blob (`__fixtures__/mds3-sample-blob.jwt` + parsed payload) sourced offline from the FIDO Alliance conformance suite. Tests use the `FetchLike` injection seam to return the fixture bytes — no live network in CI.
- Negative fixtures: signature-flipped blob, chain-broken blob, expired `nextUpdate`, schema-violation payload.
### Subpath exports
- `.` — root barrel: `MetadataResolver`, `createMemoryResolver`, `createCachedResolver`, every type, every error code.
- `./fetch` — `fetchMetadataBlob`.
- `./verify` — `verifyMetadataBlob`, `MetadataBlobPayloadSchema`.
- `./cache` — `MetadataCache`, cache-key constants.
- `./refresher` — `createMetadataRefresher`, `MetadataRefresher`.
- `./errors` — `WEBAUTHN_MDS3_CODES`, `WebAuthnMds3Error`.
### Dependencies
- `@suaveplan/types workspace:^` — `FetchLike`, `StorageLike`, `Clock`, `LoggerLike`.
- `@suaveplan/error workspace:^` — `defineCodes`, base error.
- `@suaveplan/jws workspace:^` — JWS verification with `x5c` chain.
- `@suaveplan/x509 workspace:^` — chain validation to pinned FIDO root.
- `@suaveplan/crypto-hash workspace:^` — SHA-256 for AAGUID key derivation and cert thumbprints.
- `@suaveplan/crypto-utils workspace:^` — `constantTimeEqual`, base64url.
- `@opentelemetry/api catalog:observability` — refresh + lookup metrics.
- `tslib catalog:build`.
- `zod catalog:forms` — boundary validation.
## Out of scope
- **MDS v2 / MDS v1 compatibility.** MDS3 is the only metadata version on the FIDO Alliance roadmap; bundling deprecated MDS2 expands scope and ships dead code for new deployments.
- **Per-RP custom metadata sources.** The package only speaks the FIDO Alliance MDS3 wire format. Custom metadata catalogues (enterprise allow-lists) should implement `MetadataResolver` directly — that interface is the integration seam, not the MDS3 wire format.
- **Authenticator-status policy decisions.** The package surfaces `currentStatus` and `statusReports`; the relying party decides what status set to treat as revocation. The default revocation set is opinionated but configurable.
- **CRL / OCSP polling for attestation-root chains.** Out of band for MDS3 — root pinning + the published metadata `nextUpdate` cadence is the FIDO-Alliance-blessed freshness model.
## Source mapping
| Requirement | Source | Notes |
|---|---|---|
| Why an MDS3 integration is needed | `openspec/archive/2026-05-11-enterprise-webauthn/proposal.md` "Out of scope" | Verbatim deferral text |
| `trustPath` + `attestationType` consumed downstream | `packages/auth/webauthn/src/verifier/registry.ts` L14-L18 | `AttestationVerifyResult` is the integration seam |
| AAGUID parse + carry on attested-credential-data | `packages/auth/webauthn/src/parser/authenticator-data.ts` L73-L84 | Already available at attestation-verification time |
| `none`/`fido-u2f`/`packed` doc anchors flag MDS3 as caller responsibility | `packages/auth/webauthn/src/attestation/{packed,fido-u2f,none}/*.md` | Today's wording moves into the `MetadataResolver` |
| MDS3 wire format | FIDO Alliance "Metadata Service Specification" v3.0 §3.1 | External standard anchor |
| FIDO Alliance root CA pin | <https://mds.fidoalliance.org/Root.cer> | Pinned in `constants/fido-root.ts` |
| JWS verification | RFC 7515 + `@suaveplan/jws` | Internal contract |
## Verification approach
Every requirement in `specs/webauthn-mds3/spec.md` and every modified requirement in `specs/webauthn/spec.md` carries a `**Verified by:**` line naming planned test files. Test files (all paths relative to `packages/auth/webauthn-mds3/` unless noted):
- `src/fetch/fetch-blob.test.ts` — happy path via injected `FetchLike`; non-2xx surfaces `BLOB_FETCH_FAILED`; `AbortSignal` cancellation.
- `src/verify/verify-blob.test.ts` — sample-blob signature verifies; flipped signature → `BLOB_SIGNATURE_INVALID`; broken chain → `BLOB_CHAIN_UNTRUSTED`; expired `nextUpdate` → `BLOB_EXPIRED`; HS256 → `BLOB_ALG_DISALLOWED`; schema mismatch → `BLOB_SCHEMA_MISMATCH`.
- `src/cache/metadata-cache.test.ts` — round-trip through `StorageLike`; read/write failure mapping.
- `src/refresher/metadata-refresher.test.ts` — refreshes at `nextUpdate - jitter`; falls back to `intervalMs`; reentrancy guard fires `REFRESHER_ALREADY_RUNNING`; `signal.abort()` fires `REFRESHER_ABORTED`; OTel counter records per outcome.
- `src/resolver/memory.test.ts` — AAGUID lookup hit / miss; `attestationCertificateKeyId` lookup for U2F.
- `src/resolver/cached.test.ts` — reads cache; lazy-refreshes when stale.
- `src/errors/errors.test.ts` — every `WEBAUTHN_MDS3.*` code is thrown by at least one production path.
- `packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"augments result with metadata when resolver injected"` AND `"::"downgrades trusted=false when currentStatus is revoked"` AND `"::"omits metadata field when no resolver injected (back-compat)"` — integration into the existing `@suaveplan/webauthn` verifier.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/webauthn-mds3` after landing — at draft time the package is new.
- `@suaveplan/webauthn` — [x] tests run / [x] docs reviewed / [ ] no action needed. Gains the optional `metadata?: MetadataResolver` field on `VerifyAttestationInput` and the optional `metadata?: MetadataLookup` field on `AttestationVerifyResult`. Both are additive; existing call sites that do not inject a resolver see byte-identical behaviour.
- `@suaveplan/vault` — [ ] tests run / [x] docs reviewed / [x] no action needed (reason: vault unlock consumes `verifyAssertion`, not `verifyAttestation`; MDS3 surfaces only on registration paths the vault doesn't drive).
- `@suaveplan/react-auth` — [ ] tests run / [x] docs reviewed / [x] no action needed (reason: hook surface unchanged; MDS3 plugs into server-side verifier).
## Migration
No prior MDS3 surface — additive only. Behaviour change for `verifyAttestation` consumers is opt-in (only fires when `metadata: MetadataResolver` is passed). Consumers that previously open-coded an MDS3 lookup over the returned `trustPath` MAY migrate to the injected seam at their own pace; the old "ignore `metadata`, do it yourself" path is permanently supported.

View File

@@ -0,0 +1,222 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: webauthn-mds3 (webauthn-mds3)
**Package**: `@suaveplan/webauthn-mds3`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: MDS3 blob fetch via injected FetchLike
The package SHALL export `fetchMetadataBlob({ fetch, url?, signal? }): Promise<Uint8Array>` from the `./fetch` subpath. The function SHALL call the injected `FetchLike` (per `@suaveplan/types`) against `url` (default `https://mds.fidoalliance.org/`), return the response body as a `Uint8Array`, map any non-2xx status to `WEBAUTHN_MDS3.BLOB_FETCH_FAILED` carrying the status code in `context.status`, and forward `signal` so caller-side cancellation triggers the underlying `AbortController` path.
#### Scenario: returns blob bytes on 200
- **WHEN** `fetchMetadataBlob({ fetch: stub200WithBytes, url: "https://example.test/mds" })` is called
- **THEN** the resolved value `equalsBytes(stubBytes)`
#### Scenario: maps non-2xx to BLOB_FETCH_FAILED
- **WHEN** `fetchMetadataBlob({ fetch: stub503 })` is called
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_FETCH_FAILED` and `error.context.status === 503`
#### Scenario: forwards AbortSignal
- **GIVEN** an `AbortController` whose `signal` is passed
- **WHEN** the controller is aborted before the stub fetch resolves
- **THEN** the call rejects, the stub fetch received the same `signal`, and the rejection's code is `WEBAUTHN_MDS3.BLOB_FETCH_FAILED` with `cause.name === "AbortError"`
**Verified by:** `src/fetch/fetch-blob.test.ts::"returns blob bytes on 200"` AND `src/fetch/fetch-blob.test.ts::"maps non-2xx to BLOB_FETCH_FAILED"` AND `src/fetch/fetch-blob.test.ts::"forwards AbortSignal to the injected fetch"`
---
### Requirement: MDS3 blob signature verification with pinned FIDO root
The package SHALL export `verifyMetadataBlob({ jws, trustAnchors? }): Promise<MetadataBlobPayload>` from the `./verify` subpath. The function SHALL parse the compact JWS, enforce the algorithm allow-list `{ RS256, ES256, ES384 }` (rejecting any other with `WEBAUTHN_MDS3.BLOB_ALG_DISALLOWED`), validate the `x5c` certificate chain to the pinned FIDO Alliance MDS3 root CA (or to caller-supplied `trustAnchors`) via `@suaveplan/x509`, verify the signature via `@suaveplan/jws` and `@suaveplan/crypto-rsa`/`@suaveplan/crypto-curve` per the header `alg`, decode the JSON payload, Zod-validate against `MetadataBlobPayloadSchema`, and reject with `WEBAUTHN_MDS3.BLOB_EXPIRED` when `now > payload.nextUpdate`.
#### Scenario: verifies the FIDO sample blob
- **WHEN** `verifyMetadataBlob({ jws: SAMPLE_BLOB_JWT })` is called against the bundled FIDO Alliance conformance sample
- **THEN** the resolved value is a typed `MetadataBlobPayload` with `legalHeader`, `no`, `nextUpdate`, and at least one `entries[]` element
#### Scenario: rejects flipped signature
- **WHEN** `verifyMetadataBlob({ jws: CORRUPTED_BLOB_JWT })` is called against the bundled signature-flipped variant
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_SIGNATURE_INVALID`
#### Scenario: rejects untrusted chain
- **WHEN** `verifyMetadataBlob({ jws })` is called with a JWS whose `x5c` chain terminates at a non-pinned root
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_CHAIN_UNTRUSTED`
#### Scenario: rejects disallowed algorithm
- **WHEN** `verifyMetadataBlob({ jws })` is called with a JWS whose header `alg === "HS256"`
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_ALG_DISALLOWED`
#### Scenario: rejects expired blob
- **WHEN** `verifyMetadataBlob({ jws })` is called and `Clock.now() > payload.nextUpdate`
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_EXPIRED`
#### Scenario: rejects schema mismatch
- **WHEN** `verifyMetadataBlob({ jws })` is called against a payload missing the required `entries` field
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_SCHEMA_MISMATCH`
**Verified by:** `src/verify/verify-blob.test.ts::"verifies the FIDO sample blob"` AND `src/verify/verify-blob.test.ts::"rejects flipped signature"` AND `src/verify/verify-blob.test.ts::"rejects untrusted chain"` AND `src/verify/verify-blob.test.ts::"rejects disallowed algorithm"` AND `src/verify/verify-blob.test.ts::"rejects expired blob"` AND `src/verify/verify-blob.test.ts::"rejects schema mismatch"`
---
### Requirement: Metadata cache via injected StorageLike
The package SHALL export `MetadataCache` from the `./cache` subpath that wraps an injected `StorageLike<MetadataBlobPayload>` under the key namespace `webauthn-mds3:blob:v1`. The cache SHALL surface storage read failures as `WEBAUTHN_MDS3.CACHE_READ_FAILED` and write failures as `WEBAUTHN_MDS3.CACHE_WRITE_FAILED`, attaching the underlying error as `cause`. The cache SHALL refuse to write a payload whose `no` field is strictly lower than the currently-cached payload's `no` (rollback protection).
#### Scenario: round-trips a verified payload
- **WHEN** `cache.set(payload)` then `cache.get()` is called against an in-memory `StorageLike`
- **THEN** the returned payload deep-equals the written payload
#### Scenario: surfaces storage read failure
- **GIVEN** a `StorageLike` whose `get` rejects with `new Error("disk full")`
- **WHEN** `cache.get()` is called
- **THEN** the call rejects with `WEBAUTHN_MDS3.CACHE_READ_FAILED` and `error.cause.message === "disk full"`
#### Scenario: refuses to write a rolled-back payload
- **GIVEN** a cache holding a payload with `no: 42`
- **WHEN** `cache.set(payloadWithNo41)` is called
- **THEN** the call rejects with `WEBAUTHN_MDS3.CACHE_WRITE_FAILED` and `error.context.reason === "rollback"`
**Verified by:** `src/cache/metadata-cache.test.ts::"round-trips a verified payload"` AND `src/cache/metadata-cache.test.ts::"surfaces storage read failure as CACHE_READ_FAILED"` AND `src/cache/metadata-cache.test.ts::"refuses to write a rolled-back payload"`
---
### Requirement: Scheduled metadata refresher
The package SHALL export `createMetadataRefresher({ fetch, storage, clock, logger, intervalMs?, signal? }): MetadataRefresher` from the `./refresher` subpath. The refresher SHALL schedule the next refresh at `payload.nextUpdate - jitter` (uniform jitter in `[-5min, +5min]`) when `nextUpdate` is present, otherwise at `now + intervalMs` (default 24h). The refresher SHALL throw `WEBAUTHN_MDS3.REFRESHER_ALREADY_RUNNING` when `refresh()` is called while a refresh is in flight, and SHALL reject the pending refresh with `WEBAUTHN_MDS3.REFRESHER_ABORTED` when the injected `AbortSignal` fires.
#### Scenario: schedules at nextUpdate minus jitter
- **WHEN** a refresher is started against a payload with `nextUpdate = now + 1h`
- **THEN** the next `clock.setTimeout` call is in the range `[55min, 65min]` from `now`
#### Scenario: falls back to intervalMs when nextUpdate absent
- **WHEN** a refresher is started against a payload with `nextUpdate: undefined` and `intervalMs: 1h`
- **THEN** the next `clock.setTimeout` call is at approximately `now + 1h`
#### Scenario: reentrancy guard fires REFRESHER_ALREADY_RUNNING
- **WHEN** `refresh()` is called while a prior `refresh()` is still pending
- **THEN** the second call rejects with `WEBAUTHN_MDS3.REFRESHER_ALREADY_RUNNING`
#### Scenario: aborts on signal
- **GIVEN** a refresher started with `signal: ac.signal`
- **WHEN** `ac.abort()` is called mid-fetch
- **THEN** the pending refresh rejects with `WEBAUTHN_MDS3.REFRESHER_ABORTED`
#### Scenario: records refresh outcome counter
- **WHEN** a refresh completes successfully
- **THEN** the OTel counter `webauthn_mds3.refresh_total{outcome="ok"}` increments by 1
**Verified by:** `src/refresher/metadata-refresher.test.ts::"schedules at nextUpdate minus jitter"` AND `src/refresher/metadata-refresher.test.ts::"falls back to intervalMs when nextUpdate absent"` AND `src/refresher/metadata-refresher.test.ts::"reentrancy guard fires REFRESHER_ALREADY_RUNNING"` AND `src/refresher/metadata-refresher.test.ts::"aborts on signal"` AND `src/refresher/metadata-refresher.test.ts::"records refresh outcome counter"`
---
### Requirement: AAGUID lookup API
The package SHALL export a `MetadataResolver` interface with `resolveByAaguid(aaguid: Uint8Array): Promise<MetadataLookup | undefined>` and `resolveByAttestationCertificateKeyId(keyId: Uint8Array): Promise<MetadataLookup | undefined>`. The package SHALL ship two factories: `createMemoryResolver({ blob })` (synchronous in-memory over a verified payload) and `createCachedResolver({ cache, refresher? })` (production resolver that reads through the cache and optionally starts the refresher on first access). The AAGUID comparator SHALL use `constantTimeEqual` from `@suaveplan/crypto-utils`. The `attestationCertificateKeyId` comparator SHALL match on the byte-equal SHA-1 SKI of the leaf attestation certificate (FIDO MDS3 §3.1.6 — U2F authenticators have no AAGUID).
#### Scenario: AAGUID hit returns a typed MetadataLookup
- **GIVEN** a memory resolver seeded with a payload containing an entry whose AAGUID is `0x11223344...`
- **WHEN** `resolveByAaguid(0x11223344...)` is called
- **THEN** the resolved value has `statement.aaguid === "11223344-..."`, `currentStatus` set, `attestationRootCertificates.length >= 1`, and `lastUpdate > 0`
#### Scenario: AAGUID miss returns undefined
- **WHEN** `resolveByAaguid(zerosBytes)` is called against a resolver whose payload has no zero-AAGUID entry
- **THEN** the resolved value is `undefined`
#### Scenario: U2F attestation-cert SKI lookup
- **GIVEN** a memory resolver seeded with a payload containing an entry whose `attestationCertificateKeyIdentifiers` includes a specific SHA-1 SKI
- **WHEN** `resolveByAttestationCertificateKeyId(thatSki)` is called
- **THEN** the resolved value's `statement` matches that entry
#### Scenario: cached resolver lazy-refreshes when cache empty
- **GIVEN** a cached resolver whose cache is empty and whose refresher is wired
- **WHEN** `resolveByAaguid(any)` is called
- **THEN** the refresher's `refresh()` is invoked exactly once before the resolver returns
**Verified by:** `src/resolver/memory.test.ts::"AAGUID hit returns a typed MetadataLookup"` AND `src/resolver/memory.test.ts::"AAGUID miss returns undefined"` AND `src/resolver/memory.test.ts::"U2F attestation-cert SKI lookup"` AND `src/resolver/cached.test.ts::"lazy-refreshes when cache empty"`
---
### Requirement: Error-code registry
The package SHALL declare its error codes via `defineCodes("WEBAUTHN_MDS3", [...])` covering `BLOB_FETCH_FAILED`, `BLOB_MALFORMED`, `BLOB_SIGNATURE_INVALID`, `BLOB_CHAIN_UNTRUSTED`, `BLOB_EXPIRED`, `BLOB_ALG_DISALLOWED`, `BLOB_SCHEMA_MISMATCH`, `CACHE_READ_FAILED`, `CACHE_WRITE_FAILED`, `RESOLVER_LOOKUP_FAILED`, `REFRESHER_ALREADY_RUNNING`, and `REFRESHER_ABORTED`. Every declared code SHALL be thrown by at least one production call site exercised by an existing test.
#### Scenario: every code is reachable
- **WHEN** the test harness greps `src/` for `WEBAUTHN_MDS3_CODES.<CODE>` references for every declared `CODE`
- **THEN** every code has at least one reference outside `src/errors/`
**Verified by:** `src/errors/errors.test.ts::"every WEBAUTHN_MDS3 code is thrown by at least one production path"`
---
### Requirement: Observability counters and histogram
The package SHALL emit `webauthn_mds3.refresh_total{outcome}` and `webauthn_mds3.lookup_total{outcome}` counters and a `webauthn_mds3.blob_age_ms` histogram via `@opentelemetry/api`. When no `MeterProvider` is registered the metrics SHALL no-op silently.
#### Scenario: refresh_total records on success
- **WHEN** a refresh completes successfully against an in-memory `MeterProvider`
- **THEN** `webauthn_mds3.refresh_total{outcome="ok"}` is `1`
#### Scenario: lookup_total records on miss
- **WHEN** `resolveByAaguid` returns `undefined`
- **THEN** `webauthn_mds3.lookup_total{outcome="miss"}` is `1`
#### Scenario: blob_age_ms records on read
- **WHEN** `cache.get()` returns a payload aged 1h
- **THEN** `webauthn_mds3.blob_age_ms` has a recorded value in the bucket containing 3_600_000
**Verified by:** `src/observability/metrics.test.ts::"refresh_total records on success"` AND `src/observability/metrics.test.ts::"lookup_total records on miss"` AND `src/observability/metrics.test.ts::"blob_age_ms records on read"`
---
### Requirement: Universal-tier purity
The package SHALL NOT import any DOM, browser, or Node-only API. `FetchLike`, `StorageLike`, `Clock`, and `LoggerLike` SHALL be injected through the public API surface so the package runs unmodified on Bun, Node, Cloudflare Workers, Deno, and inside a browser bundle.
#### Scenario: dep-graph harness sees no DOM types
- **WHEN** the dep-graph test sweeps the public surface for `Window`, `Document`, `localStorage`, `Blob` references
- **THEN** zero matches are found
**Verified by:** `src/dep-graph.test.ts::"public surface imports zero DOM or Node-only types"`
---
### Requirement: Subpath exports
The package SHALL expose subpath exports for `.`, `./fetch`, `./verify`, `./cache`, `./refresher`, and `./errors` in its `package.json` `exports` map so consumers can tree-shake unused capabilities.
#### Scenario: every subpath resolves
- **WHEN** the package's `index.test.ts` imports from each subpath
- **THEN** every import resolves to the expected module and the bundler tree-shakes unused entries
**Verified by:** `src/index.test.ts::"every documented subpath resolves through the package's exports map"`

View File

@@ -0,0 +1,49 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: webauthn (webauthn)
**Package**: `@suaveplan/webauthn`
**Status**: PROPOSED (companion delta of `add-webauthn-mds3`)
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## MODIFIED Requirements
### Requirement: Attestation verification dispatch
The package SHALL export `verifyAttestation(input: VerifyAttestationInput): Promise<AttestationVerifyResult>` from the `./verifier` subpath. The function SHALL dispatch on `input.attestationObject.fmt` to the per-format verifier registered in `input.registry`, throwing `WEBAUTHN.ATTESTATION_FORMAT_UNSUPPORTED` when no verifier is registered. `VerifyAttestationInput` SHALL accept an optional `metadata?: MetadataResolver` field (typed via `import type` from `@suaveplan/webauthn-mds3` — runtime-optional) and an optional `revokedStatuses?: ReadonlySet<AuthenticatorStatus>` field. `AttestationVerifyResult` SHALL carry an optional `metadata?: MetadataLookup` field. When `input.metadata` is `undefined` the returned `AttestationVerifyResult` SHALL be byte-identical to the pre-modification contract (strict back-compat). When `input.metadata` is supplied, after the per-format verifier returns, `verifyAttestation` SHALL resolve the AAGUID — or, for `fmt === "fido-u2f"` or an all-zero AAGUID, the SHA-1 SKI of the leaf attestation certificate (FIDO MDS3 §3.1.6) — call `resolveByAaguid` / `resolveByAttestationCertificateKeyId` on the supplied resolver, set `result.metadata` to the returned `MetadataLookup` (or omit when `undefined`), and downgrade `result.trusted` to `false` when the lookup's `currentStatus` is in `revokedStatuses ?? DEFAULT_REVOCATION_STATUSES`.
#### Scenario: dispatches to registered format verifier
- **WHEN** `verifyAttestation({ attestationObject: { fmt: "packed", ... }, registry: defaultAttestationRegistry(), ... })` is called
- **THEN** the result is produced by the `packed` verifier and includes `trustPath`, `attestationType`, `trusted`
#### Scenario: throws WEBAUTHN.ATTESTATION_FORMAT_UNSUPPORTED on unknown fmt
- **WHEN** `verifyAttestation({ attestationObject: { fmt: "unknown-fmt", ... }, registry: emptyRegistry })` is called
- **THEN** the call rejects with `WEBAUTHN.ATTESTATION_FORMAT_UNSUPPORTED`
#### Scenario: omits metadata field when no resolver injected (back-compat)
- **WHEN** `verifyAttestation(input)` is called with `input.metadata === undefined`
- **THEN** the resolved `AttestationVerifyResult` has `metadata === undefined` and is byte-for-byte identical to the legacy (pre-modification) result for the same inputs
#### Scenario: augments result with metadata when resolver injected
- **GIVEN** a `MetadataResolver` whose `resolveByAaguid` returns a `MetadataLookup` with `currentStatus === "FIDO_CERTIFIED_L1"`
- **WHEN** `verifyAttestation({ ..., metadata: resolver })` is called against a `packed` attestation carrying a known AAGUID
- **THEN** `result.metadata` deep-equals the lookup and `result.trusted` is the verifier's original value (unchanged)
#### Scenario: downgrades trusted=false when currentStatus is revoked
- **GIVEN** a `MetadataResolver` whose `resolveByAaguid` returns a `MetadataLookup` with `currentStatus === "REVOKED"`
- **WHEN** `verifyAttestation({ ..., metadata: resolver })` is called and the per-format verifier returned `trusted: true`
- **THEN** the merged `result.trusted` is `false` and `result.metadata.currentStatus === "REVOKED"`
#### Scenario: resolves U2F by attestation-cert SKI
- **GIVEN** a `MetadataResolver` and a `fido-u2f` attestation whose leaf cert has SKI `0xDEAD…`
- **WHEN** `verifyAttestation({ ..., metadata: resolver })` is called
- **THEN** the resolver's `resolveByAttestationCertificateKeyId(0xDEAD…)` is invoked exactly once and `resolveByAaguid` is never called
**Verified by:** `packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"dispatches to registered format verifier"` AND `packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"throws WEBAUTHN.ATTESTATION_FORMAT_UNSUPPORTED on unknown fmt"` AND `packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"omits metadata field when no resolver injected (back-compat)"` AND `packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"augments result with metadata when resolver injected"` AND `packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"downgrades trusted=false when currentStatus is revoked"` AND `packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"resolves U2F by attestation-cert SKI"`

View File

@@ -0,0 +1,98 @@
# Tasks — `add-webauthn-mds3`
## 1. Package scaffolding
- [ ] 1.1 Create `packages/auth/webauthn-mds3/` per genesis package layout.
- [ ] 1.2 `package.json` at `version: 0.0.0`, `sideEffects: false`, `workspace:^` for internal deps, `catalog:*` for external.
- [ ] 1.3 `tsconfig.json` extending `@suaveplan/typescript-config` universal preset.
- [ ] 1.4 `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`; coverage thresholds 100 / 100 / 100 / 100; `provider: 'istanbul'`.
- [ ] 1.5 Subpath exports map in `package.json`: `.`, `./fetch`, `./verify`, `./cache`, `./refresher`, `./errors`.
- [ ] 1.6 Root `README.md` with one-paragraph description, install, quick-start, Modules section linking to every co-located `.md`.
## 2. Constants + FIDO root pinning
- [ ] 2.1 `src/constants/fido-root.ts` + `.md` — pin the FIDO Alliance MDS3 root CA DER bytes + SHA-256 fingerprint; export `FIDO_MDS3_DEFAULT_URL`, `DEFAULT_REFRESH_INTERVAL_MS`, `DEFAULT_REVOCATION_STATUSES`.
## 3. Error codes
- [ ] 3.1 `src/errors/error-codes.ts` + `.md` — `defineCodes("WEBAUTHN_MDS3", [...])` with every code from the spec delta; export `WebAuthnMds3Error`.
- [ ] 3.2 `src/errors/errors.test.ts` — assert every code is reachable from a production path (call-site grep harness).
## 4. Schemas
- [ ] 4.1 `src/schemas/metadata-blob.schemas.ts` + `.md` — `MetadataBlobPayloadSchema`, `MetadataStatementSchema`, `AuthenticatorStatusSchema`, `StatusReportSchema`, `BiometricStatusReportSchema`, `EcdaaTrustAnchorSchema`, `MetadataBlobEntrySchema` tracking FIDO MDS3 §3.1.
## 5. Fetch
- [ ] 5.1 `src/fetch/fetch-blob.ts` + `.md` — `fetchMetadataBlob({ fetch, url?, signal? })`; maps non-2xx → `BLOB_FETCH_FAILED`; honors `AbortSignal`.
- [ ] 5.2 `src/fetch/fetch-blob.test.ts` — happy path; non-2xx; abort.
## 6. Verify
- [ ] 6.1 `src/verify/verify-blob.ts` + `.md` — verify compact JWS via `@suaveplan/jws`; pin alg allow-list (RS256, ES256, ES384); validate `x5c` chain to FIDO root via `@suaveplan/x509`; check `nextUpdate`; decode + Zod-validate payload.
- [ ] 6.2 `src/verify/verify-blob.test.ts` — happy, signature-flipped, chain-broken, expired, alg-disallowed, schema-mismatch (use sample-blob fixture).
## 7. Cache
- [ ] 7.1 `src/cache/metadata-cache.ts` + `.md` — wraps injected `StorageLike<MetadataBlobPayload>` with key namespace `webauthn-mds3:blob:v1`; surfaces `CACHE_READ_FAILED` / `CACHE_WRITE_FAILED`.
- [ ] 7.2 `src/cache/metadata-cache.test.ts` — round-trip; injected storage failure mapping.
## 8. Refresher
- [ ] 8.1 `src/refresher/metadata-refresher.ts` + `.md` — `createMetadataRefresher({ fetch, storage, clock, logger, intervalMs?, signal? })`; reentrancy guard; OTel counter; jittered scheduling at `nextUpdate - jitter`.
- [ ] 8.2 `src/refresher/metadata-refresher.test.ts` — schedule, reentrancy guard, abort, OTel counter, fallback to `intervalMs`.
## 9. Resolver
- [ ] 9.1 `src/resolver/types.ts` + `.md` — `MetadataResolver`, `MetadataLookup` interfaces (no name collisions per AGENTS.md §10 — folder is `resolver/`, files use feature names not `*-resolver.ts`).
- [ ] 9.2 `src/resolver/memory.ts` + `.md` + `memory.test.ts` — `createMemoryResolver` for verified payloads.
- [ ] 9.3 `src/resolver/cached.ts` + `.md` + `cached.test.ts` — `createCachedResolver` reading the cache + optional refresher drive.
## 10. Observability
- [ ] 10.1 `src/observability/metrics.ts` + `.md` — `webauthn_mds3.refresh_total{outcome}`, `webauthn_mds3.lookup_total{outcome}` counters; `webauthn_mds3.blob_age_ms` histogram. No-op when no exporter.
- [ ] 10.2 `src/observability/metrics.test.ts` — every metric records on its production path against an in-memory `MeterProvider`.
## 11. Fixtures
- [ ] 11.1 `src/__fixtures__/mds3-sample-blob.jwt` — captured FIDO Alliance MDS3 conformance-suite sample blob.
- [ ] 11.2 `src/__fixtures__/mds3-corrupted-blob.jwt` — signature-flipped variant for negative tests.
- [ ] 11.3 `src/__fixtures__/index.ts` — typed fixture exports.
## 12. Benchmarks
- [ ] 12.1 `src/__benchmarks__/verify-blob.bench.ts` — JWS verify throughput on the sample blob.
- [ ] 12.2 `src/__benchmarks__/resolve-aaguid.bench.ts` — AAGUID lookup throughput.
## 13. Integration into `@suaveplan/webauthn`
- [ ] 13.1 Extend `VerifyAttestationInput` with optional `metadata?: MetadataResolver` field (typed via `import type` from `@suaveplan/webauthn-mds3` — no runtime dep).
- [ ] 13.2 Extend `AttestationVerifyResult` with optional `metadata?: MetadataLookup` field.
- [ ] 13.3 Wire post-verifier resolution: AAGUID for non-U2F formats, attestation-cert SKI for `fido-u2f`; downgrade `trusted=false` on revoked statuses; configurable revocation set with default from `DEFAULT_REVOCATION_STATUSES`.
- [ ] 13.4 Update `packages/auth/webauthn/src/verifier/verify-attestation.test.ts` — three new tests: resolver injected augments result; revoked status downgrades trust; no resolver = byte-identical legacy behaviour.
- [ ] 13.5 Refresh `packages/auth/webauthn/src/verifier/verify-attestation.md` and the three attestation doc anchors (`packed.md`, `fido-u2f.md`, `none.md`) so the "caller wires MDS3" language now mentions `MetadataResolver` as the canonical seam.
- [ ] 13.6 Add `@suaveplan/webauthn-mds3` to `packages/auth/webauthn/README.md` Modules section as the recommended MDS3 sidecar.
## 14. Pre-publish gate
- [ ] 14.1 `bunx turbo lint typecheck test --filter=@suaveplan/webauthn-mds3` green.
- [ ] 14.2 `bunx turbo test:coverage --filter=@suaveplan/webauthn-mds3` 100 / 100 / 100 / 100.
- [ ] 14.3 `bunx turbo lint typecheck test --filter=@suaveplan/webauthn` green (dependent sweep — §13 step 10).
- [ ] 14.4 Banned-pattern scan: zero matches per §13 step 4.
- [ ] 14.5 Barrel-shape scan (§13 step 4a): zero matches.
- [ ] 14.6 Test-runner import scan (§13 step 4b): zero matches.
- [ ] 14.7 Missing-`.md` scan: zero misses.
- [ ] 14.8 `bunx markdownlint-cli2 --fix` then second pass with no `--fix` exits 0.
## 15. Repo-root manifest refresh (§13b)
- [ ] 15.1 `bun scripts/folder-reorg/generate-manifest.ts` → `MANIFEST.md` refreshed.
- [ ] 15.2 `bun scripts/generate-missing.ts` → `MISSING.yml` refreshed.
- [ ] 15.3 `STATUS.md` updated: add `@suaveplan/webauthn-mds3` row.
- [ ] 15.4 `SUMMARY.md` paragraph for `webauthn-mds3` added.
## 16. Fold + archive
- [ ] 16.1 Fold `specs/webauthn-mds3/spec.md` into `openspec/specs/webauthn-mds3/spec.md`.
- [ ] 16.2 Fold `specs/webauthn/spec.md` `## MODIFIED Requirements` into the canonical `openspec/specs/webauthn/spec.md`.
- [ ] 16.3 Move `openspec/changes/add-webauthn-mds3/` → `openspec/archive/<yyyy-mm-dd>-add-webauthn-mds3/`.

View File

@@ -0,0 +1,88 @@
# OpenSpec Change: enterprise-react-auth
- **Change ID**: enterprise-react-auth
- **Package**: `@suaveplan/react-auth`
- **Category**: react
- **Target path**: `react/auth/react-auth`
- **Status**: PROPOSED
- **Date**: 2026-04-24
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/react-auth.md`
---
## Summary
Deliver `@suaveplan/react-auth` on genesis from scratch at enterprise quality. The core React authentication layer: `AuthProvider`, `useAuth`, `useUser`, `useIsAuthenticated`, token management, session persistence, and integration with `@suaveplan/oauth-client` for Authorization Code + PKCE flows. Ships structured errors via `@suaveplan/error`, OTel span events for sign-in/sign-out/refresh, Storybook stories, and Playwright E2E for the full auth lifecycle.
---
## Current-state audit (genesis)
Package does not exist on genesis — fresh build.
---
## Available workspace deps on genesis
| Package | Role |
|---|---|
| `@suaveplan/error` (`workspace:^`) | `AuthError` |
| `@suaveplan/types` (`workspace:^`) | `LoggerLike` |
| `@suaveplan/events` (`workspace:^`) | Auth state change events |
| `@suaveplan/oauth-client` (`workspace:^`) | PKCE + token refresh |
| `@suaveplan/react-translate` (`workspace:^`) | i18n |
| `zod` (`catalog:forms`) | Auth config schemas |
| `@opentelemetry/api` (`catalog:observability`) | Auth lifecycle spans |
External: `tslib catalog:build`. React 18 peer.
---
## Public API surface
```ts
AuthProvider, AuthContext
useAuth, useUser, useIsAuthenticated, useAuthToken
AuthGuard — wraps children, redirects to login if unauthenticated
withAuth(Component) — HOC variant of AuthGuard
// Types
AuthConfig, AuthUser, AuthState, AuthContextValue,
AuthProviderProps, AuthGuardProps, SignInOptions, SignOutOptions
```
---
## New additions for genesis (full build)
1. **`AuthError`** — codes: `AUTH_UNAUTHENTICATED`, `AUTH_TOKEN_EXPIRED`, `AUTH_REFRESH_FAILED`, `AUTH_CONTEXT_MISSING`.
2. **Token refresh** via `@suaveplan/oauth-client` with automatic retry on 401.
3. **OTel spans** — `auth.sign_in`, `auth.sign_out`, `auth.token_refresh`.
4. **Session persistence** — optional `sessionStorage` / `localStorage` adapter.
5. **Stories** — authenticated state, unauthenticated, loading, `AuthGuard` redirect.
6. **Playwright E2E** — sign-in flow, token refresh, protected route redirect.
---
## File layout
```text
react/auth/react-auth/
package.json
tsconfig.json
vitest.config.ts
src/
index.ts
context/
auth-context/ (tsx + types + md + test + index)
hooks/
use-auth/ use-user/ use-is-authenticated/ use-auth-token/
components/
auth-guard/ (tsx + types + md + test + stories + index)
utils/
token-storage/ session-persistence/
types/
auth.types.ts
e2e/
auth.spec.ts
```

View File

@@ -0,0 +1,79 @@
# Tasks: enterprise-react-auth
**Change ID**: enterprise-react-auth
**Package**: `@suaveplan/react-auth`
**Target path**: `react/auth/react-auth`
**Prerequisite packages on Verdaccio**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events`, `@suaveplan/oauth-client`, `@suaveplan/react-translate`
---
## 1. Scaffold
- [ ] Create directory tree per proposal layout.
- [ ] Write `package.json` at `0.1.0` with `"sideEffects": false`, exports map.
- [ ] Dependencies: `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/events workspace:^`, `@suaveplan/oauth-client workspace:^`, `@suaveplan/react-translate workspace:^`, `zod catalog:forms`, `@opentelemetry/api catalog:observability`, `tslib catalog:build`.
- [ ] Write `tsconfig.json`, `vitest.config.ts`, scripts.
---
## 2. Error codes and types
- [ ] Define `AuthError` extending `SuaveplanError`; codes: `AUTH_UNAUTHENTICATED`, `AUTH_TOKEN_EXPIRED`, `AUTH_REFRESH_FAILED`, `AUTH_CONTEXT_MISSING`.
- [ ] Define `AuthUser`, `AuthState`, `AuthConfig`, `AuthContextValue`, `AuthProviderProps`, `AuthGuardProps`, `SignInOptions`, `SignOutOptions`.
- [ ] Write `AuthConfigSchema` Zod schema.
---
## 3. Token utilities
- [ ] `src/utils/token-storage/` — `createTokenStorage(adapter: 'memory' | 'localStorage' | 'sessionStorage')`: `setTokens(tokens)`, `getTokens()`, `clearTokens()`.
- [ ] `src/utils/session-persistence/` — `saveSession(user)`, `loadSession(): AuthUser | null`, `clearSession()`.
- [ ] Write tests + md for each.
---
## 4. AuthContext + AuthProvider
- [ ] State: `user: AuthUser | null`, `isLoading`, `isAuthenticated`.
- [ ] `signIn(options: SignInOptions)` — calls `@suaveplan/oauth-client` authorization + token exchange; stores tokens; emits `@suaveplan/events` `auth:signed_in`. OTel `auth.sign_in` span.
- [ ] `signOut(options?)` — clears tokens + session; emits `auth:signed_out`. OTel `auth.sign_out` span.
- [ ] `refreshToken()` — calls `@suaveplan/oauth-client` token refresh; on failure throws `AuthError(AUTH_REFRESH_FAILED)`. OTel `auth.token_refresh` span.
- [ ] Auto-refresh: installs a timer to refresh before expiry.
- [ ] `useAuth()` throws `AuthError(AUTH_CONTEXT_MISSING)` outside provider.
- [ ] Write `auth-context.test.tsx` (≤500 lines): sign-in, sign-out, refresh, auto-refresh timer, context missing. `{ timeout: 10000 }`.
- [ ] Write `auth-context.md`.
---
## 5. Hooks
- [ ] `useUser(): AuthUser | null` — from context.
- [ ] `useIsAuthenticated(): boolean`.
- [ ] `useAuthToken(): string | null` — current access token.
- [ ] Write tests + md for each.
---
## 6. AuthGuard + withAuth
- [ ] `AuthGuard` — renders children when authenticated; calls `onUnauthenticated` (default: throws `AuthError(AUTH_UNAUTHENTICATED)`) when not.
- [ ] `withAuth(Component)` — HOC wrapping `AuthGuard`.
- [ ] Write tests, stories (authenticated, unauthenticated-redirect, loading), md.
---
## 7. E2E
- [ ] `e2e/auth.spec.ts` — Playwright with injected mock `@suaveplan/oauth-client`:
- Render `AuthGuard` while unauthenticated → `onUnauthenticated` fires.
- Call `signIn()` → user set in context → `AuthGuard` renders children.
- Call `signOut()` → user cleared.
---
## 8. Verify and publish
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/react-auth` — green.
- [ ] `bunx turbo test:coverage --filter=@suaveplan/react-auth` — 100%.
- [ ] `bun scripts/publish.ts --package @suaveplan/react-auth`.
- [ ] Archive change folder.

View File

@@ -0,0 +1,232 @@
# Design — hoist-drivelike-to-types
Non-normative architecture notes. Normative requirements live in `./specs/types/spec.md` and `./specs/drive/spec.md`.
## 1. Why now (≥2-consumer rule satisfied)
`AGENTS.md` §0 step 6 and CLAUDE.md §11 say: a type referenced by ≥ 2 packages belongs in `@suaveplan/types`. Until today, `DriveLike` had exactly one consumer (`drive-uri`), so the archived `add-drive-uri` proposal correctly deferred the hoist. The current change folder is being authored alongside a sibling proposal `add-drive-uri-writes` that adds write-path URI plugins consuming the same structural shape. With those two in flight plus the obvious future `drive-mux` / `drive-fanout` composition consumers, the rule is satisfied and the hoist is the right next step.
Explicit consumer enumeration (the ≥ 2 the rule needs):
1. `@suaveplan/drive-uri` (archived `add-drive-uri`, shipped) — uses `DriveLike` for read-path URI plugins.
2. `@suaveplan/drive-uri` writes plugins (parallel `add-drive-uri-writes`) — uses `DriveLike` for write-path URI plugins.
3. Future `drive-mux` / `drive-fanout` packages — will compose multiple `DriveLike` instances without taking on the concrete `Drive` runtime.
Two consumers is the floor; three is the case actually in flight.
## 2. The canonical type surface (verbatim TS)
The new `packages/core/types/src/drive.ts` exports the following declarations. Bodies are preserved byte-for-byte from `packages/drive/drive/src/types/drive.types.ts` except where noted. `StorageLike` is the existing structural type already in `@suaveplan/types`; `DriveStorage` extends it instead of `AbstractStorage` so the hoisted file has zero outbound workspace dependencies beyond sibling `@suaveplan/types` files.
```ts
import type {
Clock,
EventBusLike,
LoggerLike,
PeerId,
Rng,
StorageLike,
} from "./index.js";
export interface DriveLike {
readonly clock: Clock | undefined;
readonly rng: Rng | undefined;
readonly logger: LoggerLike | undefined;
readonly events: EventBusLike | undefined;
usePlugin<T>(plugin: DrivePlugin<T>): Promise<T>;
useMiddleware(mw: DriveMiddleware): void;
read(path: string, options?: ReadOptions): Promise<Uint8Array>;
write(
path: string,
data: Uint8Array,
options?: WriteOptions
): Promise<void>;
getStorage(id: string): DriveStorage;
}
export type DriveStorage = StorageLike & {
readonly id: string;
connect?(): Promise<void>;
dispose?(): Promise<void>;
isConnected?(): boolean;
};
export interface DrivePlugin<THandle = void> {
readonly name: string;
install(drive: DriveLike): THandle | Promise<THandle>;
dispose?(): void | Promise<void>;
}
export interface MiddlewareContext {
operation: string;
path: string;
drive: DriveLike;
[key: string]: unknown;
}
export type MiddlewareNext<T = unknown> = () => Promise<T>;
export interface DriveMiddleware {
read?(
ctx: MiddlewareContext,
next: MiddlewareNext<Uint8Array>
): Promise<Uint8Array>;
write?(ctx: MiddlewareContext, next: MiddlewareNext<void>): Promise<void>;
delete?(
ctx: MiddlewareContext,
next: MiddlewareNext<boolean>
): Promise<boolean>;
stat?(
ctx: MiddlewareContext,
next: MiddlewareNext<unknown | null>
): Promise<unknown | null>;
list?(
ctx: MiddlewareContext,
next: MiddlewareNext<unknown>
): Promise<unknown>;
}
export interface DriveOptions {
defaultStorage?: DriveStorage;
plugins?: DrivePlugin<unknown>[];
middleware?: DriveMiddleware[];
clock?: Clock;
rng?: Rng;
logger?: LoggerLike;
events?: EventBusLike;
ourPubkey?: PeerId;
}
export interface ReadOptions {
signal?: AbortSignal;
}
export interface WriteOptions {
signal?: AbortSignal;
}
export interface ListOptions {
signal?: AbortSignal;
recursive?: boolean;
limit?: number;
cursor?: string;
}
export interface DriveEntry {
path: string;
size: number;
isDirectory: boolean;
createdAt: number;
modifiedAt: number;
keyStatus?: KeyStatus;
presence?: ChunkPresence;
}
export interface HydrateOptions {
readonly signal?: AbortSignal;
readonly onProgress?: (event: HydrateProgressEvent) => void;
readonly preFetch?: "none" | "metadata-only" | "auto-download-files";
}
export interface HydrateProgressEvent {
readonly stage:
| "validating"
| "indexing"
| "scheduling"
| "complete"
| "aborted";
readonly filesAdded: number;
readonly bytesScheduled: number;
}
export interface HydrateResult {
readonly filesAdded: number;
readonly bytesScheduled: number;
readonly unlocked: number;
readonly locked: number;
}
```
`KeyStatus` and `ChunkPresence` already live in `@suaveplan/types` and are imported, not re-declared, by `DriveEntry`.
`DriveStats` (the extension of `DriveEntry` with `checksum?` + `metadata?`) already lives in `@suaveplan/types/drive-stats`; the version inside the old `drive.types.ts` is a duplicate and is dropped from the hoist (consumers already import from `@suaveplan/types`).
## 3. Name-collision resolution
The current `packages/core/types/src/plugin.ts` exports a `DrivePlugin` interface that is a *hook-bag* shape (`hooks: Record<string, PluginHook>`). The drive-package `DrivePlugin<THandle>` is an *install/dispose* shape returning a typed handle. Both names appearing in the same root barrel is illegal.
Resolution: the existing hook-bag interface is renamed to `HookDrivePlugin` (the name it should always have had — it is one of two plugin shapes the drive ecosystem could pick, and the install/dispose form is the one actually used by `Drive.usePlugin`). The renamed symbol is re-exported from the root barrel; the only known consumer is the drive runtime's plugin manager, which is updated in this same change (see tasks §3.5).
After the change, `@suaveplan/types` root exports:
- `DrivePlugin<THandle>` (install/dispose, the one `Drive.usePlugin` accepts) — new, from `./drive`.
- `HookDrivePlugin` (hook-bag) — renamed from the prior `DrivePlugin`, still in `./plugin`.
This is the only public-surface MODIFY in `@suaveplan/types`. Every other export is purely ADDED.
## 4. Back-compat plan for `@suaveplan/drive`
`packages/drive/drive/src/types/drive.types.ts` is rewritten to a thin forwarder:
```ts
/**
* Back-compat re-export. Canonical home: `@suaveplan/types/drive`.
* Existed locally before the hoist; preserved here so existing
* `import { DriveLike } from "@suaveplan/drive"` keeps resolving for one
* release cycle while consumers migrate.
*/
export type {
DriveEntry,
DriveLike,
DriveMiddleware,
DriveOptions,
DrivePlugin,
DriveStorage,
HydrateOptions,
HydrateProgressEvent,
HydrateResult,
ListOptions,
MiddlewareContext,
MiddlewareNext,
ReadOptions,
WriteOptions,
} from "@suaveplan/types/drive";
```
No `@deprecated` JSDoc (banned by CLAUDE.md §2 / OpenSpec §2). The migration notice lives in this `proposal.md`, in `MIGRATION.md`, and in the sibling `drive.types.md`. The forwarder is itself the working replacement; deletion happens in a separate future change after a consumer audit.
`packages/drive/drive/package.json` adds `@suaveplan/types: workspace:^` to `dependencies` (it is already imported for `Clock`, `Rng`, `LoggerLike`, `EventBusLike`, `PeerId`, so this is a documentation fix, not a new edge).
## 5. Consumer migration table
| Consumer | File | Old import | New import | Notes |
|---|---|---|---|---|
| `@suaveplan/drive` | `src/types/drive.types.ts` | local declarations | `export type {...} from "@suaveplan/types/drive"` | Forwarder. |
| `@suaveplan/drive-uri` | `src/types/plugin-options.types.ts` | `import type { Drive } from "@suaveplan/drive"` | `import type { DriveLike } from "@suaveplan/types"` | The `drive` option field is structural. The concrete class is never called. |
| `@suaveplan/drive-uri` | `src/adapter/drive-storage-like.ts` | `import type { Drive } from "@suaveplan/drive"` | `import type { DriveLike } from "@suaveplan/types"` | Same — the adapter calls only public-surface methods. |
| `@suaveplan/drive-uri` | `src/plugins/drive/drive.ts` | `import type { Drive } from "@suaveplan/drive"` | `import type { DriveLike } from "@suaveplan/types"` | Plugin reads only `drive.read` / `createReadStream`. |
| `@suaveplan/drive-uri` | `src/plugins/drive/drive.streams.ts` | `import type { Drive } from "@suaveplan/drive"` | `import type { DriveLike } from "@suaveplan/types"` | Stream forwarder; structural. |
| `@suaveplan/drive-uri` | `src/plugins/drive-mesh/drive-mesh.ts` | `import type { Drive } from "@suaveplan/drive"` | `import type { DriveLike } from "@suaveplan/types"` | Mesh dispatch is consumer-wired. |
| `@suaveplan/drive-uri` | `src/plugins/drive-template/drive-template.ts` | `import type { Drive } from "@suaveplan/drive"` | `import type { DriveLike } from "@suaveplan/types"` | `hydrateFrom` is part of the public structural surface. |
| `@suaveplan/drive-uri` | `*.md` sibling docs | `import { Drive } from "@suaveplan/drive"` in advanced examples | unchanged where the consumer would instantiate `Drive`; updated where the example only types a parameter | CLAUDE.md §4a — docs must move in lock-step. |
| `add-drive-uri-writes` (parallel) | n/a — drafts against the canonical home directly. |
| Future `drive-mux` / `drive-fanout` | n/a — will be authored against `@suaveplan/types` from day one. |
## 6. Why a subpath (`./drive`) and not only the root
`@suaveplan/types` already groups multi-symbol domains under subpaths (`./universe`). The drive surface is 14 symbols — large enough to warrant a grouped subpath for consumers that want to import the whole drive contract in one line. Root re-exports are preserved for the ergonomic single-symbol case (`import type { DriveLike } from "@suaveplan/types"`).
Per CLAUDE.md §6 / project.md rule 11, the subpath gets its own entry in `package.json`'s `exports` map so tree-shaking remains granular.
## 7. Zero-runtime guarantee
The new `src/drive.ts` is types-only. `tsc` emits a 1-line module stub for it; the existing `tests/zero-runtime.test.ts` per-file ≤ 200-byte budget walk continues to pass without modification. The `package.json` dependency block stays empty. The rule-8 carve-out for testing packages is unaffected.
## 8. Risks & trade-offs
| Risk | Mitigation |
|---|---|
| The `HookDrivePlugin` rename breaks an external (non-genesis) consumer. | The hook-bag `DrivePlugin` has no known consumer outside the drive runtime; a one-line `grep` across `STATUS.md`-listed packages confirms this before merge. If a consumer surfaces, add a `type DrivePlugin = HookDrivePlugin` alias from `./plugin.js` in a follow-up. |
| A consumer relies on the *nominal* identity of `DriveLike` (e.g. structural-subtyping edge cases under `--exactOptionalPropertyTypes`). | The hoist preserves byte-for-byte field shapes; the forwarder uses `export type {...} from`, which establishes referential identity to the canonical declaration. Verified by the `expectTypeOf<...>().toEqualTypeOf<...>()` test in tasks §3.4. |
| Two `DriveStats` declarations diverge silently. | The hoist explicitly drops the duplicate from `drive.types.ts`; only the existing `@suaveplan/types/drive-stats` declaration remains. The forwarder file does not re-export `DriveStats` (consumers already import it from `@suaveplan/types` directly). |
| Future need to add a non-structural helper to `DriveLike`. | The structural contract stays in `@suaveplan/types`; any concrete helper lives on the `Drive` class in `@suaveplan/drive`. Adding helpers does not require unwinding the hoist. |

View File

@@ -0,0 +1,101 @@
# Hoist `DriveLike` from `@suaveplan/drive` into `@suaveplan/types`
**Package**: `@suaveplan/types` (primary), `@suaveplan/drive` (back-compat re-export)
**Status**: PROPOSED
**Type**: type-promotion (cross-package contract hoist)
- **Design**: ./design.md
- **Spec deltas**: ./specs/types/spec.md, ./specs/drive/spec.md
## Why
`DriveLike` lives today inside `packages/drive/drive/src/types/drive.types.ts`. It is a **structural** description of a drive instance — `read` / `write` / `usePlugin` / `useMiddleware` / `getStorage` plus the injected `Clock` / `Rng` / `LoggerLike` / `EventBusLike` slots — and contains zero runtime. Every consumer that wants to *accept* a drive without instantiating one (URI plugins, mux dispatchers, test fixtures) has to pull in `@suaveplan/drive` as a dependency to get the type. That pulls the entire drive runtime — `AbstractStorage` integration, plugin manager, middleware chain, `hydrateFrom` pipeline — into the dep graph of packages that never call any of it.
The archived `add-drive-uri` change called this out explicitly in its **Out of scope** section: *"A `DriveLike` interface in `@suaveplan/types`. Adding it requires ≥2 consumers per AGENTS.md §11; `drive-uri` is the only consumer today. If a second appears, hoist then."* That second consumer is now drafted: the parallel `add-drive-uri-writes` proposal adds write-path URI plugins that consume the same structural shape. Together they satisfy the ≥2-consumer rule for promotion to `@suaveplan/types` per `AGENTS.md` §0 step 6 and CLAUDE.md §11.
The hoist is also a circularity prevention measure: a future `drive-mux` or `drive-fanout` package that composes multiple `DriveLike`s must not transitively depend on every middleware and template plugin already attached to `@suaveplan/drive`.
## What changes
- ADDED (in `@suaveplan/types`): `DriveLike`, `DrivePlugin<T>` (drive-flavoured hook plugin), `DriveMiddleware`, `MiddlewareContext` (drive-flavoured, with `drive: DriveLike` field), `MiddlewareNext<T>`, `DriveStorage`, `ReadOptions`, `WriteOptions`, `ListOptions`, `DriveEntry`, `DriveOptions`, `HydrateOptions`, `HydrateProgressEvent`, `HydrateResult` — every cross-package structural type previously sourced from `@suaveplan/drive/types/drive`. `DriveStats` and `ChunkPresence` already live in `@suaveplan/types` and remain as-is.
- ADDED (subpath): `@suaveplan/types/drive` re-namespace barrel (`./drive` subpath export) grouping every hoisted symbol — matches the way `./universe` is already used. The flat root barrel also re-exports every name for back-compat.
- MODIFIED (in `@suaveplan/drive`): `src/types/drive.types.ts` becomes a **thin re-export** of the canonical declarations from `@suaveplan/types`. The package adds `@suaveplan/types` to `dependencies` (it already imports `Clock` / `Rng` / `LoggerLike` / `EventBusLike` / `PeerId` from there). No public surface change: every name `import { DriveLike } from "@suaveplan/drive"` resolved yesterday continues to resolve unchanged today.
- MODIFIED (consumers `drive-uri` + `add-drive-uri-writes`): switch `import type { Drive } from "@suaveplan/drive"` to `import type { DriveLike } from "@suaveplan/types"` in every site that only needs the structural shape. The concrete `Drive` class import remains where the class is actually instantiated (none of these sites instantiate it).
- NO new error codes, no behaviour change. Pure type-surface relocation.
## Out of scope
- **Hoisting the concrete `Drive` class.** The class stays in `@suaveplan/drive` with its plugin manager, middleware composer, and storage integration. Only the structural interface moves.
- **Removing the `@suaveplan/drive` re-export immediately.** It stays as a back-compat alias (no `@deprecated` JSDoc per CLAUDE.md §2 / OpenSpec §2; the proposal text and the `MIGRATION.md` entry are the deprecation notice). Removal is tracked in a follow-up after one release cycle and after `STATUS.md` consumers are audited.
- **Adding a `DriveLike` factory or stub implementation to `@suaveplan/types`.** `@suaveplan/types` MUST stay zero-runtime per its existing **Zero runtime code** requirement (≤ 200-byte stubs). Any runtime helpers belong in `testing` fixtures.
- **Renaming any field on `DriveLike`.** The contract is preserved byte-for-byte; renames would force every consumer to change their call sites for no semantic gain.
- **Touching `Drive.hydrateFrom`'s 8-step pipeline or `TemplateNonceLru`.** Those live behind the concrete class; the structural type only exposes the public surface they share.
## Source mapping
- Canonical source of every hoisted type: `packages/drive/drive/src/types/drive.types.ts` (verbatim shapes preserved).
- Existing pattern to mirror: `StorageLike` in `packages/core/types/src/storage-like.ts` is the structural counterpart for `AbstractStorage` in `@suaveplan/storage`; `DriveLike` is the structural counterpart for `Drive` in `@suaveplan/drive` and follows the same authoring style.
- Subpath-export pattern: `./universe` (and the `universe.ts` re-namespace barrel in `@suaveplan/types`) is the precedent for a multi-symbol grouped subpath.
### Requirements with no source line
| Decision | Justification |
|---|---|
| Add a `./drive` subpath barrel in addition to the flat root export | Matches `./universe` (the existing precedent for a grouped multi-symbol domain inside `@suaveplan/types`). Lets future consumers import `import type { DriveLike } from "@suaveplan/types/drive"` and tree-shake the rest of the type universe at IDE level. |
| Keep flat root exports too | Mirrors how `StorageLike`, `Clock`, `Rng` are surfaced at the root for ergonomic import. Removing the root export would be a breaking change to every existing `import type { Clock } from "@suaveplan/types"` consumer pattern. |
| `@suaveplan/drive` keeps a back-compat re-export instead of disappearing | Avoids a flag-day across `drive-uri`, the parallel `add-drive-uri-writes`, and any external Verdaccio consumer. Removal is auditable via a future change once `grep -rn "DriveLike.*from.*@suaveplan/drive"` returns zero outside test fixtures. |
| No `@deprecated` JSDoc on the re-export | CLAUDE.md §2 / OpenSpec §2 ban `@deprecated` placeholders. The re-export is a working forwarder pointing at a working canonical declaration — the migration notice lives in `MIGRATION.md` and this proposal, not in JSDoc. |
| Drive flavour of `MiddlewareContext` / `DrivePlugin` lives next to `DriveLike` (not collapsed into the existing `Plugin` / `MiddlewareContext` in `@suaveplan/types/plugin.js` / `middleware.js`) | The existing `DrivePlugin` in `@suaveplan/types/plugin.ts` is a *hook-bag* plugin (`hooks: Record<string, PluginHook>`); the drive-package `DrivePlugin<THandle>` is an *install/dispose* plugin returning a typed handle. The two are different contracts that happen to share a name in the current drive runtime. To avoid breaking the existing `Plugin` / hook-bag shape, the hoisted drive flavours land under the `./drive` subpath as `DrivePlugin` / `DriveMiddleware` and the existing root export `DrivePlugin` from `./plugin.js` is renamed in this change to `HookDrivePlugin` (its only consumer is the drive runtime, which switches in lock-step). Detailed in `design.md` → *Name collision resolution*. |
## Verification approach
Each requirement in the spec deltas carries a `**Verified by:**` line pointing at concrete planned tests:
- `packages/core/types/tests/spec-conformance.test.ts::"DriveLike is assignable from a Drive-shaped object literal"` — type-only assignability via `expectTypeOf`.
- `packages/core/types/tests/spec-conformance.test.ts::"DriveLike, DrivePlugin, DriveMiddleware, DriveStorage are exported at root"` — root barrel surface.
- `packages/core/types/tests/spec-conformance.test.ts::"DriveLike, DrivePlugin, DriveMiddleware, DriveStorage are exported from ./drive subpath"` — subpath barrel surface.
- `packages/core/types/tests/zero-runtime.test.ts::"every .js file under dist/ is ≤ 200 bytes"` — the existing budget walk continues to pass after the hoist (no runtime added).
- `packages/drive/drive/src/types/drive.types.spec-conformance.test.ts::"re-exports DriveLike from @suaveplan/types"` — proves the back-compat re-export resolves to the canonical declaration (referential identity via `expectTypeOf<DriveLike>().toEqualTypeOf<TypesDriveLike>()`).
- `packages/drive/drive-uri/src/types/plugin-options.spec-conformance.test.ts::"plugin options accept a structural DriveLike, not only the concrete Drive class"` — proves the consumer can be satisfied by any structural conformant value.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/types` to enumerate (foundation package — large dependent set). Because this change is **additive at the public surface** of `@suaveplan/types` and **strictly back-compatible** at the public surface of `@suaveplan/drive`, no dependent's source needs to change. Per AGENTS.md §13 step 10, the dependent sweep MUST still run.
Direct consumers of `DriveLike` (the surface that moves):
- `@suaveplan/drive` — [x] tests run (re-export forwarding test added) / [x] docs reviewed (`drive.types.md` updated to note the canonical home) / [ ] no action needed.
- `@suaveplan/drive-uri` — [x] tests run / [x] docs reviewed (import path in advanced examples switched to `@suaveplan/types`) / [ ] no action needed.
- `@suaveplan/drive-uri` writes plugins (drafted in parallel `add-drive-uri-writes`) — [x] tests run / [x] docs reviewed / [ ] no action needed. Listed because this proposal is the prerequisite that lets `add-drive-uri-writes` accept a `DriveLike` without depending on `@suaveplan/drive`.
Indirect consumers of `@suaveplan/types` (broad surface, unchanged contract):
- Every package in `STATUS.md` that lists `@suaveplan/types` as a dependency — no action needed (no canonical type renamed or removed; new exports are additive).
### Affected upstreams
| Package | Action | Breaking? |
|---|---|---|
| `@suaveplan/types` | ADDED: `DriveLike`, `DriveStorage`, `DrivePlugin` (drive-install flavour), `DriveMiddleware`, `MiddlewareContext` (drive flavour, under `./drive`), `MiddlewareNext`, `DriveOptions`, `ReadOptions`, `WriteOptions`, `ListOptions`, `DriveEntry`, `HydrateOptions`, `HydrateProgressEvent`, `HydrateResult`. MODIFIED: existing hook-bag `DrivePlugin` from `./plugin.js` renamed to `HookDrivePlugin` (root re-export updated in the same change). | no — only the renamed `DrivePlugin` (hook-bag) needs a consumer update; its only consumer is the drive runtime, updated in this change |
| `@suaveplan/drive` | `src/types/drive.types.ts` becomes a thin re-export. `package.json` adds `@suaveplan/types` to `dependencies` (it already imports from it). | no |
| `@suaveplan/drive-uri` | Imports switched to `@suaveplan/types` where only structural shape is needed; `Drive` concrete class still imported where instantiated (none today, all type-only). | no |
### Ordering
- This change MUST land **before** `add-drive-uri-writes` so the writes-plugin draft can author its `DriveLike` imports against the canonical home.
- The follow-up to delete the back-compat re-export from `@suaveplan/drive` is **deferred** to a future change after one release cycle.
## Migration
1. **Old behaviour:** `import type { DriveLike } from "@suaveplan/drive"` worked and pulled `@suaveplan/drive` into the consumer's dep graph.
2. **New behaviour:** Canonical home is `@suaveplan/types` (root) or `@suaveplan/types/drive` (subpath). The `@suaveplan/drive` export remains as a back-compat forwarder for one release cycle.
3. **Search command for consumers:**
```bash
grep -rn "DriveLike\|DriveMiddleware\|MiddlewareContext\|DrivePlugin\|DriveStorage" packages/ \
--include="*.ts" --include="*.tsx" | grep "@suaveplan/drive"
```
4. **Recommended replacement:** rewrite the import to `from "@suaveplan/types"` (root) or `from "@suaveplan/types/drive"` (subpath). The concrete `Drive` class import — `import { Drive } from "@suaveplan/drive"` — stays unchanged where the class is actually instantiated.

View File

@@ -0,0 +1,59 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: drive (hoist-drivelike-to-types)
**Package**: `@suaveplan/drive`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: `DriveLike` and drive-domain structural types are re-exported from `@suaveplan/types`
The package SHALL re-export `DriveLike`, `DriveStorage`, `DrivePlugin`, `DriveMiddleware`, `MiddlewareContext`, `MiddlewareNext`, `DriveOptions`, `ReadOptions`, `WriteOptions`, `ListOptions`, `DriveEntry`, `HydrateOptions`, `HydrateProgressEvent`, and `HydrateResult` from `@suaveplan/types/drive` via a thin forwarder at `src/types/drive.types.ts`. The forwarder MUST establish referential type identity with the canonical declarations (`expectTypeOf<DriveLike>().toEqualTypeOf<import("@suaveplan/types").DriveLike>()`). The forwarder MUST NOT carry `@deprecated` JSDoc per CLAUDE.md §2; the migration notice lives in `proposal.md`, in `MIGRATION.md`, and in the sibling `drive.types.md`. The forwarder is a back-compat alias retained for one release cycle to avoid a flag-day across consumers; its removal is deferred to a separate future change after a consumer audit returns zero external uses.
#### Scenario: `import { DriveLike } from "@suaveplan/drive"` still resolves
- **WHEN** a consumer writes `import type { DriveLike } from "@suaveplan/drive"`
- **THEN** `tsc` resolves the symbol AND the resolved type is referentially identical to `import("@suaveplan/types").DriveLike`
#### Scenario: Every previously-local drive-domain type still resolves under the old import path
- **WHEN** a consumer writes `import type { DriveMiddleware, DriveOptions, HydrateOptions } from "@suaveplan/drive"`
- **THEN** `tsc` resolves every name AND each matches the canonical declaration in `@suaveplan/types/drive`
**Verified by:** `packages/drive/drive/src/types/drive.types.spec-conformance.test.ts::"re-exports DriveLike from @suaveplan/types"` AND `packages/drive/drive/src/types/drive.types.spec-conformance.test.ts::"every prior drive-domain type is resolvable under the old import path"`
---
### Requirement: `@suaveplan/drive` declares `@suaveplan/types` as a `workspace:^` dependency
The package's `package.json` SHALL list `@suaveplan/types: workspace:^` in `dependencies`. The dep is required at runtime by the drive plugin manager (which consumes the install/dispose `DrivePlugin<THandle>` shape) and at type-check time by the forwarder in `src/types/drive.types.ts`. Bare version strings MUST NOT be used (project.md rule 3).
#### Scenario: Dependency manifest declares `@suaveplan/types`
- **WHEN** `packages/drive/drive/package.json` is read
- **THEN** `dependencies["@suaveplan/types"] === "workspace:^"`
**Verified by:** `packages/drive/drive/src/types/drive.types.spec-conformance.test.ts::"package.json declares @suaveplan/types as a workspace dep"`
---
### Requirement: Drive runtime consumes the install/dispose `DrivePlugin` from `@suaveplan/types`
The drive plugin manager (`Drive.usePlugin`) SHALL accept the install/dispose `DrivePlugin<THandle>` shape re-exported from `@suaveplan/types/drive`, not a local copy. The rename of the legacy hook-bag `DrivePlugin` (formerly in `@suaveplan/types/plugin`) to `HookDrivePlugin` MUST be reflected at every consumer site inside `@suaveplan/drive`; the package MUST NOT carry two declarations of the same name.
#### Scenario: `Drive.usePlugin` accepts the canonical `DrivePlugin`
- **WHEN** a consumer authors a plugin satisfying the install/dispose shape and passes it to `drive.usePlugin(plugin)`
- **THEN** `tsc` accepts the call AND no widening of `THandle` occurs
#### Scenario: No duplicate `DrivePlugin` declaration
- **WHEN** the `@suaveplan/drive` source tree is scanned for `interface DrivePlugin` or `type DrivePlugin`
- **THEN** zero local declarations are found (the only source is the re-export from `@suaveplan/types/drive`)
**Verified by:** `packages/drive/drive/src/plugins/registry.spec-conformance.test.ts::"usePlugin accepts the canonical DrivePlugin from @suaveplan/types"` AND `packages/drive/drive/src/types/drive.types.spec-conformance.test.ts::"no local duplicate DrivePlugin declaration"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,82 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: types (hoist-drivelike-to-types)
**Package**: `@suaveplan/types`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Canonical `DriveLike` structural contract
The package SHALL export `DriveLike` as a structural interface describing any object that satisfies a drive's public surface: the four injected accessors (`clock`, `rng`, `logger`, `events`), the plugin and middleware installers (`usePlugin<T>(plugin: DrivePlugin<T>): Promise<T>`, `useMiddleware(mw: DriveMiddleware): void`), the two core file operations (`read(path, options?): Promise<Uint8Array>`, `write(path, data, options?): Promise<void>`), and the storage lookup (`getStorage(id): DriveStorage`). The interface MUST contain no runtime members, no nominal branding, and no decorators — consumers MUST be able to satisfy it with any conformant object literal or class instance. The hoisted shape MUST match the prior `@suaveplan/drive` declaration field-for-field; renaming or re-typing any member is out of scope for this change.
#### Scenario: A literal satisfies `DriveLike`
- **WHEN** a consumer authors `const stub: DriveLike = { clock: undefined, rng: undefined, logger: undefined, events: undefined, usePlugin: async () => undefined as never, useMiddleware: () => {}, read: async () => new Uint8Array(), write: async () => {}, getStorage: () => ({} as DriveStorage) }`
- **THEN** `tsc` accepts the assignment AND the built bundle contains zero bytes from `@suaveplan/types`
#### Scenario: A `Drive` class instance satisfies `DriveLike`
- **WHEN** a consumer authors `const d: DriveLike = new Drive()` where `Drive` is the concrete class from `@suaveplan/drive`
- **THEN** `tsc` accepts the assignment with no widening or property-mismatch error
**Verified by:** `packages/core/types/tests/spec-conformance.test.ts::"DriveLike is assignable from a Drive-shaped object literal"` AND `packages/core/types/tests/spec-conformance.test.ts::"DriveLike is satisfied by a concrete Drive instance"`
---
### Requirement: Drive-domain types are exported at root and from `./drive` subpath
The package SHALL export `DriveLike`, `DriveStorage`, `DrivePlugin`, `DriveMiddleware`, `MiddlewareContext`, `MiddlewareNext`, `DriveOptions`, `ReadOptions`, `WriteOptions`, `ListOptions`, `DriveEntry`, `HydrateOptions`, `HydrateProgressEvent`, and `HydrateResult` from BOTH the root entry point (`@suaveplan/types`) AND the grouped subpath entry point (`@suaveplan/types/drive`). The `./drive` subpath MUST appear in `package.json`'s `exports` map per CLAUDE.md §6, and MUST resolve to a declaration-only build artefact. Both entry points MUST surface the same type identities (`expectTypeOf` equality across imports).
#### Scenario: Root import resolves
- **WHEN** a consumer writes `import type { DriveLike, DriveMiddleware, DriveOptions } from "@suaveplan/types"`
- **THEN** `tsc` resolves every name AND the symbols match the canonical declarations in `src/drive.ts`
#### Scenario: Subpath import resolves
- **WHEN** a consumer writes `import type { DriveLike, DriveMiddleware, DriveOptions } from "@suaveplan/types/drive"`
- **THEN** `tsc` resolves every name AND `expectTypeOf<DriveLike>().toEqualTypeOf<import("@suaveplan/types").DriveLike>()` holds
**Verified by:** `packages/core/types/tests/spec-conformance.test.ts::"DriveLike, DrivePlugin, DriveMiddleware, DriveStorage are exported at root"` AND `packages/core/types/tests/spec-conformance.test.ts::"DriveLike, DrivePlugin, DriveMiddleware, DriveStorage are exported from ./drive subpath"`
---
### Requirement: Hoist preserves zero-runtime invariant
Adding the drive-domain types MUST NOT introduce any runtime code into `@suaveplan/types`. The new `dist/drive.js` MUST be a ≤ 200-byte module stub, the package's `dependencies` / `peerDependencies` / `optionalDependencies` MUST stay empty, and the existing per-file budget walk MUST continue to exit zero. The new `src/drive.ts` MUST import only from sibling files inside `@suaveplan/types` (`./clock`, `./rng`, `./logger-like`, `./events-bus-like`, `./storage-like`, `./peer-id`, `./key-status`, `./chunk-presence`).
#### Scenario: `dist/drive.js` is within the stub budget
- **WHEN** the package is built
- **THEN** `dist/drive.js` is ≤ 200 bytes AND contains no function or class declarations AND contains no non-export runtime statements
#### Scenario: No outbound workspace dependency added
- **WHEN** `package.json` is read after the hoist
- **THEN** `dependencies`, `peerDependencies`, and `optionalDependencies` are still empty or absent
**Verified by:** `packages/core/types/tests/zero-runtime.test.ts::"every .js file under dist/ is ≤ 200 bytes"` AND `packages/core/types/tests/spec-conformance.test.ts::"@suaveplan/types has no runtime workspace deps after the drive hoist"`
## MODIFIED Requirements
### Requirement: Canonical shared types
The package SHALL export `Clock`, `MonotonicClock`, `IdGenerator`, `Rng`, `MessageResolver`, `EventBusLike`, `LoggerLike`, `StorageLike`, `DatabaseLike`, `CancellableOptions`, `TraceableOptions`, `DeterministicOptions`, AND the drive-domain types `DriveLike`, `DriveStorage`, `DrivePlugin`, `DriveMiddleware`, `MiddlewareContext`, `MiddlewareNext`, `DriveOptions`, `ReadOptions`, `WriteOptions`, `ListOptions`, `DriveEntry`, `HydrateOptions`, `HydrateProgressEvent`, `HydrateResult` as named type/interface exports from the root entry point. The previously-exported hook-bag plugin shape (formerly `DrivePlugin` from `./plugin.js`) is renamed to `HookDrivePlugin`; the install/dispose drive plugin shape now owns the `DrivePlugin` name from `./drive.js`.
#### Scenario: Consumer uses `import type` for a drive-domain symbol
- **WHEN** a consumer writes `import type { DriveLike } from "@suaveplan/types"`
- **THEN** `tsc` accepts the import AND the built bundle contains zero bytes from `@suaveplan/types`
#### Scenario: Hook-bag plugin is reachable under its new name
- **WHEN** a consumer writes `import type { HookDrivePlugin } from "@suaveplan/types"`
- **THEN** `tsc` resolves it AND the shape matches the prior hook-bag declaration field-for-field
**Verified by:** `packages/core/types/tests/spec-conformance.test.ts::"every canonical type is exported at root"` AND `packages/core/types/tests/spec-conformance.test.ts::"HookDrivePlugin satisfies the hook-bag contract"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,56 @@
# Tasks — hoist-drivelike-to-types
Implementation ordered so each step keeps the workspace green.
## 1. Canonical declarations in `@suaveplan/types`
- [ ] 1.1 Create `packages/core/types/src/drive.ts` exporting the canonical declarations: `DriveLike`, `DriveStorage`, `DrivePlugin<THandle>` (install/dispose flavour), `DriveMiddleware`, `MiddlewareContext` (drive flavour), `MiddlewareNext<T>`, `DriveOptions`, `ReadOptions`, `WriteOptions`, `ListOptions`, `DriveEntry`, `HydrateOptions`, `HydrateProgressEvent`, `HydrateResult`. Copy bodies verbatim from `packages/drive/drive/src/types/drive.types.ts`; replace the `AbstractStorage` import with a structural `StorageLike` reference where possible, falling back to the existing `StorageLike` already in `@suaveplan/types` for the `DriveStorage` base.
- [ ] 1.2 Author sibling `packages/core/types/src/drive.md` per CLAUDE.md §4 (Purpose ≥ 2 sentences, Features, Basic + Advanced usage, API Reference enumerating every exported symbol, Implementation Notes covering the structural-not-nominal contract and the zero-runtime guarantee).
- [ ] 1.3 Rename the existing hook-bag `DrivePlugin` in `packages/core/types/src/plugin.ts` to `HookDrivePlugin`. Update the root barrel (`src/index.ts`) re-export accordingly. Update the sibling `plugin.md` to describe the rename.
- [ ] 1.4 Add a `./drive` subpath barrel: extend the root `src/index.ts` to re-export every new symbol from `./drive.js` for flat consumers, and add the `./drive` entry to `package.json`'s `exports` map per CLAUDE.md §6 / project.md rule 11.
- [ ] 1.5 Confirm `@suaveplan/types`'s `dependencies` / `peerDependencies` / `optionalDependencies` remain empty — the new file MUST NOT introduce a runtime dep (it imports only from sibling files inside `@suaveplan/types`).
## 2. Tests for `@suaveplan/types`
- [ ] 2.1 Extend `packages/core/types/tests/spec-conformance.test.ts` with `expectTypeOf` cases proving: (a) `DriveLike` is assignable from a literal that shape-matches `Drive`'s public surface; (b) every newly added symbol is exported from the root and from `@suaveplan/types/drive`; (c) the renamed `HookDrivePlugin` still satisfies the hook-bag contract.
- [ ] 2.2 Re-run the existing `tests/zero-runtime.test.ts` per-file budget walk to confirm the new `dist/drive.js` is a ≤ 200-byte stub.
- [ ] 2.3 Coverage gate stays at 100/100/100/100 (no new runtime added).
## 3. Back-compat re-export in `@suaveplan/drive`
- [ ] 3.1 Rewrite `packages/drive/drive/src/types/drive.types.ts` as a thin `export type {...} from "@suaveplan/types/drive"` forwarder covering every previously local declaration. NO `@deprecated` JSDoc (CLAUDE.md §2). The single file's prose comment names the canonical home.
- [ ] 3.2 Add `@suaveplan/types` to `packages/drive/drive/package.json`'s `dependencies` via `workspace:^` (it is already transitively imported for `Clock`, `Rng`, `LoggerLike`, `EventBusLike`, `PeerId`).
- [ ] 3.3 Update sibling `drive.types.md` to state that the canonical home is `@suaveplan/types/drive` and the local file is a back-compat re-export; update the package `README.md`'s Features / Modules section accordingly per CLAUDE.md §13a + §4a.
- [ ] 3.4 Add `packages/drive/drive/src/types/drive.types.spec-conformance.test.ts::"re-exports DriveLike from @suaveplan/types"` proving referential type identity via `expectTypeOf<DriveLike>().toEqualTypeOf<import("@suaveplan/types").DriveLike>()`.
- [ ] 3.5 Update the local `DrivePlugin` consumer (the drive plugin manager) to import from the renamed `HookDrivePlugin` if and only if the drive runtime was using the hook-bag flavour; otherwise switch to the install/dispose `DrivePlugin` re-exported from `@suaveplan/types/drive`. Verify with a full-package test run.
## 4. Consumer migration — `@suaveplan/drive-uri`
- [ ] 4.1 In `packages/drive/drive-uri/src/types/plugin-options.types.ts`, switch `import type { Drive } from "@suaveplan/drive"` to `import type { DriveLike } from "@suaveplan/types"` for the `drive` field type on every option interface where the structural shape is sufficient. Retain `Drive` only where a method exclusive to the concrete class is required (audit: none today).
- [ ] 4.2 Repeat the import switch in `src/adapter/drive-storage-like.ts`, `src/plugins/drive/drive.ts`, `src/plugins/drive/drive.streams.ts`, `src/plugins/drive-mesh/drive-mesh.ts`, `src/plugins/drive-template/drive-template.ts`. Each `.md` sibling MUST be updated in the same edit per CLAUDE.md §4a (the advanced example's import line changes).
- [ ] 4.3 Add `packages/drive/drive-uri/src/types/plugin-options.spec-conformance.test.ts::"plugin options accept a structural DriveLike, not only the concrete Drive class"` exercising the structural acceptance via a minimal stub.
- [ ] 4.4 Remove `@suaveplan/drive` from `packages/drive/drive-uri/package.json`'s `dependencies` if and only if no concrete-class import remains. Otherwise leave it. Document the choice in the change folder.
## 5. Cross-cutting verification
- [ ] 5.1 `bunx turbo lint typecheck test --filter=@suaveplan/types` — green.
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/drive` — green.
- [ ] 5.3 `bunx turbo lint typecheck test --filter=@suaveplan/drive-uri` — green.
- [ ] 5.4 Foundation dependent sweep per AGENTS.md §13 step 10: `bunx turbo test --filter=...@suaveplan/types` — green.
- [ ] 5.5 `bunx turbo test:coverage --filter=@suaveplan/types` — 100/100/100/100.
- [ ] 5.6 Banned-pattern scan (§13 step 4) returns zero hits on touched packages, including zero `@deprecated`.
- [ ] 5.7 Test-runner-import scan (§13 step 4b) returns zero on touched packages.
## 6. Doc & manifest refresh (AGENTS.md §13b)
- [ ] 6.1 `bun scripts/folder-reorg/generate-manifest.ts` to refresh `MANIFEST.md`.
- [ ] 6.2 Update `STATUS.md` if the package row needs in-flight status.
- [ ] 6.3 Update `SUMMARY.md` `@suaveplan/types` paragraph to mention the new `./drive` subpath.
- [ ] 6.4 Add a `MIGRATION.md` entry naming the old import path and the canonical home.
- [ ] 6.5 `bunx markdownlint-cli2 --fix` then `bunx markdownlint-cli2` (must exit 0).
## 7. Spec fold & archive (AGENTS.md §14)
- [ ] 7.1 Fold `specs/types/spec.md` into `openspec/specs/types/spec.md`.
- [ ] 7.2 Fold `specs/drive/spec.md` into `openspec/specs/drive/spec.md`.
- [ ] 7.3 Move `openspec/changes/hoist-drivelike-to-types/` → `openspec/archive/<yyyy-mm-dd>-hoist-drivelike-to-types/`.

View File

@@ -0,0 +1,111 @@
# Design: unify-rng-across-packages
## Goal
Single canonical `Rng` interface across every `@suaveplan/*` package, with
zero runtime behaviour change and a one-release-cycle deprecation window
for the local aliases that are being phased out.
## Strategy
1. **Structural conformance over re-declaration.** The canonical `Rng` in
`@suaveplan/types` (`src/rng.ts`) is the contract. Local types that
exactly match this shape become deprecated re-exports of the canonical.
Local types that diverge are evaluated case-by-case (see compatibility
table below).
2. **Deprecated forwarders, not deletions.** Per CLAUDE.md §2, JSDoc
`@deprecated` tags are banned. The aliases are kept as named re-exports
with prose comments ("Backwards-compatibility alias for `@suaveplan/types`
`Rng` — prefer the canonical import; this re-export will be removed in a
follow-up change.") so existing consumers continue to compile.
3. **Type-only migration.** No runtime behaviour changes. Every package's
full test suite must remain green with zero assertion edits before and
after the swap. Verified with
`bunx turbo typecheck test --filter=@suaveplan/<pkg>` (rule 1: never
`tsc` directly).
## Compatibility table
| Package | Local symbol | Local surface | Canonical surface | Compatible? | Action |
|---|---|---|---|---|---|
| `@suaveplan/types` | `Rng` | `{ nextUint32(): number; nextFloat(): number; nextBytes?(n): Uint8Array; nextIntInRange?(min, maxExclusive): number }` | — (canonical) | — | None — source of truth. |
| `@suaveplan/crypto-core` | `type Rng = ByteSource` where `ByteSource = (n: number) => Uint8Array` | `(n) => Uint8Array` (a **function**, not an object with methods) | object with `nextUint32`/`nextFloat` | **No** — structurally incompatible | Drop the `Rng` alias entirely; keep `ByteSource` as the package's own concept (distinct contract: a byte-source callable, not a uniform-distribution number source). Re-export the canonical `Rng` as a deprecated forwarder so any external code that imported `crypto-core`'s `Rng` keeps compiling but now sees the canonical shape. **Note**: any consumer that was actually using `crypto-core`'s `Rng` as a callable will fail typecheck — this is intentional and the migration note in proposal.md flags it. |
| `@suaveplan/crypto` | re-exports `Rng` from `crypto-core` | same as crypto-core | object | inherits from crypto-core | Update `src/index.ts` to forward the canonical `Rng` (via crypto-core's new deprecated re-export). |
| `@suaveplan/testing` | `interface Rng { nextUint32(): number; nextFloat(): number }` | exact subset of canonical | object | **Yes** — structural match (canonical adds two OPTIONAL members; required members are identical) | Import canonical `Rng`; re-export as deprecated forwarder. `cryptoRng`/`seededRng` implementations satisfy the canonical type without code changes (they implement the required members and omit the optional ones). Add `@suaveplan/types` as a `workspace:^` dependency (rule 8 exception: testing-infrastructure may depend on `@suaveplan/types` for types-only, byte-size-test guarded). |
## Why `crypto-core`'s `ByteSource` is not the canonical `Rng`
The canonical `Rng` is an **object** with `nextUint32` / `nextFloat` (uniform
number sources). `crypto-core`'s `ByteSource` is a **function** that returns
random bytes — a fundamentally different shape and use case (key material,
nonces, AEAD entropy). These should never have shared the `Rng` name. The
migration cleans up the naming collision: `ByteSource` stays under its real
name, and `Rng` becomes the canonical numeric RNG everywhere.
## Back-compat strategy
For one release cycle (until the follow-up removal change archives):
- `@suaveplan/crypto-core/random-bytes` exports a deprecated `Rng` re-export
pointing at `@suaveplan/types`' `Rng`. The original `type Rng = ByteSource`
alias is **removed** because keeping both shapes under the same name would
break the typecheck.
- `@suaveplan/crypto/index` forwards the same deprecated alias.
- `@suaveplan/testing/context` exports a deprecated `Rng` re-export of the
canonical type. The local `interface Rng` declaration is removed.
The deprecation prose comment names the canonical import path and the
removal change ID (TBD when the follow-up is opened).
## Removal follow-up (not in scope here)
A separate OpenSpec change titled
`remove-deprecated-rng-aliases` (opened **at least one release cycle after
this change archives**) will:
1. Delete the `Rng` re-export from `@suaveplan/crypto-core/primitives/random-bytes/random-bytes.ts`.
2. Delete the `Rng` re-export from `@suaveplan/crypto/src/index.ts`.
3. Delete the `Rng` re-export from `@suaveplan/testing/src/context/context.ts`.
4. Run dependent sweep (`bunx turbo test --filter=...@suaveplan/<pkg>`) for
each of the three packages.
5. Update consumers caught by the sweep with the canonical import.
This is tracked as a follow-up item — not a phase-2 split of this change
(per CLAUDE.md §2b, this change is complete on its own surface).
## Error codes
No new error codes are introduced. The migration is structural-types-only;
no new throw sites, no new error paths.
## Verification commands (per CLAUDE.md rule 1)
```bash
# Per affected package — full pre-publish gate
bunx turbo lint typecheck test --filter=@suaveplan/crypto-core
bunx turbo lint typecheck test --filter=@suaveplan/crypto
bunx turbo lint typecheck test --filter=@suaveplan/testing
# Dependent sweep for testing (factory consumers)
bunx turbo test --filter=...@suaveplan/testing
# Repo-wide grep — only the canonical declaration plus the three deprecated
# re-exports should remain
grep -rln "interface Rng\b\|type Rng = " packages/ --include="*.ts" \
| grep -v "/dist/\|/node_modules/"
```
## File layout (per affected package)
No new files. The migration edits exactly three implementation files and
their three sibling `.md` files (doc-drift gate, CLAUDE.md §4a):
```text
packages/crypto/crypto-core/src/primitives/random-bytes/random-bytes.ts # swap alias
packages/crypto/crypto-core/src/primitives/random-bytes/random-bytes.md # update API reference
packages/crypto/crypto/src/index.ts # update forwarder
packages/crypto/crypto/src/index.md # update API reference (if mentions Rng)
packages/testing/testing/src/context/context.ts # swap interface for re-export
packages/testing/testing/src/context/context.md # update API reference
packages/testing/testing/package.json # add @suaveplan/types: workspace:^
```

View File

@@ -0,0 +1,109 @@
# OpenSpec Change: unify-rng-across-packages
- **Change ID**: unify-rng-across-packages
- **Package**: `@suaveplan/types` (canonical) + downstream consumers
- **Design**: ./design.md
- **Spec delta**: ./specs/types/spec.md
- **Status**: PROPOSED
- **Date**: 2026-05-17
## Why
The 2026-04-27 `types-contract-tightening` change widened the canonical `Rng`
interface in `@suaveplan/types` (adding optional `nextBytes` and
`nextIntInRange` members) and explicitly **deferred** migration of the
packages that were redefining `Rng`-shaped interfaces locally
(`crypto-core`, `cache`, `idempotency`). That deferral has lapsed: every
fresh package author who needs randomness still has to choose between
importing the canonical `Rng` or hand-rolling a local one, and the existing
local redefinitions silently fork the contract.
This change closes the loop. It migrates every genesis package with a local
`Rng` type to consume the canonical `Rng` from `@suaveplan/types`, leaving
deprecated aliases in place for one release cycle so consumers can update at
their own pace.
## What changes
For each affected genesis package, the source files that currently declare a
local `Rng` interface are migrated to:
1. Import the canonical `Rng` from `@suaveplan/types`.
2. Re-export the canonical `Rng` from the original module path under the
original name, with a "Backwards-compatibility alias for `@suaveplan/types`
`Rng`" prose comment (per CLAUDE.md §2 — no `@deprecated` JSDoc).
3. Add `@suaveplan/types` as a `workspace:^` dependency where missing.
4. Re-run `bunx turbo typecheck` and `bunx turbo test` to confirm zero
runtime / behavioural change.
The spec delta is a `## MODIFIED Requirements` block on the existing
`Rng has optional bounded operations` requirement in `openspec/specs/types/`
that adds an explicit single-source-of-truth clause: no other `@suaveplan/*`
package may declare its own `Rng` interface; downstream packages that need a
narrower or wider random-source shape MUST either consume `Rng` directly or
define a sub-interface that `extends` it.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/types` to enumerate. Packages that
currently declare a local `Rng` type (surveyed via
`grep -rln "interface Rng\b\|type Rng = " packages/`) are:
- `@suaveplan/crypto-core` — [x] tests run / [x] docs reviewed / [ ] no action needed
(declares `type Rng = ByteSource` as a back-compat alias in
`src/primitives/random-bytes/random-bytes.ts`)
- `@suaveplan/crypto` — [x] tests run / [x] docs reviewed / [ ] no action needed
(re-exports the alias from `crypto-core` via `src/index.ts`)
- `@suaveplan/testing` — [x] tests run / [x] docs reviewed / [ ] no action needed
(declares a full `interface Rng` in `src/context/context.ts`; consumed by
`src/factories/factory/factory.ts`)
For every other dependent of `@suaveplan/types`: [x] no action needed —
they already consume `Rng` from `@suaveplan/types` and are unaffected by
this change.
### Source mapping (local Rng → canonical)
| Package | Local file | Local symbol | Action |
|---|---|---|---|
| `@suaveplan/crypto-core` | `src/primitives/random-bytes/random-bytes.ts` | `type Rng = ByteSource` | Replace with deprecated re-export of canonical `Rng`; keep `ByteSource` (distinct contract). |
| `@suaveplan/crypto` | `src/index.ts` | re-export `Rng` from `crypto-core` | Update re-export to point at canonical alias. |
| `@suaveplan/testing` | `src/context/context.ts` | `interface Rng { nextUint32; nextFloat }` | Import canonical `Rng`; re-export as deprecated alias. Add `@suaveplan/types` workspace dep (rule 8 exception). |
### Verification
- **Verified by:** `bunx turbo typecheck test --filter=@suaveplan/crypto-core`
- **Verified by:** `bunx turbo typecheck test --filter=@suaveplan/crypto`
- **Verified by:** `bunx turbo typecheck test --filter=@suaveplan/testing`
- **Verified by:** `bunx turbo typecheck test --filter=...@suaveplan/testing`
(dependents of `testing` consume `Rng` transitively via `factory.ts`)
- **Verified by:** repo-wide grep `grep -rln "interface Rng\b\|type Rng = " packages/ --include="*.ts" | grep -v "/dist/\|/node_modules/"` returns only the canonical declaration in `packages/core/types/src/rng.ts` plus the deprecated alias forwarders the change introduces.
## Out of scope
- **Widening the canonical `Rng`.** If a consumer needs additional members
(e.g. `nextBigInt`, streaming bytes), open a separate
`types-contract-tightening`-style change against `@suaveplan/types`.
- **Removing the deprecated aliases.** Follow-up change (one release cycle
after this one archives) removes the `Rng` re-exports from `crypto-core`,
`crypto`, and `testing`. Tracked as a TODO in this proposal's design.md.
- **Upstream-only packages (`cache`, `idempotency` upstream RNG redefinitions).**
The 2026-04-27 deferral text named `cache` and `idempotency` as RNG-shape
redefiners. On genesis, `@suaveplan/idempotency` and the three cache
packages (`@suaveplan/cache`, `cache-storage`, `cache-redis`) do **not**
currently reference `Rng` at all (verified by grep). When those packages
port the upstream randomness features, the migration must consume the
canonical `Rng` directly — there is no local type to migrate here yet.
- **Runtime behaviour changes.** This change is type-only; the test suite
must remain green pre- and post-migration with zero assertion edits.
## Migration
- **Old**: consumers importing `Rng` from `@suaveplan/crypto-core`,
`@suaveplan/crypto`, or `@suaveplan/testing`.
- **New**: import `Rng` from `@suaveplan/types`.
- **Search command**: `grep -rln "from ['\"]@suaveplan/\(crypto-core\|crypto\|testing\)['\"]" packages/ | xargs grep -l "Rng\b"`
- **Recommended replacement**: `import type { Rng } from "@suaveplan/types"`.
The deprecated alias re-exports remain functional for one release cycle.

View File

@@ -0,0 +1,38 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: types (types)
**Package**: `@suaveplan/types`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## MODIFIED Requirements
### Requirement: Rng has optional bounded operations
The `Rng` interface SHALL declare `nextBytes(n: number): Uint8Array` and `nextIntInRange(min: number, maxExclusive: number): number` as OPTIONAL members. Implementations SHOULD provide them when efficient (CSPRNGs typically expose `getRandomValues` directly; deterministic seeded RNGs can derive both from `nextUint32`). Consumers needing them MUST narrow on presence (`if (rng.nextBytes)`). `nextIntInRange` is contractually expected to use unbiased rejection sampling, not the `% range` shortcut.
`@suaveplan/types` SHALL be the **single canonical home** for the `Rng` interface across the genesis monorepo. No other `@suaveplan/*` package MAY declare its own top-level `Rng` interface or `type Rng = ...` alias that differs structurally from the canonical export. Packages that need a narrower or wider random-source shape MUST either (a) consume the canonical `Rng` directly, (b) define a sub-interface that `extends Rng`, or (c) define a distinct concept under a non-`Rng` name (e.g. `ByteSource` for a callable byte-producer, since that is a different contract from a uniform-numeric RNG). Packages MAY re-export the canonical `Rng` under the name `Rng` as a backwards-compatibility forwarder during a documented deprecation window.
**Verified by:** `src/rng.ts` (interface) AND `tests/spec-conformance.test.ts` (optional-member presence + minimal-impl assignability) AND repo-wide `grep -rln "interface Rng\b\|type Rng = " packages/ --include="*.ts" | grep -v "/dist/\|/node_modules/"` returns only `packages/core/types/src/rng.ts` plus documented deprecated re-export forwarders.
#### Scenario: nextBytes is optional
- **WHEN** a consumer declares `const r: Rng = { nextUint32: () => 0, nextFloat: () => 0 }`
- **THEN** `tsc` accepts (no `nextBytes` provided)
#### Scenario: nextIntInRange signature
- **WHEN** an implementation provides `nextIntInRange(min, maxExclusive): number`
- **THEN** `tsc` accepts AND `nextIntInRange(0, 10)` returns `number`
#### Scenario: canonical Rng is the only authoritative declaration
- **WHEN** a developer runs `grep -rln "interface Rng\b\|type Rng = " packages/ --include="*.ts" | grep -v "/dist/\|/node_modules/"` across the genesis monorepo
- **THEN** the only top-level `Rng` declaration returned is `packages/core/types/src/rng.ts`, and any additional matches are documented deprecated re-export forwarders (e.g. `export type { Rng } from "@suaveplan/types";`) — never an independent interface body
#### Scenario: sub-interface extension is permitted
- **WHEN** a downstream package defines `interface BoundedRng extends Rng { nextIntInRange(min: number, maxExclusive: number): number }` to require the optional member
- **THEN** `tsc` accepts AND any value of type `BoundedRng` is assignable to `Rng`

View File

@@ -0,0 +1,64 @@
# Tasks: unify-rng-across-packages
## 1. `@suaveplan/crypto-core` — migrate Rng alias
- [ ] 1.1 Locate local `Rng` declaration: `packages/crypto/crypto-core/src/primitives/random-bytes/random-bytes.ts` line 12 (`type Rng = ByteSource`).
- [ ] 1.2 Confirm structural compatibility — `ByteSource = (n: number) => Uint8Array` is **not** structurally compatible with canonical `Rng`; see design.md compatibility table for rationale.
- [ ] 1.3 Remove `type Rng = ByteSource` alias; keep `ByteSource` under its real name.
- [ ] 1.4 Add deprecated re-export: `export type { Rng } from "@suaveplan/types";` with prose comment ("Backwards-compatibility alias for `@suaveplan/types` `Rng` — prefer the canonical import; this re-export will be removed in the follow-up `remove-deprecated-rng-aliases` change.").
- [ ] 1.5 Verify `@suaveplan/types` is in `dependencies` (it already is per `MANIFEST.md` dep graph).
- [ ] 1.6 Update sibling `random-bytes.md` API reference: drop `Rng` from the local symbols list; document the back-compat re-export under "Implementation Notes".
- [ ] 1.7 Update barrel re-exports if `Rng` is named in `packages/crypto/crypto-core/src/primitives/random-bytes/index.ts` or `src/primitives/index.ts` or `src/index.ts` — keep the name routed through the new deprecated forwarder.
- [ ] 1.8 `bunx turbo typecheck --filter=@suaveplan/crypto-core` — green.
- [ ] 1.9 `bunx turbo test --filter=@suaveplan/crypto-core` — green, zero assertion edits.
- [ ] 1.10 `bunx turbo test:coverage --filter=@suaveplan/crypto-core` — 100/100/100/100 preserved.
## 2. `@suaveplan/crypto` — update forwarder
- [ ] 2.1 Locate `Rng` re-export in `packages/crypto/crypto/src/index.ts`.
- [ ] 2.2 Confirm it forwards from `@suaveplan/crypto-core`; no further changes needed beyond confirming the canonical type flows through.
- [ ] 2.3 If `Rng` is mentioned in `packages/crypto/crypto/src/index.md` or any sibling doc, update to reference `@suaveplan/types` as the canonical home.
- [ ] 2.4 `bunx turbo typecheck --filter=@suaveplan/crypto` — green.
- [ ] 2.5 `bunx turbo test --filter=@suaveplan/crypto` — green.
## 3. `@suaveplan/testing` — migrate Rng interface
- [ ] 3.1 Locate local `Rng` declaration: `packages/testing/testing/src/context/context.ts` line 45 (`interface Rng { nextUint32(): number; nextFloat(): number }`).
- [ ] 3.2 Confirm structural compatibility — required members are an exact match for canonical `Rng`; canonical adds two OPTIONAL members which existing `cryptoRng` / `seededRng` implementations may omit.
- [ ] 3.3 Add `"@suaveplan/types": "workspace:^"` to `packages/testing/testing/package.json` `dependencies` (rule 8 exception: testing-infrastructure may depend on `@suaveplan/types` for types-only consumption — verify byte-size test still passes since `@suaveplan/types` emits zero-runtime stubs).
- [ ] 3.4 Replace `interface Rng { ... }` with `import type { Rng } from "@suaveplan/types"; export type { Rng };` plus prose comment marking the local re-export as a backwards-compatibility alias.
- [ ] 3.5 Confirm `cryptoRng: Rng = { ... }` and `seededRng(seed): Rng` still typecheck against canonical `Rng` (they should — they provide only the required members).
- [ ] 3.6 Update `packages/testing/testing/src/factories/factory/factory.ts` import — `import { cryptoRng, type Rng } from "../../context/context.js"` continues to work via the re-export, so no edit required unless the import path is changed for clarity.
- [ ] 3.7 Update sibling `context.md` API reference: note that `Rng` is now a re-export of the canonical `@suaveplan/types` `Rng`; remove the local interface from "API Reference"; cross-link to `@suaveplan/types`.
- [ ] 3.8 Update sibling `factory.md` if it mentions the local `Rng` source.
- [ ] 3.9 Verify rule 8 byte-size guard still passes (`@suaveplan/types` emits zero runtime).
- [ ] 3.10 `bunx turbo typecheck --filter=@suaveplan/testing` — green.
- [ ] 3.11 `bunx turbo test --filter=@suaveplan/testing` — green, zero assertion edits.
- [ ] 3.12 `bunx turbo test:coverage --filter=@suaveplan/testing` — 100/100/100/100 preserved.
- [ ] 3.13 Dependent sweep: `bunx turbo test --filter=...@suaveplan/testing` — every consumer of `factory.ts` / `context.ts` `Rng` continues to typecheck and pass.
## 4. Spec delta
- [ ] 4.1 Author `specs/types/spec.md` delta with `## MODIFIED Requirements` block on `Rng has optional bounded operations` adding the single-source-of-truth clause.
- [ ] 4.2 Carry `**Verified by:**` on the modified requirement.
## 5. Repo-wide verification gate
- [ ] 5.1 `grep -rln "interface Rng\b\|type Rng = " packages/ --include="*.ts" | grep -v "/dist/\|/node_modules/"` returns exactly:
- `packages/core/types/src/rng.ts` (canonical)
- the three deprecated re-export forwarders introduced above
- [ ] 5.2 No other genesis package declares a local `Rng` interface.
- [ ] 5.3 `bunx turbo lint typecheck --filter=...` (full repo) — green.
## 6. Doc-drift + behaviour-change propagation
- [ ] 6.1 Per CLAUDE.md §4a: every `.ts` edited has its sibling `.md` updated in the same change set.
- [ ] 6.2 Per CLAUDE.md §13a: add the `## Migration` note to proposal.md (already authored).
- [ ] 6.3 Repo-wide consumer scan: `grep -rln "from ['\"]@suaveplan/\(crypto-core\|crypto\|testing\)['\"]" packages/ | xargs grep -l "Rng\b"` — every hit either continues to compile via the deprecated forwarder OR is updated to the canonical import in this change set.
## 7. Fold + archive
- [ ] 7.1 Fold `specs/types/spec.md` delta into `openspec/specs/types/spec.md`.
- [ ] 7.2 Move `openspec/changes/unify-rng-across-packages/` → `openspec/archive/<yyyy-mm-dd>-unify-rng-across-packages/`.
- [ ] 7.3 Refresh `MANIFEST.md` / `MISSING.yml` / `STATUS.md` / `SUMMARY.md` per CLAUDE.md §13b (no package add/remove here, but dep-graph edge added: `@suaveplan/testing → @suaveplan/types`).
- [ ] 7.4 Open the follow-up `remove-deprecated-rng-aliases` change to track removal of the three forwarders after one release cycle.