10 KiB
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/removewithDRIVE_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-upadd-drive-uri-writeschange 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 thedrivescheme delegate toDrive.write(path, data, { signal })andDrive.delete(path, signal).AbortSignalis threaded through the resolverwrite(uri, body, signal)andremove(uri, signal)surfaces. OTeldrive_uri.writes_total{scheme:"drive", outcome:"success"|"error"}anddrive_uri.deletes_total{scheme:"drive", outcome}populated. - ADDED:
drive+mesh://writes/removes — identical delegation toDrive.write/Drive.delete; the consumer's installedchunksPluginhandles encryption + chunk persistence locally. Mesh re-advertisement is NOT triggered by the URI surface (matching the direct-Drivecontract); the consumer SHALL callmesh.advertise(fileId, bitset, totalChunks)after the write resolves if they want gossip propagation. The plugin documents this contract on the sibling.mdand surfaces it on a span eventdrive_uri.mesh_write_local_onlyso operators can audit any environment that expects implicit gossip. - ADDED:
drive+template://writes/removes — permanently rejected with the new codeDRIVE_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"}anddrive_uri.deletes_total{scheme:"drive+template", outcome:"error"}increment on the rejection. - ADDED: New error code
DRIVE_URI.TEMPLATE_READ_ONLYviadefineCodes(registry grows from 7 → 8). Reachable from thedrive+templateadapter'ssetanddeleteoverrides; both paths covered by tests. - MODIFIED:
DRIVE_URI.WRITE_UNSUPPORTEDretained in the registry but narrowed to a single call site —DriveStorageLikeAdapter.clear()(the explicitDrive.delete-this-instead footgun). It is no longer the rejection code for write/remove viaset/delete. The README, sibling.md, and spec all describe this narrower role. - MODIFIED:
InstrumentedDriveAdapter.setno longer unconditionally throws. It dispatches per the scheme bound at construction:drive/drive+mesh→ delegate;drive+template→ throwTEMPLATE_READ_ONLY. Same fordelete. - MODIFIED: Conformance — the
runUriPluginConformancewrites suite is re-run against all three plugins. Expected outcomes:drive://anddrive+mesh://complete the full write/read/exists/list/remove cycle;drive+template://continues to reject writes (now withTEMPLATE_READ_ONLYrather thanWRITE_UNSUPPORTED). - ADDED:
tests/integration/drive-roundtrip-write.test.ts— write bytes viaresolver.write("drive://primary/x", bytes), read back viaresolver.read, assert equality. No mocks. - ADDED:
tests/integration/drive-mesh-roundtrip-write.test.ts— same against a mesh-configured Drive; assert no implicitadvertisecall; explicitmesh.advertise(...)after the write produces a verifiable outbound advertisement via the existing transport stub fixture.
Out of scope
- Auto-gossip on
drive+mesh://writes. DirectDrive.writedoes 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_ONLYrejection is the contract. - Range reads on
drive+mesh://. Independent changeadd-drive-range-reads. DriveLikehoist to@suaveplan/types. Independent changehoist-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.writeoptions (atomicity, conditional puts, content-type). Out of scope — would require aDrive-tier proposal. - Removing
DRIVE_URI.WRITE_UNSUPPORTED. Kept for theclear()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.mdlines 30–38 — the explicit follow-up scope. packages/drive/drive-uri/src/plugins/drive/drive.streams.ts:69-84— current unconditionalsetrejection.packages/drive/drive-uri/src/adapter/drive-storage-like.ts:75-86—set/deletealready pass-through internally; only the instrumented override blocks them.packages/drive/drive/src/drive/drive.ts:180-200—Drive.write(path, data, options?)andDrive.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 ishydrateFrom, which is a read-side projection.packages/storage/storage-uri/src/resolver/resolver.writes.ts:30-75— confirmsSuaveplanErrorsubclasses (includingDriveUriError) pass throughwriteViaPluginunchanged, soTEMPLATE_READ_ONLYreaches the caller without aSTORAGE_URI.WRITE_UNSUPPORTEDenvelope.openspec/specs/drive-uri/spec.mdRequirement 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-uriis 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 fordrive+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 |