# Capability Spec Delta: drive (drive) **Package**: `@suaveplan/drive` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. ## ADDED Requirements ### Requirement: Options-bundle parity across every public op `Drive.delete`, `Drive.stat`, `Drive.createReadStream`, and `Drive.createWriteStream` MUST accept an options bundle (`{ signal? }` plus, for streams, `{ offset?, length? }`). The legacy positional `signal?` form MUST remain accepted via overload for one minor cycle and MUST be observably equivalent. #### Scenario: delete accepts options bundle - **WHEN** `drive.delete(path, { signal })` is called with an already-aborted signal - **THEN** the call MUST reject with `DriveError(DRIVE.OPERATION_ABORTED)` and MUST NOT touch the underlying storage #### Scenario: createReadStream honours offset and length - **WHEN** `drive.createReadStream(path, { offset: 4, length: 8 })` is called on a 16-byte file - **THEN** the resulting stream MUST emit exactly bytes `[4..12)` of the source **Verified by:** `src/file-operations/delete.test.ts::"delete aborts on pre-aborted signal"` AND `src/file-operations/streams.test.ts::"createReadStream honours offset and length"` --- ### Requirement: `Drive.copy(src, dst, opts?)` is middleware-wrapped and atomic-on-error `Drive.copy` SHALL invoke the `copy` middleware chain in registration order, prefer `storage.copy?` when source and destination share a storage, otherwise fall back to streaming `src → dst`. Any underlying failure MUST surface as `DriveError(DRIVE.COPY_FAILED, { cause })` and MUST NOT leave a partial file at `dst` (the destination is deleted on write failure). #### Scenario: copy emits before/after middleware - **WHEN** a middleware with a `copy(ctx, next)` hook is registered and `drive.copy("a","b")` succeeds - **THEN** the hook MUST run exactly once outer-first on the request path and outer-last on the response path #### Scenario: copy cleans up on write failure - **WHEN** the destination storage's underlying write throws midway - **THEN** `drive.copy` MUST throw `DriveError(DRIVE.COPY_FAILED)` AND `await drive.stat("b") === null` **Verified by:** `src/file-operations/copy.test.ts::"copy invokes middleware outer-first/outer-last"` AND `src/file-operations/copy.test.ts::"copy rolls back partial destination on write failure"` --- ### Requirement: `Drive.move(src, dst, opts?)` is rename-or-copy-then-delete with rollback `Drive.move` SHALL prefer `storage.move?` when src/dst share a storage; otherwise it SHALL `copy(src,dst)` then `delete(src)`. A failure during the post-copy delete MUST attempt `delete(dst)` rollback and surface as `DriveError(DRIVE.MOVE_FAILED, { cause })`. #### Scenario: cross-storage move is copy+delete - **WHEN** `drive.move("a","b")` is called with src and dst on different storages - **THEN** after success `await drive.stat("a") === null` AND `await drive.read("b")` returns the original bytes #### Scenario: rollback on delete failure - **WHEN** the source delete throws after a successful copy - **THEN** `drive.move` MUST throw `DriveError(DRIVE.MOVE_FAILED)` AND `await drive.stat("b") === null` **Verified by:** `src/file-operations/move.test.ts::"cross-storage move copies then deletes"` AND `src/file-operations/move.test.ts::"move rolls back destination on delete failure"` --- ### Requirement: `Drive.list` returns a typed page and `Drive.scan` auto-paginates `Drive.list(prefix?, opts?)` MUST return `Promise` where `DriveListPage = { entries: DriveEntry[]; cursor?: string }`. `Drive.scan(prefix?, opts?)` MUST return an `AsyncIterable` that auto-pages through `list` until exhausted and MUST honour `signal` between pages. #### Scenario: scan yields every entry across pages - **WHEN** the underlying storage returns 250 entries across 3 pages - **THEN** `for await (const e of drive.scan())` yields exactly 250 entries in storage order **Verified by:** `src/file-operations/scan.test.ts::"scan yields every entry across pages"` AND `src/file-operations/list.test.ts::"list returns typed DriveListPage"` --- ### Requirement: `Drive.watch(path, opts?)` delivers change events via the injected EventBus `Drive.watch` SHALL return `{ events: AsyncIterable; unsubscribe(): void }`. Events MUST fire for `write`, `delete`, `copy`, and `move` whose path equals or is a descendant of the subscribed path. When no `EventBusLike` is injected, drive SHALL use a private in-process fan-out so the API remains usable in zero-config mode. Subscription errors MUST surface as `DriveError(DRIVE.WATCH_FAILED, { cause })`. #### Scenario: watch fires for descendant writes - **WHEN** `drive.watch("folder")` is active and `drive.write("folder/x", bytes)` resolves - **THEN** the next `DriveChangeEvent` yielded MUST have `{ kind: "write", path: "folder/x" }` #### Scenario: unsubscribe stops delivery - **WHEN** `unsubscribe()` is called - **THEN** subsequent writes MUST NOT yield further events on that iterator **Verified by:** `src/file-operations/watch.test.ts::"watch fires for descendant writes"` AND `src/file-operations/watch.test.ts::"unsubscribe stops delivery"` --- ### Requirement: Hydration progress is also published on the EventBus `Drive.hydrateFrom` SHALL publish `drive:hydrate:progress`, `drive:hydrate:complete`, and `drive:hydrate:aborted` on the injected `EventBusLike` (in addition to invoking `options.onProgress` when supplied). Topic payloads MUST match the existing `HydrateProgressEvent` shape. #### Scenario: progress reaches a bus subscriber - **WHEN** a subscriber attaches to `drive:hydrate:progress` before `drive.hydrateFrom(template)` is called - **THEN** the subscriber MUST receive at least one event with `stage: "indexing"` and one with `stage: "complete"` **Verified by:** `src/drive/hydrate-from.test.ts::"hydrate progress reaches EventBus subscriber"` --- ### Requirement: Middleware widening — copy/move/watch slots `DriveMiddleware` MUST accept optional `copy`, `move`, and `watch` hooks, each invoked through `PluginRegistry.runMiddleware` with the same outer-first/outer-last ordering as `read`/`write`/`delete`/`stat`/`list`. Plugins authored before this change (no new hooks) MUST continue to function unchanged. #### Scenario: legacy plugin keeps working - **WHEN** a `DriveMiddleware` implementing only `read` is registered - **THEN** `drive.copy("a","b")` MUST succeed without invoking the legacy plugin **Verified by:** `src/plugins/registry.spec-conformance.test.ts::"copy/move/watch middleware run outer-first/outer-last"` AND `src/plugins/registry.spec-conformance.test.ts::"legacy read-only plugin keeps working after middleware widening"` --- ### Requirement: Plugin lifecycle — unload, list, find `Drive.unloadPlugin(name)` MUST be idempotent: calling it twice MUST NOT throw, and a successful unload MUST invoke the plugin's `dispose?()`. `Drive.listPlugins()` MUST return plugins in registration order. `Drive.findPlugin(name)` MUST return `undefined` for unknown names. Unloading an unknown name MUST throw `DriveError(DRIVE.PLUGIN_FAILED, "unknown plugin: ")`. #### Scenario: unload disposes once - **WHEN** `drive.usePlugin(p)` then `drive.unloadPlugin(p.name)` is called twice - **THEN** `p.dispose()` MUST have been called exactly once **Verified by:** `src/plugins/registry.test.ts::"unloadPlugin disposes exactly once across repeated calls"` AND `src/plugins/registry.test.ts::"listPlugins returns registration order"` --- ### Requirement: `Drive.usage()` aggregates per-storage and is 5s-cached `Drive.usage()` MUST return `{ byStorage: Record; total: DriveUsageEntry }` where `DriveUsageEntry = { bytes: number; files: number }`. It MUST prefer `storage.usage?()` when supplied; otherwise it MUST sum `stat.size` over `scan()`. Successive calls within 5 seconds MUST be served from cache. The cache MUST be invalidated by any `write`/`delete`/`copy`/`move`. #### Scenario: cache is invalidated by writes - **WHEN** `drive.usage()` is called, then `drive.write("a", bytes)`, then `drive.usage()` - **THEN** the second result's `total.bytes` MUST be strictly greater than the first **Verified by:** `src/observability/usage.test.ts::"usage cache is invalidated by writes"` AND `src/observability/usage.test.ts::"usage prefers native storage.usage when available"` --- ### Requirement: `Drive.setQuota` enforces soft/hard limits `Drive.setQuota({ soft?, hard? })` MUST cause every subsequent `write`/`copy`/`move` to first re-check `usage()`. Crossing `soft` MUST emit `drive:quota:warning` exactly once per crossing. Exceeding `hard` MUST throw `DriveError(DRIVE.QUOTA_EXCEEDED)` BEFORE the underlying storage write. #### Scenario: hard cap blocks the write - **WHEN** quota `{ hard: 100 }` is set and a 200-byte write is issued against an empty drive - **THEN** the write MUST throw `DriveError(DRIVE.QUOTA_EXCEEDED)` AND `await drive.stat(path) === null` **Verified by:** `src/observability/quota.test.ts::"hard cap blocks write before storage call"` AND `src/observability/quota.test.ts::"soft cap emits drive:quota:warning once per crossing"` --- ### Requirement: Sharing-integration hook — `DriveShareLike` seam `DriveOptions.shareHook?: DriveShareLike` MUST be accepted. `Drive.share`/`Drive.revoke`/`Drive.listShares` MUST delegate to the hook. When no hook is registered, each of these MUST throw `DriveError(DRIVE.PLUGIN_FAILED, "no share hook")`. Drive MUST NOT import `@suaveplan/drive-sharing` at runtime. #### Scenario: missing hook throws PLUGIN_FAILED - **WHEN** `drive.share("a", audience)` is called without a registered hook - **THEN** the call MUST throw `DriveError(DRIVE.PLUGIN_FAILED)` with message containing `"no share hook"` #### Scenario: hook receives the call - **WHEN** a hook is registered and `drive.share("a", audience)` is called - **THEN** the hook's `share` MUST receive arguments `("a", audience)` exactly **Verified by:** `src/drive/drive.test.ts::"share throws PLUGIN_FAILED without hook"` AND `src/drive/drive.test.ts::"share delegates to registered DriveShareLike hook"` --- ### Requirement: Error-code registry widening `DRIVE_CODES` MUST be extended with `COPY_FAILED`, `MOVE_FAILED`, `WATCH_FAILED`, `QUOTA_EXCEEDED`, `OPERATION_ABORTED`, `STORAGE_READONLY`. The registry MUST remain frozen and every new value MUST start with `DRIVE.`. Each new code MUST be raised by at least one call site in `src/`. #### Scenario: every new code is reachable - **WHEN** the conformance test iterates `DRIVE_CODES` filtered to the six new entries - **THEN** each MUST appear in a `grep -r "DRIVE_CODES\." src/` result with at least one `throw new DriveError` call site **Verified by:** `src/error-codes.spec-conformance.test.ts::"every new DRIVE_* code is thrown from at least one call site"` AND `src/error-codes.spec-conformance.test.ts::"DRIVE_CODES remains frozen with DRIVE. prefix after widening"` --- ### Requirement: OpenTelemetry spans on every public op Every public operation (`read`, `write`, `delete`, `stat`, `list`, `scan`, `copy`, `move`, `watch`, `hydrateFrom`, `usage`) MUST be wrapped in a span named `drive.` with attributes `drive.path` (where applicable), `drive.storage.id`, and `drive.bytes` (where applicable). The span MUST end with `SpanStatusCode.ERROR` and record the exception when the op throws. With no exporter registered, span overhead MUST be no-op. #### Scenario: read span carries storage id - **WHEN** an in-memory exporter is registered and `drive.read("a")` succeeds - **THEN** exactly one span named `drive.read` MUST be recorded with attribute `drive.storage.id === "default"` **Verified by:** `src/observability/spans.test.ts::"drive.read span carries drive.storage.id attribute"` AND `src/observability/spans.test.ts::"thrown ops mark span ERROR and record exception"` --- ### Requirement: 100% coverage gate `bunx turbo test:coverage --filter=@suaveplan/drive` MUST report 100% statements, 100% branches, 100% functions, 100% lines (istanbul). No `c8 ignore`, no `it.skip`, no tautological assertion (CLAUDE.md §2b). Coverage MUST be enforced by `vitest.config.ts` `thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 }`. #### Scenario: coverage gate fails closed - **WHEN** a deliberate uncovered branch is introduced - **THEN** `bunx turbo test:coverage --filter=@suaveplan/drive` MUST exit non-zero **Verified by:** `vitest.config.ts::thresholds-100` AND `src/coverage-gate.test.ts::"vitest threshold is 100/100/100/100"`