Files
suaveplan/openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-drive-uri-writes/proposal.md
2026-05-27 23:29:53 +01:00

94 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |