94 lines
10 KiB
Markdown
94 lines
10 KiB
Markdown
# 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 |
|