Files
2026-05-27 23:29:53 +01:00

395 lines
23 KiB
Markdown

# Tasks: enterprise-storage
Change ID: `enterprise-storage`
Package: `@suaveplan/storage`
Path: `universal/storage/storage`
Work tasks in order. Each section is a logical gate — do not start the next section until all checkboxes in the current section are green and `bunx turbo lint typecheck test --filter=@suaveplan/storage` passes.
---
## 1. Scaffold
- [x] Create `universal/storage/storage/` directory tree:
```
universal/storage/storage/
src/
abstract/
errors/
factory/
memory/
types/
__benchmarks__/
tests/
package.json
tsconfig.json
tsconfig.build.json
vite.config.ts
vitest.config.ts
biome.json (extend root)
```
- [x] Write `package.json`:
- `name`: `@suaveplan/storage`, `version`: `0.1.0`, `private`: false
- `"sideEffects": false`
- `type`: `"module"`
- `main`/`module`/`types` pointing at `dist/` output
- Subpath exports:
- `"."` → `./dist/index.js`
- `"./abstract"` → `./dist/abstract/index.js`
- `"./errors"` → `./dist/errors/index.js`
- `"./factory"` → `./dist/factory/index.js`
- `"./memory"` → `./dist/memory/index.js`
- `"./types"` → `./dist/types/index.js`
- Runtime deps: `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/events workspace:^`, `zod catalog:forms`, `@opentelemetry/api catalog:observability`, `tslib catalog:build`
- Dev deps: `vitest catalog:testing`, `@vitest/coverage-istanbul catalog:testing`, `fast-check catalog:testing`, `mitata catalog:testing`
- Scripts: `build`, `test`, `test:coverage`, `test:bench`, `lint`, `typecheck`, `docs:check`
- [x] Write `tsconfig.json` extending `@suaveplan/typescript-config/library.json`
- [x] Write `tsconfig.build.json` (excludes test files and `__benchmarks__`)
- [x] Write `vite.config.ts` for library build (entry `src/index.ts`, formats `es`/`cjs`, `dts: true`)
- [x] Write `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`; set `provider: 'istanbul'`, `thresholds` at 100/100/100/100
- [x] Add package to `turbo.json` pipeline (inherits global tasks — confirm no extra config needed)
- [x] Add `@suaveplan/storage` to root `bun.lockb` workspace list and run `bun install`
- [x] Write guard test `tests/tree-shake.test.ts`:
- Assert the built `dist/index.js` contains no `import ... from 'node:*'` (universal runtime constraint)
- Assert `dist/index.js` byte size is tracked (soft limit alert at 50 KB)
- [x] Confirm `bunx turbo build --filter=@suaveplan/storage` exits 0 (even with empty `src/index.ts`)
---
## 2. Types + Error Codes
### 2a. Error codes (`src/errors/`)
- [x] Create `src/errors/errors.ts`:
- Import `SuaveplanError`, `defineCodes` from `@suaveplan/error`
- Define code registry with `defineCodes`:
```
STORAGE.GENERIC
STORAGE.NOT_FOUND
STORAGE.PERMISSION_DENIED
STORAGE.QUOTA_EXCEEDED
STORAGE.INVALID_KEY
STORAGE.BATCH_ERROR
STORAGE.ABORTED
```
- Implement `StorageError extends SuaveplanError` — constructor accepts `(message, code, key?, cause?)`; preserves `key` as a typed public field; emits OTel span event on construction
- Implement `StorageNotFoundError extends StorageError` — constructor `(key, cause?)`
- Implement `StoragePermissionError extends StorageError` — constructor `(key, operation, cause?)`
- Implement `StorageQuotaError extends StorageError` — constructor `(key, cause?)`
- Implement `StorageInvalidKeyError extends StorageError` — constructor `(key, reason, cause?)`
- Implement `StorageBatchError extends StorageError` — constructor `(message, cause?)`
- [x] Create `src/errors/errors.types.ts` with any shared error-related type aliases
- [x] Create `src/errors/errors.md` (purpose, features, usage examples, API reference, implementation notes — ≥ 200 words)
- [x] Create `src/errors/index.ts` barrel
- [x] Create `src/errors/errors.test.ts`:
- Instantiate each error class and assert `name`, `code`, `key`, `cause` fields
- Assert each is `instanceof SuaveplanError`
- Assert `StorageError` with each `STORAGE.*` code round-trips through `@suaveplan/error` wire format
- `{ timeout: 5000 }` on all tests
### 2b. Types and Zod schemas (`src/types/`)
- [x] Create `src/types/types.ts` — all interface definitions from the preserved surface (see proposal § Preserved public API):
- `StorageOptions`, `StorageMetadata`, `StorageResult<T>`, `StorageListOptions`, `StorageListResult`, `StoragePatternOptions`, `StorageBatchOperation`, `StorageBatchResult`, `StorageStats`, `StorageEventType`, `StorageEventData`, `StorageEventHandler`, `StoragePreSetHook`, `StoragePostGetHook`
- All `metadata` fields typed `Record<string, unknown>` (not `any`)
- [x] Create `src/types/schemas.ts` — Zod schemas:
- `StorageOptionsSchema`, `StorageMetadataSchema`, `StorageResultSchema` (generic via `z.object` + type param helper), `StorageListOptionsSchema`, `StorageListResultSchema`, `StoragePatternOptionsSchema`, `StorageBatchOperationSchema`, `StorageBatchResultSchema`, `StorageStatsSchema`
- Export inferred types alongside schemas (e.g. `export type StorageOptions = z.infer<typeof StorageOptionsSchema>`)
- [x] Create `src/types/types.md` and `src/types/schemas.md` (≥ 200 words each)
- [x] Create `src/types/index.ts` barrel
- [x] Create `src/types/types.test.ts`:
- Parse valid and invalid objects through every Zod schema
- Assert discriminated union `StorageBatchOperationSchema` correctly narrows on `type`
- `{ timeout: 5000 }` on all tests
---
## 3. AbstractStorage Core
### 3a. Implementation (`src/abstract/abstract.ts`)
- [x] Declare `AbstractStorage implements StorageLike` (import from `@suaveplan/types`)
- [x] Constructor accepts optional `eventBus?: EventBusLike` (from `@suaveplan/types`); defaults to `new EventBus()` from `@suaveplan/events`
- [x] Internal `protected stats: StorageStats` initialized with all counters at 0
- [x] Internal `protected preSetHooks: StoragePreSetHook[]` and `protected postGetHooks: StoragePostGetHook[]`
- [x] Internal `protected maxValueSize: number` defaulting to `10 * 1024 * 1024`
- [x] Internal `protected maxTotalSize: number | undefined`
- [x] Implement `protected validateKey(key: string): void` — checks: non-empty, no `..` or `\`, no absolute path, length ≤ 1024, no `<>"|*?`; throws `StorageInvalidKeyError`
- [x] Implement `protected async validateValueSize(key, value, signal?)` — throws `StorageQuotaError` on single-value or total-quota breach
- [x] Declare all abstract methods with `signal?: AbortSignal` on every async signature:
- `get`, `set`, `delete`, `exists`, `list`, `clear` (StorageLike conformance)
- `getWithMetadata`, `getMetadata`, `getSize`
- `getPartial`, `getItemSize`, `createReadStream`, `createWriteStream`
- [x] Provide default implementations for streaming methods that delegate to `get`/`set` (subclasses may override for efficiency):
- `getPartial`: calls `get`, slices the resulting `Uint8Array`
- `getItemSize`: calls `getMetadata`, returns `metadata.size`
- `createReadStream`: enqueues single chunk from `get()` then closes
- `createWriteStream`: accumulates all chunks, calls `set()` on close
- [x] Implement concrete helpers: `batch`, `getdel`, `mdelete`, `listWithPattern` — all accept optional `signal`, check `signal?.aborted` at start and between async steps; throw `StorageError(STORAGE.ABORTED)` when aborted
- [x] `batch()` rollback: on error or abort, reverse completed `set`/`delete` ops; use structured `console.warn` only if OTel is unavailable at that point, otherwise emit span event
- [x] Implement `setMaxValueSize`, `setMaxTotalSize`
- [x] Implement `getStats()`, `resetStats()`
- [x] Implement `on(event, handler)` and `off(event, handler)` delegating to the injected `EventBusLike`
- [x] Implement `addPreSetHook`, `addPostGetHook`, `removeHook` with priority sort
- [x] Implement `protected async applyPreSetHooks(key, value, options?)` and `protected async applyPostGetHooks(key, value)`
- [x] Implement `protected emitEvent(event, data)` — publishes to `EventBusLike`
- [x] Implement `namespace(prefix): NamespacedStorage`
### 3b. OTel instrumentation (`src/abstract/abstract.ts` or extracted `src/abstract/telemetry.ts`)
- [x] Import `metrics` from `@opentelemetry/api`
- [x] Create meter `@suaveplan/storage` once (module-level singleton, lazy init)
- [x] Counter `storage.ops` — attributes: `operation` (get/set/delete/exists/list/clear/batch/getdel/mdelete), `provider` (class name), `status` (success/error/aborted)
- [x] Histogram `storage.op.duration_ms` — same attributes
- [x] Histogram `storage.value.bytes` — attributes: `operation` (get/set), `provider`
- [x] Instrument all concrete helper methods and wrap the OTel recording around the abstract method calls within those helpers; individual subclasses do not need to add OTel (they inherit it)
### 3c. NamespacedStorage (`src/abstract/namespace.ts`)
- [x] `class NamespacedStorage extends AbstractStorage`
- [x] Constructor: `(storage: AbstractStorage, prefix: string)` — validate prefix is non-empty, no `:`
- [x] `prefixKey(key)` → `${prefix}:${key}`; `unprefixKey(key)` strips prefix
- [x] Implement all abstract methods by delegating to underlying `storage` with prefixed key
- [x] `list()` filters by `prefix:` prefix, strips prefix from returned keys, applies pagination correctly against filtered total
- [x] `clear()` lists all keys in this namespace and calls `mdelete` on prefixed keys — scoped only, does not touch other namespaces
- [x] `getSize()` iterates only namespace keys and sums `metadata.size` — fixes the main-branch approximate-total bug
- [x] `getPartial`, `getItemSize`, `createReadStream`, `createWriteStream` delegate via `prefixKey`
### 3d. Docs and tests
- [x] Create `src/abstract/abstract.md` (≥ 200 words, both basic and advanced examples)
- [x] Create `src/abstract/namespace.md` (≥ 200 words)
- [x] Create `src/abstract/index.ts` barrel
- [x] Create `src/abstract/abstract.test.ts` — test via a minimal `ConcreteStorage` implementation (not `MemoryStorage`; keeps unit tests isolated):
- `validateKey`: empty key, path-traversal variants, absolute paths, over-1024-chars, forbidden chars, valid key
- `validateValueSize`: single-value limit, total-quota limit
- `batch()`: happy path, rollback on mid-batch failure, abort signal mid-batch
- `getdel()`: value returned and deleted, null when absent
- `mdelete()`: returns count of actually-deleted keys
- `listWithPattern()`: glob `*`, `?`, no-pattern passthrough, pagination
- `on/off/emitEvent`: handler called on emit, removed after `off`
- `addPreSetHook`/`addPostGetHook`/`removeHook`: priority ordering, named removal
- `setMaxValueSize`/`setMaxTotalSize`: rejects invalid values
- `getStats()`/`resetStats()`: snapshot copy, reset to zeros
- `namespace()`: returns `NamespacedStorage` with correct prefix
- AbortSignal: each helper aborts before work starts
- `{ timeout: 5000 }` on sync tests; `{ timeout: 30000 }` on async tests
- [x] Create `src/abstract/namespace.test.ts`:
- Prefix isolation: keys in `ns('a')` never visible via `ns('b')`
- `clear()`: only removes namespace-scoped keys
- `getSize()`: returns scoped bytes only
- `list()` pagination against namespace-filtered total
- `{ timeout: 5000 }` / `{ timeout: 30000 }` as appropriate
---
## 4. MemoryStorage
### 4a. Implementation (`src/memory/memory.ts`)
- [x] `class MemoryStorage extends AbstractStorage`
- [x] Internal `Map<string, { value: Uint8Array; metadata: StorageMetadata }>` store
- [x] Constructor accepts `MemoryStorageConfig` (Zod-validated): `maxSize?`, `cleanupIntervalMs?` (default 60000; 0 disables timer)
- [x] Background TTL cleanup loop using `setInterval`; call `.unref()` in Node/Bun to avoid blocking exit
- [x] `get(key, signal?)`:
- Check `signal?.aborted`; throw `StorageError(STORAGE.ABORTED)`
- Return `undefined` (not `null`) for missing/expired keys — matches `StorageLike`
- Expire-on-read for TTL entries; update stats
- Apply `postGetHooks`; emit `get` event
- [x] `set(key, value, options?, signal?)`:
- Check signal; `validateKey`; `validateValueSize`; apply `preSetHooks`
- Write entry with metadata (`createdAt`, `modifiedAt`, `expiresAt`, `size`)
- On error: roll back stats increments
- [x] `delete(key, signal?)`: returns `boolean` (true if existed) — matches `StorageLike`; update stats; emit event
- [x] `exists(key, signal?)`: expire-on-read; return boolean
- [x] `list(options?, signal?)`: filter expired, filter by `prefix`, paginate, return `StorageListResult`
- [x] `getWithMetadata(key, signal?)`: returns `StorageResult<Uint8Array> | null`
- [x] `getMetadata(key, signal?)`: returns `StorageMetadata | null` without retrieving value bytes
- [x] `clear(signal?)`: clears map; resets `keyCount`/`totalSize` stats; emits `clear` event
- [x] `getSize(signal?)`: sums live (non-expired) entry byte lengths
- [x] `get size(): number` — count of live (non-expired) entries
- [x] `destroy()`: clears the cleanup interval; idempotent
- [x] Streaming overrides (zero-copy from internal `Uint8Array`):
- `getPartial(key, offset, length, signal?)`: `entry.value.subarray(offset, offset + length)` — handles out-of-range gracefully (clamp to actual size)
- `getItemSize(key, signal?)`: `entry.metadata.size` without extra allocation
- `createReadStream(key, signal?)`: `ReadableStream` that enqueues the stored `Uint8Array` (defensive copy so callers cannot mutate the store)
- `createWriteStream(key, signal?)`: `WritableStream` that accumulates typed-array chunks then calls `this.set()` on close; pipes abort signal through `WritableStreamDefaultController`
### 4b. Types (`src/memory/memory.types.ts`)
- [x] `MemoryEntry` — internal store entry shape
- [x] `MemoryStorageConfig` — exported, used by `MemoryStorageConfigSchema` in `src/types/schemas.ts`
### 4c. Docs and tests
- [x] Create `src/memory/memory.md` (≥ 200 words, includes TTL and streaming examples)
- [x] Create `src/memory/index.ts` barrel
- [x] Create `src/memory/memory.test.ts` (core operations):
- `get`/`set`/`delete`/`exists`/`list`/`clear`/`getSize` happy paths
- TTL: entry expires after `ttl` ms, returns `undefined` on late read
- `getWithMetadata` / `getMetadata` field correctness
- Stats accuracy across all operations including error paths
- Size limit (`maxSize`) enforced on `set`
- `destroy()` stops the cleanup timer
- `get size` property reflects live key count
- AbortSignal: each method aborts correctly
- `{ timeout: 5000 }` / `{ timeout: 30000 }` as appropriate
- [x] Create `src/memory/memory-streaming.test.ts`:
- `getPartial`: byte-range slices, out-of-range clamping, missing key returns null
- `getItemSize`: matches stored byte length
- `createReadStream`: full round-trip matches original bytes
- `createWriteStream`: multi-chunk write round-trips correctly via `get()`
- AbortSignal on streaming methods
- `{ timeout: 30000 }` on all
- [x] Create `src/memory/memory-ttl-cleanup.test.ts`:
- Expired entries removed by background cleanup (use `FakeClock` via injectable clock or mock `Date.now`)
- Stats (`keyCount`, `totalSize`) correctly decremented after cleanup
- `cleanupIntervalMs: 0` disables background timer
- `{ timeout: 30000 }` on all
- [x] Create `src/memory/memory-edge-cases.test.ts`:
- Overwrite: `modifiedAt` updates, `createdAt` unchanged
- Large value exactly at `maxValueSize` accepted; one byte over rejected
- Empty `Uint8Array` stored and retrieved correctly
- `clear()` then `getSize()` returns 0
- Concurrent async `set`s to the same key settle deterministically (last writer wins in single-threaded JS)
- `{ timeout: 30000 }` on all
- [x] Create `src/memory/memory-atomic.test.ts`:
- `getdel()` is logically atomic: value returned and key absent after call
- `mdelete()` returns correct count for mix of existing and missing keys
- `{ timeout: 5000 }` / `{ timeout: 30000 }` as appropriate
---
## 5. NamespacedStorage (integration tests)
- [x] Create `tests/namespace-integration.test.ts` (in `tests/` sibling, cross-cutting):
- Full round-trip using `MemoryStorage` as the backing store: `set`→`get`→`getWithMetadata`→`delete`→`exists`
- `list()` with prefix filter, pagination
- Verify no key leakage: `ns('a').set('k', v)` is not visible via `ns('b').get('k')`
- `getSize()` returns bytes for the namespace only
- Nested namespaces: `ns1.namespace('sub')` works correctly
- `{ timeout: 30000 }` on all
---
## 6. StorageFactory + Provider Registry
### 6a. Implementation (`src/factory/factory.ts`)
- [x] Module-level `providers = new Map<string, new (config?: unknown) => AbstractStorage>()`
- [x] Auto-register `"memory"` → `MemoryStorage` at module initialisation (this is the only side effect; it is isolated to the factory module and does not affect tree-shaking of other exports)
- [x] Implement all factory functions from the preserved surface (see proposal):
- `registerStorageProvider<T extends string>(type, implementation): void`
- `createStorage<T extends StorageProviderType>(options): StorageProviderImplementation<T>` — throws `StorageError(STORAGE.GENERIC)` if provider not found (not raw `Error`)
- `createStorageWithConfig<T extends StorageProviderType>(type, config?): StorageProviderImplementation<T>`
- `hasStorageProvider(type): boolean`
- `getStorageProviders(): string[]`
- `unregisterStorageProvider(type): void`
- `clearStorageProviders(): void` — resets to memory-only
- [x] `const StorageFactory = { register, create, createWithConfig, hasProvider, getProviders, unregister, clearProviders } as const`
### 6b. Factory types (`src/factory/factory.types.ts`)
- [x] `StorageProviderRegistry` interface with `memory: MemoryStorage`
- [x] `type InMemory = MemoryStorage` — alias export for backwards compat
- [x] `StorageProviderType`, `StorageProviderImplementation<T>`, `StorageProviderConfig`, `MemoryStorageConfig` (re-export from `memory.types.ts`), `StorageProviderConfigFor<T>`, `StorageFactoryConfig<T>`
### 6c. Docs and tests
- [x] Create `src/factory/factory.md` (≥ 200 words, module-augmentation registration pattern example)
- [x] Create `src/factory/index.ts` barrel
- [x] Create `src/factory/factory.test.ts`:
- `createStorage({ type: 'memory' })` returns a `MemoryStorage` instance
- `createStorage` with unregistered type throws `StorageError` with code `STORAGE.GENERIC`
- `registerStorageProvider` + `createStorage` with custom provider works
- `hasStorageProvider` / `getStorageProviders` / `unregisterStorageProvider` / `clearStorageProviders` correctness
- `StorageFactory` facade delegates to the same functions
- `InMemory` type alias is exported (type-level test using `satisfies`)
- `{ timeout: 5000 }` on all
---
## 7. OTel Instrumentation Verification
- [x] Create `tests/otel-instrumentation.test.ts`:
- Use a `TestSpanExporter` or in-memory OTel SDK setup from `@suaveplan/opentelemetry` (if available) or a minimal no-op; verify that span event / metric recording code paths are exercised without throwing
- Assert `storage.ops` counter is incremented by 1 for each of: `get`, `set`, `delete`, `exists`, `list`, `clear`
- Assert `storage.op.duration_ms` histogram is recorded for each operation
- Assert `storage.value.bytes` histogram is recorded for `get` and `set` with the correct byte count
- `{ timeout: 30000 }` on all
---
## 8. Property-Based Tests
- [x] Create `tests/property-based.test.ts`:
- **Key validation**: `fc.string()` — `validateKey` accepts all valid keys; rejects all strings containing `..`, `\`, `/`, `<>"|*?`, or longer than 1024 chars
- **Set/get roundtrip**: `fc.tuple(fc.string({ minLength: 1, maxLength: 100 }).filter(k => !k.includes('..')), fc.uint8Array())` — `set(k, v)` then `get(k)` returns equal `Uint8Array`
- **Namespace roundtrip**: `fc.tuple(fc.string(...), fc.string(...), fc.uint8Array())` — `namespace(p).set(k, v)` then `namespace(p).get(k)` equals `v`; `namespace(p2).get(k)` returns `undefined` for `p2 !== p`
- **listWithPattern consistency**: for any store state and glob pattern, `listWithPattern({ pattern })` result is a subset of `list()` result and matches the pattern on every key
- **Batch rollback**: inject a failure at a random operation index; assert all keys revert to pre-batch state
- **TTL expiry**: `fc.nat({ max: 100 })` for ttl; advance time past ttl; `get` must return `undefined`
- `{ timeout: 30000 }` on the `describe` block
---
## 9. Benchmarks
- [x] Create `src/__benchmarks__/memory-ops.bench.ts`:
- Benchmark `MemoryStorage` `get`, `set`, `delete` individually (1 000-key warm store)
- Benchmark `list()` on a 10 000-key store with and without prefix
- Use `mitata` `bench` and `group` API
- [x] Create `src/__benchmarks__/batch-ops.bench.ts`:
- Benchmark `batch()` of 100 mixed set/get/delete operations vs equivalent sequential calls
- Measure rollback overhead by injecting a failure at index 50
- [x] Create `src/__benchmarks__/namespace-ops.bench.ts`:
- Compare `MemoryStorage.get/set` direct vs through `NamespacedStorage` wrapper
- `list()` with namespace prefix vs without
- [x] Add `"test:bench": "vitest bench"` script to `package.json`
- [x] Confirm `bun run test:bench --filter=@suaveplan/storage` runs without error and prints numbers
---
## 10. Docs Coverage Pass
- [x] Ensure every `.ts` implementation file has a sibling `.md` (no co-located `.types.ts`, `.test.ts`, barrel `index.ts`, or `__benchmarks__` files require `.md`):
- `src/errors/errors.md` ✓ (Task 2a)
- `src/types/types.md` ✓ (Task 2b)
- `src/types/schemas.md` ✓ (Task 2b)
- `src/abstract/abstract.md` ✓ (Task 3d)
- `src/abstract/namespace.md` ✓ (Task 3d)
- `src/abstract/telemetry.md` (if telemetry extracted to its own file)
- `src/memory/memory.md` ✓ (Task 4c)
- `src/factory/factory.md` ✓ (Task 6c)
- [x] Write root `README.md` at `universal/storage/storage/README.md`:
- Package purpose (2+ sentences)
- Installation snippet
- Quick-start example (MemoryStorage + namespace + TTL)
- Streaming example (createReadStream / createWriteStream)
- Factory / custom provider registration example
- Error handling example (StorageNotFoundError, StorageQuotaError)
- Links to each co-located `.md` file
- Dependency table
- No category-level README inside `src/` subdirectories
- [x] Run `bun run docs:check --filter=@suaveplan/storage` and fix any missing doc symbols
---
## 11. Verify and Publish
- [x] Run the full pre-publish gate — all four commands must exit 0:
```bash
bunx turbo lint --filter=@suaveplan/storage
bunx turbo typecheck --filter=@suaveplan/storage
bunx turbo test --filter=@suaveplan/storage
bunx turbo test:coverage --filter=@suaveplan/storage
```
- [x] Confirm coverage thresholds 100/100/100/100 are met on all touched modules (istanbul reporter)
- [x] Confirm `bun run docs:check --filter=@suaveplan/storage` reports 100%
- [x] Confirm `bun run test:bench --filter=@suaveplan/storage` runs to completion and outputs benchmark numbers
- [x] Confirm `sideEffects: false` is set in `package.json` and tree-shake guard test passes
- [x] Review `src/` for any `TODO`, `FIXME`, `@deprecated`, `any`, `ts-ignore`, or `it.skip` / `describe.skip` — fix all before proceeding
- [x] Add `@suaveplan/storage` to `CLAUDE.md` package listing (under Universal packages) with its full description and dep list
- [x] Publish: `bun scripts/publish.ts --package @suaveplan/storage`
- [x] Archive this OpenSpec change: move `openspec/changes/enterprise-storage/` to `openspec/archive/2026-04-23-enterprise-storage/` and fold spec deltas into `openspec/specs/storage/spec.md`