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,261 @@
# Design — @suaveplan/drive-uri
## Tier and dependency posture
Universal tier. Zero `node:` / `bun:` imports in `src/`. Runtime deps:
- `@suaveplan/error` — `defineCodes`, `SuaveplanError`
- `@suaveplan/storage-uri` — `UriPlugin`, `ParsedUri`, `parseUri`, `runUriPluginConformance`, `StorageUriError` (for `cause` chain typing)
- `@suaveplan/drive` — `Drive` (consumed type), `ReadOptions`, `WriteOptions`
- `@suaveplan/drive-template` — `DriveTemplate` (type only — schema is never decoded inside `drive-uri`)
- `@suaveplan/types` — `StorageLike` (the contract the adapter shim satisfies)
- `@opentelemetry/api` — meter + tracer
- `tslib`
`@suaveplan/drive-mesh`, `drive-plugin-chunks`, `drive-transport-https`, `drive-webrtc` are **NOT** runtime deps. The consumer wires those into the `Drive` instance they pass in; `drive-uri` only sees `Drive`. Same posture as `storage-uri` and the storage providers.
DevDeps: `@suaveplan/testing`, `@suaveplan/storage`, `@suaveplan/storage-file`, `@suaveplan/drive-plugin-chunks`, `@suaveplan/drive-mesh`, `@vitest/coverage-istanbul`, `fast-check`, `typescript`, `vite`, `vite-plugin-dts`, `vitest`. The integration tests require real `Drive` + `chunksPlugin` + storage-file pairs (no mocks per CLAUDE.md rule 2).
## File layout
```text
packages/drive/drive-uri/
package.json
README.md
src/
index.ts # explicit named re-exports (rule 10)
error-codes/
error-codes.ts # defineCodes("DRIVE_URI", [...]), DriveUriError
error-codes.test.ts
error-codes.md
index.ts
adapter/
drive-storage-like.ts # Drive → StorageLike shim
drive-storage-like.test.ts
drive-storage-like.md
index.ts
plugins/
drive/
drive.ts # driveUriPlugin(options): UriPlugin
drive.streams.ts # createReadStream/createWriteStream variants
drive.test.ts
drive.streams.test.ts
drive.md
drive.streams.md
index.ts
drive-mesh/
drive-mesh.ts # driveMeshUriPlugin(options): UriPlugin
drive-mesh.test.ts
drive-mesh.md
index.ts
drive-template/
drive-template.ts # driveTemplateUriPlugin(options): UriPlugin
drive-template.test.ts
drive-template.md
index.ts
types/
plugin-options.types.ts # DriveUriPluginOptions, DriveMeshPluginOptions, DriveTemplatePluginOptions, TemplateProvider
plugin-options.types.md
index.ts
otel/
otel.ts # meter + tracer wiring, recordOp, recordTemplateHydration
otel.test.ts
otel.md
index.ts
conformance/
conformance.ts # re-export of runUriPluginConformance from storage-uri
conformance.md # how to author the fixture set for a drive-uri plugin
index.ts
tests/
setup/polyfills.ts
integration/
drive-roundtrip.test.ts # real Drive + storage-file
template-roundtrip.test.ts # real Drive + chunksPlugin + storage-file + in-process templateProvider
compose-with-storage-uri.test.ts # one resolver, file:// + drive:// in the same dispatch
tsconfig.json
tsconfig.lib.json
vite.config.ts
vitest.config.ts
```
Every implementation `.ts` outside the excluded set (`*.test.ts`, `*.types.ts` inside a feature dir, `index.ts`) has a sibling `.md` of ≥200 words with the 6 required sections.
Per AGENTS §10 (no redundant naming): the `plugins/` parent does NOT cause its children to be named `*-plugin.ts` — children are `drive/drive.ts`, `drive-mesh/drive-mesh.ts`, `drive-template/drive-template.ts`. Each exports its `*UriPlugin` factory and the plugin-specific types.
## Adapter shim — `Drive → StorageLike`
```ts
export class DriveStorageLikeAdapter implements StorageLike {
constructor(private readonly drive: Drive) {}
async get(key: string): Promise<Uint8Array | undefined> {
try {
return await this.drive.read(key);
} catch (err) {
if (isDriveNotFound(err)) return undefined;
throw err;
}
}
async set(key: string, value: Uint8Array): Promise<void> {
await this.drive.write(key, value);
}
async delete(key: string): Promise<boolean> {
return this.drive.delete(key);
}
async exists(key: string): Promise<boolean> {
return (await this.drive.stat(key)) !== null;
}
async list(options?: { prefix?: string; limit?: number }): Promise<ListResult> {
return this.drive.list(options ?? {});
}
async clear(): Promise<void> {
throw new Error("clear() is not supported on Drive — use targeted delete instead");
}
// Optional: bridge createReadStream onto the resolver's open() fast path.
createReadStream(key: string, signal?: AbortSignal): ReadableStream<Uint8Array> {
return this.drive.createReadStream(key, signal);
}
}
```
`isDriveNotFound(err)` is the only place that has to know about `DRIVE.IO_FAILED`'s NOT_FOUND variant. The shim does not own any chunk codec, manifest fetching, peer discovery, or template validation — that all happens inside `Drive` via its installed middleware.
## Three plugins — common shape
```ts
export function driveUriPlugin(options: DriveUriPluginOptions): UriPlugin {
validateOptions(options); // throws DRIVE_URI.INVALID_URI / DRIVE_ID_MISMATCH eagerly
const adapter = new DriveStorageLikeAdapter(options.drive);
return {
scheme: "drive",
adapter,
parse(uri) {
const parsed = parseUri(uri);
if (parsed.scheme !== "drive") throw new DriveUriError("INVALID_URI", { uri });
const driveId = parsed.authority ?? "";
if (driveId !== options.driveId) {
throw new DriveUriError("DRIVE_ID_MISMATCH", { uri, expected: options.driveId, actual: driveId });
}
return { key: parsed.path.replace(/^\//, ""), meta: { driveId } };
},
build(key, meta) {
const id = meta?.driveId ?? options.driveId;
return `drive://${id}/${key.replace(/^\//, "")}`;
},
};
}
```
`driveMeshUriPlugin` is identical in shape, swapping `scheme: "drive+mesh"` and capturing `swarmId` instead of validating `driveId`. The mesh plugin does **not** enforce a swarmId match — it captures the swarmId for instrumentation only because the `Drive` instance is the consumer's choice and they may use the same `Drive` to read from multiple swarms (the mesh transport handles peer routing).
`driveTemplateUriPlugin` adds the hydration step in `parse` (or, more accurately, in `get` — the parse happens synchronously, the hydration is async):
```ts
export function driveTemplateUriPlugin(options: DriveTemplatePluginOptions): UriPlugin {
if (!options.templateProvider) {
throw new DriveUriError("CAPABILITY_MISSING", { reason: "templateProvider is required" });
}
const hydrated = new Map<string, Promise<void>>(); // templateId → in-flight hydration
const adapter = wrapDriveWithHydration(options.drive, options.templateProvider, hydrated);
return {
scheme: "drive+template",
adapter,
parse(uri) { /* extract templateId + path */ },
build(key, meta) { /* re-serialise */ },
};
}
```
The hydration cache is a per-plugin `Map<string, Promise<void>>` keyed by `templateId`. First `get(key)` for a given template-id awaits `templateProvider.fetchTemplate(templateId) → drive.hydrateFrom(template)`; concurrent reads await the same in-flight promise; subsequent reads see a resolved promise and short-circuit. The cache is intentionally **not** LRU — the only state it holds is "we've already hydrated this template into the Drive", and re-hydrating is idempotent at the `Drive` layer via `TemplateNonceLru`.
## Error mapping
| Origin (in `cause`) | Re-wrap | Why |
|---|---|---|
| `DRIVE.IO_FAILED` with NOT_FOUND | `undefined` from `adapter.get`, then `STORAGE_URI.NOT_FOUND` from resolver | Matches `StorageLike` contract |
| `DRIVE.IO_FAILED` other | `STORAGE_URI.ADAPTER_ERROR` (via resolver) | Generic adapter failure |
| `DRIVE_MESH.ALL_PEERS_FAILED` / `CHUNK_UNAVAILABLE` / `SIG_VERIFY_FAILED` | `STORAGE_URI.ADAPTER_ERROR` with original code on `cause` | Mesh-specific failures preserved for diagnosis |
| `CHUNKS.AEAD_TAG_INVALID` / `MANIFEST_NOT_FOUND` | `STORAGE_URI.ADAPTER_ERROR` with original on `cause` | Chunks plugin failures |
| `DTP_*` from `hydrateFrom` | `DRIVE_URI.TEMPLATE_INVALID` with original on `cause` | Template validation is a drive-uri-tier semantic concern |
| `templateProvider.fetchTemplate` rejects | `DRIVE_URI.TEMPLATE_NOT_AVAILABLE` with original on `cause` | Distinct from validation failure |
| AbortSignal aborted before adapter call | `DRIVE_URI.ABORTED` | Pre-flight short-circuit |
| URI's driveId ≠ plugin's configured driveId | `DRIVE_URI.DRIVE_ID_MISMATCH` | Detected in `parse`, before any `Drive` call |
| Malformed URI | `DRIVE_URI.INVALID_URI` (or `STORAGE_URI.INVALID_URI` from `parseUri`) | Either is acceptable — the resolver surfaces whichever is thrown |
## OTel surface
Tracer + meter: `@suaveplan/drive-uri`.
Histograms:
- `drive_uri.op_duration_ms{op, scheme, outcome}` — `outcome ∈ success | not_found | error | aborted`
Counters:
- `drive_uri.reads_total{scheme, outcome}`
- `drive_uri.writes_total{scheme, outcome}` — increments even though writes throw (so operator can see misuse rate)
- `drive_uri.deletes_total{scheme, outcome}`
- `drive_uri.errors_total{scheme, code}` — `code` is the `DRIVE_URI.*` enum or `ADAPTER_ERROR` for downstream
- `drive_uri.template_hydrations_total{outcome}` — increments once per first-use template; cache-hit reads do not increment
Spans: a single span per public op (`drive.uri.read`, `drive.uri.write`, `drive.uri.stat`, …). Attributes:
- `drive.uri` — full URI minus query string
- `drive.uri.scheme`
- `drive.uri.drive_id?` / `drive.uri.swarm_id?` / `drive.uri.template_id?` — depending on scheme
- `drive.uri.path` — post-parse path
Query strings are dropped from `drive.uri` regardless of scheme. This is a contract, not an opt-in.
## Conformance harness
`drive-uri` does not author its own conformance harness. It re-exports `runUriPluginConformance` from `@suaveplan/storage-uri` (the canonical home) and adds three planned fixture sets (one per scheme) to its own test suite. Each fixture set covers:
1. **Valid URIs** — three representative shapes per scheme (root path, nested path, path with allowed special chars).
2. **Invalid URIs** — wrong scheme prefix, missing authority, embedded null, oversize (> 4096 chars).
3. **Round-trip** — `build(parse(uri).key, parse(uri).meta) === uri` for each valid fixture.
This keeps the conformance contract in one place (`storage-uri`) and avoids drift between the two URI-plugin packages.
## Composition recipe (in README)
```ts
import { createStorageUriResolver } from "@suaveplan/storage-uri";
import { fileUriPlugin } from "@suaveplan/storage-file"; // future
import { httpsUriPlugin } from "@suaveplan/storage-http"; // future
import {
driveUriPlugin,
driveMeshUriPlugin,
driveTemplateUriPlugin,
} from "@suaveplan/drive-uri";
const localDrive = new Drive({ /* local-only config */ });
const meshDrive = new Drive({ /* mesh + chunks + transports */ });
const resolver = createStorageUriResolver([
fileUriPlugin({ rootDir: "/var/media" }),
httpsUriPlugin(httpStorage),
driveUriPlugin({ drive: localDrive, driveId: "local-cache-01" }),
driveMeshUriPlugin({ drive: meshDrive, swarmId: "*", instrumentation: { includeSwarmIdInSpans: true } }),
driveTemplateUriPlugin({ drive: meshDrive, templateProvider }),
]);
const stream = await resolver.open("drive+mesh://swarmA/raw/clip.mov");
const buf = await resolver.read("drive+template://templA/manifest/clip.jpg");
```
One resolver, mixed schemes, same dispatch path. No second resolver class, no fall-through helper, no umbrella registry.
## Trade-offs accepted
- **No DriveUriResolver class.** All routing through `StorageUriResolver`. Loses one layer of indirection; gains: no duplicated dispatch code, no conflict-detection seam between two resolvers, simpler composition.
- **Drive instance is the consumer's responsibility.** `drive-uri` does NOT new up a Drive. Cost: the consumer wires `chunksPlugin` + transports + templates themselves. Benefit: drive-uri stays decoupled and testable; the same Drive can serve multiple URI schemes (e.g. one mesh-configured Drive backs both `drive+mesh://` and `drive+template://`).
- **Template hydration cache is per-plugin-instance, not global.** Two different `driveTemplateUriPlugin` instances do not share the cache. Cost: marginal — consumers rarely instantiate two; if they do, `Drive.hydrateFrom`'s nonce LRU still makes re-hydration idempotent at the drive tier.
- **`clear()` throws.** Wiping a Drive via the URI surface is a footgun; the explicit error code points the consumer at `Drive.delete(path)` instead.
- **Writes are deferred.** See proposal §"Out of scope". Reading is the dominant URI use case (media pipelines, hydration sources, sharing links); writes are a separate design problem with non-trivial fan-out semantics.

