# 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 |