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

10 KiB
Raw Blame History

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