View File

@@ -0,0 +1,99 @@
# Add @suaveplan/drive-uri — drive, drive+mesh, drive+template URI plugins for StorageUriResolver
**Package**: `@suaveplan/drive-uri`
**Status**: PROPOSED
**Type**: new-package (genesis-original)
**Depends on**: `add-storage-uri` (archived) — provides `UriPlugin`, `StorageUriResolver`, `runUriPluginConformance`.
- **Design**: ./design.md
- **Spec delta**: ./specs/drive-uri/spec.md
## Why
`@suaveplan/storage-uri` collapses seven storage adapters into a single `read("scheme://…")` surface, but it dispatches at the storage tier only. The drive ecosystem — `drive`, `drive-template`, `drive-mesh`, `drive-plugin-chunks`, `drive-transport-https`, `drive-webrtc` — sits **above** storage and addresses files by drive identity, swarm partition, or dehydrated template descriptor, not by a flat storage key. A consumer holding a `drive+mesh://swarm-abc/raw/clip.mov` URI today has no way to reach the bytes without instantiating a `Drive`, wiring `chunksPlugin`, a mesh transport, a peer registry, and a trust map at every call site.
`drive-uri` is the bridge. It exports three `UriPlugin` factories — `driveUriPlugin`, `driveMeshUriPlugin`, `driveTemplateUriPlugin` — that each wrap a caller-provided `Drive` instance in a `StorageLike`-conformant adapter and plug into the existing `StorageUriResolver`. The resolver itself is unchanged; the consumer simply registers drive plugins alongside their `fileUriPlugin` / `httpsUriPlugin` / `s3UriPlugin` set and the same `resolver.read("drive+mesh://…")` call returns plaintext bytes via the drive's existing middleware (`chunksPlugin` reassembles chunks from mesh peers; `Drive.hydrateFrom` validates the template) — `drive-uri` does **not** re-implement any chunk codec, peer discovery, or signature verification logic.
This composition is the central design choice: `storage-uri` stays drive-agnostic and `drive-uri` stays storage-agnostic; they meet in the consumer's plugin list. No new resolver class, no umbrella composition helper, no API duplication.
## What changes
- ADDED: `driveUriPlugin(options): UriPlugin` — scheme `drive`. Parses `drive://<driveId>/<path>` and forwards `path` to the caller-supplied `Drive` (which the consumer wires with `chunksPlugin` + a local-only configuration). The `driveId` is captured in `meta.driveId` and surfaced on spans, errors, and the rebuilt URI; the plugin enforces that the supplied `Drive` matches the expected `driveId` via the options' `driveId` field.
- ADDED: `driveMeshUriPlugin(options): UriPlugin` — scheme `drive+mesh`. Parses `drive+mesh://<swarmId>/<path>` and forwards `path` to a caller-supplied `Drive` that has been configured with `chunksPlugin` + a mesh transport (`drive-transport-https` and/or `drive-webrtc`) and a populated `PeerRegistry`. The `swarmId` is captured in `meta.swarmId` and used only for instrumentation; the plugin does **not** dispatch on swarmId — that responsibility stays with the `Drive` and its transports.
- ADDED: `driveTemplateUriPlugin(options): UriPlugin` — scheme `drive+template`. Parses `drive+template://<templateId>/<path>` and, before delegating to `drive.read(path)`, fetches the `DriveTemplate` via an injected `templateProvider.fetchTemplate(templateId, signal)`, hydrates the `Drive` via `drive.hydrateFrom(template, { signal })`, and caches the `(templateId → hydrated)` pair so subsequent reads against the same template skip the round-trip. Hydration errors from `Drive.hydrateFrom` (any `DTP_*` code) are re-wrapped as `DRIVE_URI.TEMPLATE_INVALID` with `cause` preserved.
- ADDED: `DriveStorageLikeAdapter` (internal, exported for testability) — translates `Drive.read` / `Drive.write` / `Drive.delete` / `Drive.stat` / `Drive.createReadStream` into the `StorageLike` shape consumed by `StorageUriResolver`. The shim maps `Drive`'s "throw on missing" semantics into `StorageLike`'s "`get` returns `undefined`" semantics (DRIVE.IO_FAILED with a `NOT_FOUND`-flavoured cause → `undefined`; every other failure re-throws so the resolver can wrap it as `STORAGE_URI.ADAPTER_ERROR`).
- ADDED: `DRIVE_URI_CODES` registry via `defineCodes("DRIVE_URI", [...] as const)` — 7 codes: `INVALID_URI`, `DRIVE_ID_MISMATCH`, `TEMPLATE_NOT_AVAILABLE`, `TEMPLATE_INVALID`, `WRITE_UNSUPPORTED`, `CAPABILITY_MISSING`, `ABORTED`. The resolver itself emits `STORAGE_URI.*` codes for routing concerns (`UNKNOWN_SCHEME`, `NOT_FOUND`, etc.); `DRIVE_URI.*` is reserved for drive-specific semantic failures the resolver cannot diagnose.
- ADDED: `DriveUriError extends SuaveplanError<DriveUriErrorCode>` with `uri`, `scheme`, `driveId?`, `swarmId?`, `templateId?`, `path?` discriminants. Adapter errors from `Drive` retain their original `DRIVE_*` / `CHUNKS_*` / `DRIVE_MESH_*` / `DTP_*` codes via `cause`.
- ADDED: OTel surface — `drive_uri.op_duration_ms{op, scheme, outcome}` histogram; `drive_uri.{reads, writes, deletes, errors, template_hydrations}_total{scheme, outcome}` counters; spans on `@suaveplan/drive-uri` tracer with `drive.uri`, `drive.uri.scheme`, `drive.uri.drive_id?`, `drive.uri.swarm_id?`, `drive.uri.template_id?` attributes (URI query strings stripped to avoid peer-key leakage). The plugins set those attributes via the parent `StorageUriResolver` span context — the resolver still emits its own `storage_uri.*` instruments.
- ADDED: Conformance — each plugin's parse/build round-trip is verified via `runUriPluginConformance` (re-exported from `@suaveplan/storage-uri`); each is also covered by an integration test that wires a real `Drive` (no mocks per CLAUDE.md rule 2).
## Out of scope
- **Writes via `drive*://` URIs.** 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.
- **A new `DriveUriResolver` class.** The three plugins satisfy the existing `UriPlugin` contract from `@suaveplan/storage-uri`; consumers register them into a `StorageUriResolver` next to their storage plugins. Adding a separate resolver would duplicate dispatch logic without changing observable behaviour.
- **A `composeUriResolvers` helper.** Equivalent to "register more plugins into one resolver." Out of scope unless a future use case requires two resolvers with conflicting state.
- **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.
- **Auto-discovery of peers across swarms.** Each `drive+mesh://` URI carries its `swarmId` in the authority; cross-swarm peer routing is the application's job, not the resolver's.
- **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.
- **Reimplementing chunk codec / template validation / peer discovery.** All three live in their owning packages and are exercised through the `Drive` reference the consumer provides. `drive-uri` is a routing layer, not a drive substitute.
## Source mapping
This is a **genesis-original** package; there is no upstream in `../suaveplan/`. All requirements derive from:
- The `UriPlugin` / `StorageUriResolver` / `runUriPluginConformance` surface in `@suaveplan/storage-uri` (`openspec/specs/storage-uri/spec.md`, folded post-archival).
- The `Drive` class in `@suaveplan/drive` — exact signatures used: `Drive.read(path: string, options?: ReadOptions): Promise<Uint8Array>`, `Drive.createReadStream(path: string, signal?: AbortSignal): ReadableStream<Uint8Array>`, `Drive.write(path, data, options?)`, `Drive.delete(path, signal?)`, `Drive.stat(path, signal?)`, `Drive.hydrateFrom(template, options?)`.
- The `DriveTemplate` schema from `@suaveplan/drive-template` — fetched via the consumer-supplied `templateProvider`; never decoded or validated inside `drive-uri` (validation happens inside `Drive.hydrateFrom` via the existing 8-step pipeline).
### Requirements with no source line
| Requirement | Justification |
|---|---|
| Three distinct schemes rather than one `drive://` with a discriminator segment | Each scheme has different authority semantics (driveId / swarmId / templateId), different consumer-side wiring (local-only Drive vs. mesh-configured Drive vs. mesh-configured Drive + templateProvider), and different failure modes. Collapsing them would force every consumer to wire every dependency. |
| `templateId → hydrated` cache lives in `drive-uri`, not `Drive` | The cache is a URI-resolver concern: it prevents repeated `templateProvider.fetchTemplate` calls when the same template is referenced by many URIs. `Drive.hydrateFrom` is naturally idempotent on `(driveId, nonce)` via `TemplateNonceLru`, but it does not memoise the template object itself — `drive-uri` does, scoped to its lifetime. |
| `driveId` mismatch is `DRIVE_URI.DRIVE_ID_MISMATCH`, not a `Drive`-tier error | The check happens in the plugin before any `Drive` call, so the failure has no `Drive` origin to wrap. Surfacing it as a drive-uri code keeps the error code namespace honest. |
| Query strings dropped from span attributes | A future iteration may carry peer hints or seeds in the query string; redacting now establishes the contract before any sensitive value is leaked. |
| Adapter shim translates `Drive`'s throw-on-missing into `StorageLike`'s undefined-on-missing | `StorageUriResolver.read` already maps `undefined` → `STORAGE_URI.NOT_FOUND`; pushing the translation up into `Drive` would change `Drive`'s contract for every consumer. Keeping it in the shim is one-line and local. |
| No `composeUriResolvers` helper despite the earlier proposal mentioning one | A single `StorageUriResolver` with mixed storage + drive plugins gives the same composition without introducing a second resolver class or a fall-through dispatch. Simpler is better. |
| `DRIVE_URI.CAPABILITY_MISSING` is the lazy-check code | A consumer who never resolves `drive+template://` should not have to wire a `templateProvider`. The plugin only requires the dependency on first use; absence at first use raises this code (not at construction). |
## Verification approach
Every requirement in the spec delta carries a `**Verified by:**` line referencing concrete `it(...)` titles in these planned test files:
- `src/plugins/drive/drive.test.ts` — parse `drive://<driveId>/<path>`, dispatch to `Drive.read(path)`, `DRIVE_ID_MISMATCH` when the URI's driveId differs from the bound `Drive`'s configured id, `INVALID_URI` for malformed inputs, AbortSignal propagation.
- `src/plugins/drive/drive.streams.test.ts` — `createReadStream` forwarding, mid-stream abort cancels the underlying `Drive.createReadStream`.
- `src/plugins/drive-mesh/drive-mesh.test.ts` — parse `drive+mesh://<swarmId>/<path>`, dispatch to a mesh-configured `Drive`, swarmId captured in `meta.swarmId`, span attribute populated, error wrapping for `DRIVE_MESH.*` codes.
- `src/plugins/drive-template/drive-template.test.ts` — parse `drive+template://<templateId>/<path>`, `templateProvider.fetchTemplate` invoked exactly once per templateId (memoisation), `drive.hydrateFrom` called before first read, `TEMPLATE_NOT_AVAILABLE` when provider rejects, `TEMPLATE_INVALID` when `hydrateFrom` throws any `DTP_*` code, `CAPABILITY_MISSING` when no provider is wired.
- `src/adapter/drive-storage-like.test.ts` — `get` returns `undefined` on `DRIVE.IO_FAILED`-with-NOT_FOUND cause, re-throws on every other failure, `exists` is `HEAD`-flavoured (does not buffer file bytes), `stat` populates `size`/`lastModified` from `Drive.stat`.
- `src/error-codes/error-codes.test.ts` — exhaustive `DRIVE_URI_CODES` registry, `DriveUriError` discriminant fields, `cause` preserved across re-wraps.
- `src/otel/otel.test.ts` — `template_hydrations_total` increments per first-use template, query string stripped from `drive.uri` attribute, per-scheme error counters populated.
- `src/conformance/conformance.test.ts` — `runUriPluginConformance` (re-exported) passes for all three plugins with the planned URI fixture sets.
- `tests/integration/drive-roundtrip.test.ts` — writes a file via `Drive.write(path, bytes)` on a real local-storage Drive, reads it via `resolver.read("drive://.../path")` through `StorageUriResolver`, asserts equality.
- `tests/integration/template-roundtrip.test.ts` — issues a `DriveTemplate`, dehydrates, exposes the template via an in-process `templateProvider`, resolves `drive+template://.../path` through a fully wired Drive + chunks plugin pair, asserts plaintext matches source.
- `tests/integration/compose-with-storage-uri.test.ts` — single `StorageUriResolver` registered with `fileUriPlugin` + `driveUriPlugin`; resolves `file:///tmp/foo` and `drive://.../foo` through the same dispatcher without code-path divergence.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/drive-uri` to enumerate. The package is new — no dependents currently. After adoption, the obvious upstream beneficiaries are `enterprise-media`, audio/video pipelines that consume URI-addressed sources, and `drive-sharing` flows that surface shareable URIs.
- No dependents (verified by `bunx turbo ls --filter=...@suaveplan/drive-uri` returning the package itself).
### Affected upstreams (none modified)
| Package | Action | Breaking? |
|---|---|---|
| `@suaveplan/storage-uri` | None — consumed via `UriPlugin` + `runUriPluginConformance` export; new package registers plugins into the existing resolver | no |
| `@suaveplan/drive` | None — `Drive.read` / `createReadStream` / `write` / `delete` / `stat` / `hydrateFrom` consumed as-is | no |
| `@suaveplan/drive-template` | None — only the `DriveTemplate` type is consumed (passed through to `Drive.hydrateFrom`) | no |
| `@suaveplan/drive-mesh` | None — only via the consumer-supplied `Drive` reference | no |
| `@suaveplan/drive-plugin-chunks` | None — only via the consumer-supplied `Drive` reference | no |
| `@suaveplan/drive-transport-https` / `drive-webrtc` | None — wired into `Drive` by the consumer | no |
`drive-uri` MUST NOT silently grow any of these packages' contracts. If a needed capability turns out to be missing during implementation, capture it as a separate proposal against the relevant upstream package — never bend the contract here.
### Ordering
This change is unblocked: `add-storage-uri` was archived in commit `f41fe490` (2026-05-16). All other dependencies (`drive`, `drive-mesh`, `drive-template`, `drive-plugin-chunks`) ship today with the consumed surface intact.

View File

@@ -0,0 +1,255 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: drive-uri (add-drive-uri)
**Package**: `@suaveplan/drive-uri`
**Status**: PROPOSED
> Every requirement carries a `**Verified by:**` line at authoring time.
> **Note — buffered-read signal threading is revisited in `add-signal-to-storage-like`.** `StorageUriResolver.read()` does not currently thread `AbortSignal` through `adapter.get()`, only through `adapter.createReadStream()` via `open()`. Requirement R7 below therefore verifies signal propagation through the streaming path. A future cross-cutting change against `@suaveplan/types`' `StorageLike` interface will extend `get/set/exists/...` to accept an optional `AbortSignal`; once it lands, R7 can be extended to cover the buffered path as well.
## ADDED Requirements
### Requirement: `drive://` plugin parses authority as driveId and delegates to `Drive.read`
The `driveUriPlugin(options)` factory SHALL return a `UriPlugin` with `scheme === "drive"`. Its `parse` SHALL extract the URI authority as `driveId` and the remainder as a slash-prefix-trimmed `key`. The returned plugin SHALL forward `key` to the consumer-supplied `Drive` via `Drive.read(key, { signal })` (or `Drive.createReadStream` on `open()`). The plugin SHALL throw `DRIVE_URI.DRIVE_ID_MISMATCH` when the URI's driveId does not equal the `driveId` declared in `options`.
#### Scenario: URI dispatches to Drive.read
- **WHEN** `resolver.read("drive://primary/folder/file.bin")` is called and the resolver holds `driveUriPlugin({ drive, driveId: "primary" })`
- **THEN** `drive.read("folder/file.bin", { signal })` is invoked exactly once and its return value is the resolver's result
#### Scenario: driveId mismatch surfaces DRIVE_URI.DRIVE_ID_MISMATCH
- **WHEN** `resolver.read("drive://other/x")` is called and the plugin is configured with `driveId: "primary"`
- **THEN** the call rejects with an error whose `cause` is a `DriveUriError` having `code === "DRIVE_URI.DRIVE_ID_MISMATCH"`, `uri === "drive://other/x"`, `expected === "primary"`, `actual === "other"` (the `StorageUriResolver` wraps the plugin's `parse` throw as `STORAGE_URI.ADAPTER_ERROR` per its own contract, preserving the original on `cause`). `plugin.parse(uri)` called outside the resolver throws the `DriveUriError` directly.
#### Scenario: build round-trips driveId and key
- **WHEN** `plugin.build("folder/file.bin", { driveId: "primary" })` is called
- **THEN** the returned string equals `"drive://primary/folder/file.bin"`
**Verified by:** `src/plugins/drive/drive.test.ts::"dispatches a drive:// URI to Drive.read"` AND `src/plugins/drive/drive.test.ts::"throws DRIVE_ID_MISMATCH when URI driveId differs from configured driveId"` AND `src/plugins/drive/drive.test.ts::"build round-trips driveId and key into a drive:// URI"`
---
### Requirement: `drive+mesh://` plugin captures swarmId and delegates to Drive without dispatch-on-swarm
The `driveMeshUriPlugin(options)` factory SHALL return a `UriPlugin` with `scheme === "drive+mesh"`. Its `parse` SHALL extract the URI authority as `meta.swarmId` and the remainder as `key`. The plugin SHALL forward `key` to the consumer-supplied `Drive` exactly like the `drive` plugin; it SHALL NOT use `swarmId` to choose a `Drive` or transport — peer routing is the `Drive`'s and its transports' responsibility. The swarmId SHALL be surfaced as the `drive.uri.swarm_id` span attribute on every emitted span.
#### Scenario: URI dispatches to the configured Drive regardless of swarmId
- **WHEN** `resolver.read("drive+mesh://swarmA/raw/clip.mov")` is called and the resolver holds `driveMeshUriPlugin({ drive: meshDrive })`
- **THEN** `meshDrive.read("raw/clip.mov", { signal })` is invoked exactly once
#### Scenario: swarmId appears on the emitted span
- **WHEN** `resolver.read("drive+mesh://swarmA/raw/clip.mov")` is called
- **THEN** the emitted span has attribute `drive.uri.swarm_id === "swarmA"`
#### Scenario: build round-trips swarmId and key
- **WHEN** `plugin.build("raw/clip.mov", { swarmId: "swarmA" })` is called
- **THEN** the returned string equals `"drive+mesh://swarmA/raw/clip.mov"`
**Verified by:** `src/plugins/drive-mesh/drive-mesh.test.ts::"dispatches a drive+mesh:// URI to the configured Drive"` AND `src/plugins/drive-mesh/drive-mesh.test.ts::"surfaces swarmId on the emitted span"` AND `src/plugins/drive-mesh/drive-mesh.test.ts::"build round-trips swarmId and key into a drive+mesh:// URI"`
---
### Requirement: `drive+template://` plugin fetches the template, hydrates the Drive, then reads
The `driveTemplateUriPlugin(options)` factory SHALL return a `UriPlugin` with `scheme === "drive+template"`. On the first `get` for a given `templateId`, the plugin SHALL invoke `options.templateProvider.fetchTemplate(templateId, signal)`, then `options.drive.hydrateFrom(template, { signal })`, before delegating to `options.drive.read(key, { signal })`. Concurrent reads against the same `templateId` SHALL await a single in-flight hydration. Subsequent reads after a successful hydration SHALL skip both calls.
#### Scenario: first read hydrates exactly once
- **WHEN** `resolver.read("drive+template://templA/folder/file.bin")` is called for the first time
- **THEN** `templateProvider.fetchTemplate("templA", signal)` is invoked exactly once AND `drive.hydrateFrom(template, { signal })` is invoked exactly once AND `drive.read("folder/file.bin", { signal })` is invoked exactly once
#### Scenario: concurrent reads against the same templateId share one hydration
- **WHEN** two `resolver.read("drive+template://templA/x")` calls are issued before the first hydration resolves
- **THEN** `templateProvider.fetchTemplate` is invoked exactly once AND `drive.hydrateFrom` is invoked exactly once AND both `drive.read` calls run after hydration resolves
#### Scenario: second read after successful hydration skips fetch and hydrate
- **WHEN** a second `resolver.read("drive+template://templA/y")` is issued after the first read returned successfully
- **THEN** `templateProvider.fetchTemplate` is NOT invoked again AND `drive.hydrateFrom` is NOT invoked again AND `drive.read("y", { signal })` is invoked
**Verified by:** `src/plugins/drive-template/drive-template.test.ts::"hydrates exactly once on the first read"` AND `src/plugins/drive-template/drive-template.test.ts::"shares a single hydration across concurrent reads against the same templateId"` AND `src/plugins/drive-template/drive-template.test.ts::"skips fetch and hydrate on subsequent reads against the same templateId"`
---
### Requirement: `templateProvider` is required for `drive+template://`
The `driveTemplateUriPlugin(options)` factory SHALL throw `DRIVE_URI.CAPABILITY_MISSING` synchronously at construction time when `options.templateProvider` is missing or is not a function-typed `fetchTemplate` carrier. The factory MUST NOT defer this check to first use — the plugin would otherwise silently accept registration and fail at the first `read`.
#### Scenario: missing templateProvider throws at construction
- **WHEN** `driveTemplateUriPlugin({ drive })` is called without a `templateProvider`
- **THEN** the call throws `DriveUriError` with `code === "DRIVE_URI.CAPABILITY_MISSING"` and a `reason` field naming `templateProvider`
#### Scenario: non-function templateProvider.fetchTemplate is rejected
- **WHEN** `driveTemplateUriPlugin({ drive, templateProvider: { fetchTemplate: "not a function" as unknown as TemplateProvider["fetchTemplate"] } })` is called
- **THEN** the call throws `DriveUriError` with `code === "DRIVE_URI.CAPABILITY_MISSING"`
**Verified by:** `src/plugins/drive-template/drive-template.test.ts::"throws CAPABILITY_MISSING when templateProvider is absent"` AND `src/plugins/drive-template/drive-template.test.ts::"throws CAPABILITY_MISSING when templateProvider.fetchTemplate is not a function"`
---
### Requirement: Template fetch and hydration failures map to distinct DRIVE_URI codes
When `options.templateProvider.fetchTemplate` rejects, the plugin SHALL re-wrap the rejection as `DRIVE_URI.TEMPLATE_NOT_AVAILABLE` with the original error preserved on `cause`. When `Drive.hydrateFrom` rejects (any `DTP_*` code), the plugin SHALL re-wrap the rejection as `DRIVE_URI.TEMPLATE_INVALID` with the original on `cause`. The two failures MUST NOT be conflated — operator response differs (network/availability vs. integrity).
> Note: when `resolver.read(...)` raises an async failure produced by the drive-template adapter (a `DriveUriError`), the `StorageUriResolver` wraps it as `STORAGE_URI.ADAPTER_ERROR` per its own spec, preserving the original `DriveUriError` on `cause`. The scenarios below therefore assert on `(err as { cause }).cause` — that is the original drive-uri throwable.
#### Scenario: fetchTemplate rejection becomes TEMPLATE_NOT_AVAILABLE
- **WHEN** `resolver.read("drive+template://templA/x")` is called and `templateProvider.fetchTemplate` rejects with `new Error("404")`
- **THEN** the outer call rejects with an error whose `cause` is a `DriveUriError` having `code === "DRIVE_URI.TEMPLATE_NOT_AVAILABLE"` AND `cause.cause.message === "404"`
#### Scenario: hydrateFrom rejection becomes TEMPLATE_INVALID
- **WHEN** `resolver.read("drive+template://templA/x")` is called and `drive.hydrateFrom` rejects with a `SuaveplanError` carrying `code === "DTP.TEMPLATE_EXPIRED"`
- **THEN** the outer call rejects with an error whose `cause` is a `DriveUriError` having `code === "DRIVE_URI.TEMPLATE_INVALID"` AND `cause.cause.code === "DTP.TEMPLATE_EXPIRED"`
**Verified by:** `src/plugins/drive-template/drive-template.test.ts::"wraps fetchTemplate rejection as TEMPLATE_NOT_AVAILABLE"` AND `src/plugins/drive-template/drive-template.test.ts::"wraps hydrateFrom rejection as TEMPLATE_INVALID"`
---
### Requirement: `DriveStorageLikeAdapter` translates Drive missing-file errors into `undefined`
The internal `DriveStorageLikeAdapter` class SHALL implement `StorageLike` and SHALL translate `Drive.read` rejections whose `cause` chain indicates a not-found condition into `undefined` (matching the `StorageLike.get` contract). Every other failure SHALL be re-thrown unchanged so the upstream `StorageUriResolver` can wrap it as `STORAGE_URI.ADAPTER_ERROR`.
#### Scenario: not-found Drive error returns undefined
- **WHEN** `adapter.get("missing.bin")` is called and `Drive.read` rejects with a `DRIVE.IO_FAILED` whose cause is a `NOT_FOUND`-flavoured marker
- **THEN** the call resolves to `undefined`
#### Scenario: arbitrary Drive failure is re-thrown
- **WHEN** `adapter.get("x")` is called and `Drive.read` rejects with `new SuaveplanError("DRIVE.PLUGIN_FAILED", { message: "plugin crashed" })`
- **THEN** the call rejects with the same error reference; the adapter does NOT translate it to `undefined`
#### Scenario: clear() is unsupported
- **WHEN** `adapter.clear()` is called
- **THEN** the call rejects with a `SuaveplanError` whose message names `Drive.delete(path)` as the supported alternative
**Verified by:** `src/adapter/drive-storage-like.test.ts::"returns undefined for Drive not-found errors"` AND `src/adapter/drive-storage-like.test.ts::"re-throws non-not-found Drive errors unchanged"` AND `src/adapter/drive-storage-like.test.ts::"clear is unsupported and names the alternative"`
---
### Requirement: AbortSignal propagation across every plugin op
The adapter shim `createReadStream(key, signal)` SHALL forward the `AbortSignal` argument unchanged to `Drive.createReadStream(key, signal)` so that `StorageUriResolver.open()` threads the caller's signal into the drive's streaming path. A signal that is already aborted at call time SHALL cause the adapter shim to reject with `DRIVE_URI.ABORTED` without invoking the `Drive`. The `drive+template` plugin SHALL forward the signal to `templateProvider.fetchTemplate` and `Drive.hydrateFrom` on the first hydration. `StorageUriResolver`'s own pre-flight abort check (which raises `STORAGE_URI.ABORTED` before any plugin is consulted) is preserved as-is and is not modified by drive-uri.
#### Scenario: pre-aborted signal short-circuits the adapter stream
- **WHEN** `adapter.createReadStream("x", AbortSignal.abort())` is called on the adapter returned by `driveUriPlugin`
- **THEN** the call throws `DriveUriError` with `code === "DRIVE_URI.ABORTED"` AND `Drive.createReadStream` is NOT invoked
#### Scenario: live signal reaches Drive.createReadStream unchanged
- **WHEN** `adapter.createReadStream("x", liveSignal)` is called
- **THEN** the `Drive.createReadStream` invocation receives the same `liveSignal` reference
**Verified by:** `src/plugins/drive/drive.test.ts::"short-circuits with DRIVE_URI.ABORTED on a pre-aborted signal"` AND `src/plugins/drive/drive.test.ts::"forwards a live AbortSignal unchanged to Drive.createReadStream"`
---
### Requirement: Streaming reads forward Drive.createReadStream when present
The `DriveStorageLikeAdapter` SHALL expose a `createReadStream(key, signal)` method that forwards directly to `Drive.createReadStream(key, signal)`. When `resolver.open("drive://…")` is invoked, the resolver SHALL use this method (no buffered fallback) and the `storage_uri.stream_fallback_total` counter SHALL NOT be incremented.
#### Scenario: open returns Drive.createReadStream output
- **WHEN** `resolver.open("drive://primary/clip.mov")` is called
- **THEN** the returned `ReadableStream` is the same reference returned by `Drive.createReadStream("clip.mov", signal)` AND `storage_uri.stream_fallback_total` does NOT increment
#### Scenario: mid-stream abort cancels the Drive stream
- **WHEN** `resolver.open("drive://primary/clip.mov", controller.signal)` is called and `controller.abort()` is invoked after the first chunk
- **THEN** the underlying `Drive.createReadStream`'s signal observes the abort
**Verified by:** `src/plugins/drive/drive.streams.test.ts::"open forwards Drive.createReadStream without buffered fallback"` AND `src/plugins/drive/drive.streams.test.ts::"mid-stream abort propagates to Drive.createReadStream"`
---
### Requirement: Writes via drive:// schemes are rejected
The three plugins SHALL reject `set` (write) calls with `DRIVE_URI.WRITE_UNSUPPORTED`. The rejection SHALL carry the original URI and scheme on the error so operators can see misuse rate via `drive_uri.writes_total{scheme, outcome: "error"}`.
#### Scenario: write against drive:// rejects with DRIVE_URI.WRITE_UNSUPPORTED on the cause chain
- **WHEN** `resolver.write("drive://primary/x", new Uint8Array([1, 2, 3]))` is called
- **THEN** the call rejects with an error whose outer `code === "STORAGE_URI.WRITE_UNSUPPORTED"` (the resolver's normalised write-failure code) AND whose `cause.code === "DRIVE_URI.WRITE_UNSUPPORTED"` (the original drive-uri rejection) AND whose `cause` carries `uri === "drive://primary/x"`, `scheme === "drive"`
#### Scenario: writes_total counter increments on the rejection
- **WHEN** `adapter.set("x", bytes)` is called on the adapter returned by `driveMeshUriPlugin`
- **THEN** the call rejects with `code === "DRIVE_URI.WRITE_UNSUPPORTED"` AND `drive_uri.writes_total{scheme: "drive+mesh", outcome: "error"}` is incremented by 1
**Verified by:** `src/plugins/drive/drive.test.ts::"rejects writes with DRIVE_URI.WRITE_UNSUPPORTED"` AND `src/otel/otel.test.ts::"increments writes_total on rejected writes"`
---
### Requirement: OpenTelemetry instrumentation
Every plugin operation SHALL emit a span on the `@suaveplan/drive-uri` tracer and SHALL record `drive_uri.op_duration_ms{op, scheme, outcome}` on its meter. Errors SHALL increment `drive_uri.errors_total{scheme, code}`. The `drive.uri` span attribute SHALL exclude any URI query string. The `drive+template` plugin SHALL increment `drive_uri.template_hydrations_total{outcome}` exactly once per first-use template; cache-hit reads SHALL NOT increment it.
#### Scenario: successful read records duration and reads counter
- **WHEN** `resolver.read("drive://primary/x")` succeeds
- **THEN** `drive_uri.op_duration_ms{op:"read", scheme:"drive", outcome:"success"}` records one observation AND `drive_uri.reads_total{scheme:"drive", outcome:"success"}` increments by 1
#### Scenario: error increments errors_total with the resolved code
- **WHEN** `resolver.read("drive+template://templA/x")` rejects with `TEMPLATE_NOT_AVAILABLE`
- **THEN** `drive_uri.errors_total{scheme:"drive+template", code:"DRIVE_URI.TEMPLATE_NOT_AVAILABLE"}` increments by 1
#### Scenario: query strings are stripped from span attributes
- **WHEN** `resolver.read("drive+mesh://swarmA/path?peers=abcdef")` is called
- **THEN** the emitted span's `drive.uri` attribute equals `"drive+mesh://swarmA/path"` (no `?peers=…`)
#### Scenario: template_hydrations_total increments once per first-use template
- **WHEN** `resolver.read("drive+template://templA/x")` is called then `resolver.read("drive+template://templA/y")` is called
- **THEN** `drive_uri.template_hydrations_total{outcome:"success"}` is exactly 1
**Verified by:** `src/otel/otel.test.ts::"records duration histogram and reads counter on success"` AND `src/otel/otel.test.ts::"increments errors_total with the resolved DRIVE_URI code"` AND `src/otel/otel.test.ts::"strips query strings from the drive.uri span attribute"` AND `src/otel/otel.test.ts::"increments template_hydrations_total exactly once per first-use template"`
---
### Requirement: Conformance harness reuse via `runUriPluginConformance`
The package SHALL re-export `runUriPluginConformance` from `@suaveplan/storage-uri` and SHALL include fixture-driven conformance tests for each of the three plugins. The fixtures SHALL cover three valid URI shapes, four invalid URI shapes, and a `build` round-trip for each valid fixture. The package SHALL NOT author its own parallel harness.
#### Scenario: drive plugin passes the conformance harness
- **WHEN** `runUriPluginConformance(driveUriPlugin({ drive, driveId: "primary" }), driveFixtures)` is invoked
- **THEN** every assertion passes without throwing
#### Scenario: drive+template plugin passes the conformance harness
- **WHEN** `runUriPluginConformance(driveTemplateUriPlugin({ drive, templateProvider }), templateFixtures)` is invoked
- **THEN** every assertion passes without throwing
**Verified by:** `src/conformance/conformance.test.ts::"drive plugin satisfies runUriPluginConformance"` AND `src/conformance/conformance.test.ts::"drive+template plugin satisfies runUriPluginConformance"`
---
### Requirement: Cross-scheme composition with `storage-uri`
A single `StorageUriResolver` MUST accept a mix of `storage-uri`'s plugins and `drive-uri`'s plugins and dispatch each URI to the correct plugin without code-path divergence. Registration of one drive plugin SHALL NOT prevent registration of any number of `storage-uri` plugins, and vice versa.
#### Scenario: file:// and drive:// resolve through the same resolver
- **WHEN** `createStorageUriResolver([fileUriPlugin({ rootDir }), driveUriPlugin({ drive, driveId: "primary" })])` is constructed and both `resolver.read("file:///tmp/a")` and `resolver.read("drive://primary/b")` are invoked
- **THEN** the file:// call routes to the file plugin's adapter AND the drive:// call routes to the drive plugin's adapter; neither call observes the other's state
**Verified by:** `tests/integration/compose-with-storage-uri.test.ts::"file:// and drive:// resolve through one StorageUriResolver"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,82 @@
# Tasks — add-drive-uri
> Every checkbox MUST reflect work actually done in this change.
## 1. Spec authoring
- [ ] 1.1 Read `openspec/specs/storage-uri/spec.md` (post-archive of `add-storage-uri`) and confirm `UriPlugin` is the correct integration point
- [ ] 1.2 Read `openspec/specs/drive/spec.md`, `openspec/specs/drive-template/spec.md`, `openspec/specs/drive-mesh/spec.md`, `openspec/specs/drive-plugin-chunks/spec.md` to confirm the consumed `Drive` surface
- [ ] 1.3 Read `openspec/AGENTS.md` §0c, §2b, §6a, §7, §11, §13, §14
- [ ] 1.4 Read `openspec/archive/2026-05-16-add-storage-uri/` end-to-end as the closest precedent
- [ ] 1.5 Confirm `Drive.read`, `Drive.createReadStream`, `Drive.write`, `Drive.delete`, `Drive.stat`, `Drive.hydrateFrom` signatures by reading `packages/drive/drive/src/drive/drive.ts`
- [ ] 1.6 Draft `proposal.md`, `design.md`, `tasks.md`, `specs/drive-uri/spec.md` per `openspec/templates/*`
- [ ] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete planned `it(...)` titles
- [ ] 1.8 Build the numbered Requirement/Scenario implementation checklist (per §0c step 6) — 11 requirements, 26 scenarios
## 2. Scaffolding
- [ ] 2.1 Create `packages/drive/drive-uri/` mirroring `packages/storage/storage-uri/` conventions
- [ ] 2.2 Write `package.json` v0.0.0, `"sideEffects": false`, subpath exports for every feature module
- [ ] 2.3 Wire deps: `@suaveplan/error`, `@suaveplan/storage-uri`, `@suaveplan/drive`, `@suaveplan/drive-template`, `@suaveplan/types`, `@opentelemetry/api`, `tslib` (all `workspace:^` / `catalog:`)
- [ ] 2.4 Wire devDeps: `@suaveplan/testing`, `@suaveplan/storage`, `@suaveplan/storage-file`, `@suaveplan/drive-mesh`, `@suaveplan/drive-plugin-chunks`, `@vitest/coverage-istanbul`, `fast-check`, `typescript`, `vite`, `vite-plugin-dts`, `vitest`
- [ ] 2.5 Write `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts` (via `createTestConfig`)
- [ ] 2.6 Write `tests/setup/polyfills.ts` and `README.md` skeleton
## 3. Implementation
- [ ] 3.1 `src/error-codes/error-codes.ts` — `defineCodes("DRIVE_URI", ["INVALID_URI", "DRIVE_ID_MISMATCH", "TEMPLATE_NOT_AVAILABLE", "TEMPLATE_INVALID", "WRITE_UNSUPPORTED", "CAPABILITY_MISSING", "ABORTED"] as const)`, `DriveUriError` class, `uri`/`scheme`/`driveId?`/`swarmId?`/`templateId?`/`path?` discriminants
- [ ] 3.2 `src/types/plugin-options.types.ts` — `DriveUriPluginOptions`, `DriveMeshUriPluginOptions`, `DriveTemplateUriPluginOptions`, `TemplateProvider` interfaces (no inline shapes per §11)
- [ ] 3.3 `src/adapter/drive-storage-like.ts` — `DriveStorageLikeAdapter` translating `Drive` → `StorageLike`, with `createReadStream` passthrough and not-found-to-undefined mapping
- [ ] 3.4 `src/plugins/drive/drive.ts` — `driveUriPlugin(options): UriPlugin` with parse/build, `DRIVE_ID_MISMATCH` guard, AbortSignal propagation, `WRITE_UNSUPPORTED` on `set`
- [ ] 3.5 `src/plugins/drive/drive.streams.ts` — `createReadStream` forwarding helper (shared between the three plugins)
- [ ] 3.6 `src/plugins/drive-mesh/drive-mesh.ts` — `driveMeshUriPlugin(options): UriPlugin` with swarmId capture for instrumentation only
- [ ] 3.7 `src/plugins/drive-template/drive-template.ts` — `driveTemplateUriPlugin(options): UriPlugin` with construction-time `CAPABILITY_MISSING` check, per-`templateId` hydration cache (`Map<string, Promise<void>>`), `TEMPLATE_NOT_AVAILABLE`/`TEMPLATE_INVALID` mapping
- [ ] 3.8 `src/otel/otel.ts` — meter + tracer wiring, `recordOp(op, scheme, outcome, ms)`, `recordTemplateHydration(outcome)`, span attributes with query-string stripping
- [ ] 3.9 `src/conformance/conformance.ts` — re-export of `runUriPluginConformance` from `@suaveplan/storage-uri`; package-local fixture sets in tests
- [ ] 3.10 `src/index.ts` — explicit named re-exports per §6a
## 4. Tests (every `it(...)` carries `{ timeout: 5000 }`, 30000 for async integration)
- [ ] 4.1 `src/error-codes/error-codes.test.ts` — every code reachable from a real call site, discriminants populated, `cause` preserved
- [ ] 4.2 `src/adapter/drive-storage-like.test.ts` — `get` returns `undefined` on not-found, re-throws other failures, `clear` rejects with helpful message
- [ ] 4.3 `src/plugins/drive/drive.test.ts` — parse + dispatch + `DRIVE_ID_MISMATCH` + build round-trip + AbortSignal pre/forward + `WRITE_UNSUPPORTED`
- [ ] 4.4 `src/plugins/drive/drive.streams.test.ts` — native `createReadStream` forwarding, mid-stream abort propagation
- [ ] 4.5 `src/plugins/drive-mesh/drive-mesh.test.ts` — dispatch, swarmId span attribute, build round-trip
- [ ] 4.6 `src/plugins/drive-template/drive-template.test.ts` — first-read hydration, concurrent-read single-hydration, second-read skip, `TEMPLATE_NOT_AVAILABLE` mapping, `TEMPLATE_INVALID` mapping, `CAPABILITY_MISSING` (missing provider + non-function fetchTemplate)
- [ ] 4.7 `src/otel/otel.test.ts` — duration histogram, errors_total with code, query-string stripping, `template_hydrations_total` exactly-once per template
- [ ] 4.8 `src/conformance/conformance.test.ts` — drive / drive+mesh / drive+template plugins pass `runUriPluginConformance` with planned fixtures
- [ ] 4.9 `tests/integration/drive-roundtrip.test.ts` — real Drive on storage-file: write a file, read it back via `resolver.read("drive://...")`
- [ ] 4.10 `tests/integration/template-roundtrip.test.ts` — real Drive + chunksPlugin: issue a `DriveTemplate`, dehydrate, fetch via in-process `templateProvider`, resolve via `drive+template://...`
- [ ] 4.11 `tests/integration/compose-with-storage-uri.test.ts` — one `StorageUriResolver` with `fileUriPlugin` + `driveUriPlugin`, resolve both `file:///` and `drive://`
- [ ] 4.12 No imports from `vitest` or `bun:test` in test files — all via `@suaveplan/testing/runner` (§7)
- [ ] 4.13 No `vi.mock` / `jest.mock`; all dependencies are real (CLAUDE.md rule 2)
## 5. Documentation
- [ ] 5.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
- [ ] 5.2 `README.md` links every module under `## Modules`
- [ ] 5.3 README documents the composition recipe with `storage-uri` + `drive-uri` plugins side-by-side
- [ ] 5.4 README documents the `TemplateProvider` interface and how to author one
## 6. Gates
- [ ] 6.1 `bun run check`
- [ ] 6.2 `bunx turbo lint typecheck test --filter=@suaveplan/drive-uri`
- [ ] 6.3 `bunx turbo test:coverage --filter=@suaveplan/drive-uri` — 100/100/100/100 Istanbul
- [ ] 6.4 `bunx turbo build --filter=@suaveplan/drive-uri`
- [ ] 6.5 `bun scripts/lint/check-test-runner-imports.ts` (rule 11)
- [ ] 6.6 Tier-purity grep — `grep -rnE "from ['\"](node:|bun:)" packages/drive/drive-uri/src/` returns empty
- [ ] 6.7 Banned-pattern scan returns empty (§2)
- [ ] 6.8 Missing-`.md` scan returns empty (§4)
- [ ] 6.9 Non-root `export *` scan returns empty (§6a)
- [ ] 6.10 Bare-version scan returns empty (§1)
- [ ] 6.11 Spec-reconciliation gate — every `### Requirement` is implemented; every `**Verified by:**` test name exists verbatim and passes (§13 step 0)
## 7. Manifest & archival
- [ ] 7.1 Add package to `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts`
- [ ] 7.2 Update `STATUS.md` (genesis-original — add to "Completed" once shipped)
- [ ] 7.3 Update `SUMMARY.md` with the one-paragraph description
- [ ] 7.4 `bunx markdownlint-cli2 --fix` on touched docs
- [ ] 7.5 Fold `specs/drive-uri/spec.md` into `openspec/specs/drive-uri/spec.md`
- [ ] 7.6 Move `openspec/changes/add-drive-uri/` → `openspec/archive/<yyyy-mm-dd>-add-drive-uri/`