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

7.3 KiB

Design — add-drive-uri-writes

Non-normative. The normative contract is ./specs/drive-uri/spec.md.

Per-scheme operation matrix

Operation drive:// drive+mesh:// drive+template://
set(key, bytes) (resolver write) Drive.write(key, bytes, { signal }) → void Drive.write(key, bytes, { signal }) → void; mesh advertise NOT triggered throw DRIVE_URI.TEMPLATE_READ_ONLY
delete(key) (resolver remove) Drive.delete(key, signal) → boolean Drive.delete(key, signal) → boolean; mesh "no-longer-available" advert NOT triggered throw DRIVE_URI.TEMPLATE_READ_ONLY
clear() throw DRIVE_URI.WRITE_UNSUPPORTED (Drive-wide wipe footgun) same same
createReadStream unchanged unchanged unchanged
get / exists / list / stat unchanged unchanged unchanged

clear() retains DRIVE_URI.WRITE_UNSUPPORTED to preserve the existing error contract for that footgun call site. The new TEMPLATE_READ_ONLY code is specific to template-backed mutation attempts — operators differentiate "this drive forbids wholesale wipes" (a config-time concern) from "this URI scheme is intrinsically read-only" (an architectural constraint).

Error code transition

Old behaviour New behaviour Code
set on drive:// → throw WRITE_UNSUPPORTED succeeds (success)
set on drive+mesh:// → throw WRITE_UNSUPPORTED succeeds (success)
set on drive+template:// → throw WRITE_UNSUPPORTED throw TEMPLATE_READ_ONLY DRIVE_URI.TEMPLATE_READ_ONLY
delete on drive+template:// → forwarded to underlying Drive.delete (silent footgun: would mutate the hydrated cache, breaking the dehydrated semantics) throw TEMPLATE_READ_ONLY DRIVE_URI.TEMPLATE_READ_ONLY
clear() on any scheme → throw WRITE_UNSUPPORTED unchanged DRIVE_URI.WRITE_UNSUPPORTED

DRIVE_URI.WRITE_UNSUPPORTED is intentionally retained — consumers who already asserted on it for the clear() path continue to work; we narrow its meaning rather than churn the constant.

Mesh write — propagation timing and observability

drive+mesh:// writes complete when local persist is durable. Specifically:

  1. Drive.write(path, bytes, { signal }) returns when the installed chunksPlugin middleware has resolved (encrypted, chunked, persisted to the configured storage).
  2. Mesh re-advertise (mesh.advertise(fileId, bitset, totalChunks)) is NOT triggered by the URI surface. This matches direct-Drive behaviour and avoids surprising consumers who manage GenerationTracker / AdvertiseThrottle lifecycles themselves.
  3. The plugin emits a span event drive_uri.mesh_write_local_only on every successful drive+mesh:// write, with attributes { scheme: "drive+mesh", path }. Operators can grep the trace to confirm the contract holds in their environment.
  4. Consumers wanting gossip propagation invoke mesh.advertise(fileId, ...) after the write resolves. Propagation thereafter follows the existing GossipEngine timing (best-effort, backoff-throttled, observable via the mesh package's own events).

This deliberate choice preserves separation of concerns: the URI surface is a routing layer; mesh participation is a Drive-tier policy.

Key-rotation impact (none, by inheritance)

A write may produce data encrypted under a NEW KeySlotPublic version. The URI surface does no key derivation, no slot resolution, and no grant signing — those live in chunksPlugin and in Drive.hydrateFrom's key:grant subscription. The read path validates retroactive grants today; nothing in the write path needs to change for new key versions to be honoured on subsequent reads.

No new wiring required at the URI tier.

AbortSignal threading

  • resolver.write(uri, body, signal?) passes signal to InstrumentedDriveAdapter.set(key, value, signal).
  • InstrumentedDriveAdapter.set updates its signature to accept AbortSignal (currently typed set(key, value) per StorageLike; we add the third arg as an optional propagated to Drive.write({ signal })). Pre-aborted signals short-circuit to DRIVE_URI.ABORTED before any work.
  • resolver.remove(uri, signal?) already threads signal to adapter.delete(key); we widen the override to accept and forward signal to Drive.delete(path, signal).

StorageLike itself does not specify signal on set/delete. DriveStorageLikeAdapter adds the parameter as a tolerated extension; the resolver already passes it via the streaming-write surface (createWriteStream(key, body, signal)), so the precedent is established.

File-layout impact

New / edited files (under packages/drive/drive-uri/src/):

adapter/
  drive-storage-like.ts          # widen set/delete to accept signal; forward through
  drive-storage-like.md          # doc update — note new TEMPLATE_READ_ONLY case
error-codes/
  error-codes.ts                 # add TEMPLATE_READ_ONLY to defineCodes
  error-codes.md                 # update; describe narrowed WRITE_UNSUPPORTED role
plugins/
  drive/
    drive.streams.ts             # remove unconditional WRITE_UNSUPPORTED; per-scheme dispatch
    drive.streams.md             # doc update
  drive-template/
    drive-template.ts            # override set/delete → TEMPLATE_READ_ONLY
    drive-template.md            # doc update
otel/
  otel.ts                        # add `mesh_write_local_only` span-event constant
  otel.md                        # doc update
README.md                        # update Features / Modules sections
tests/integration/
  drive-roundtrip-write.test.ts  # NEW — real Drive, write+read cycle
  drive-mesh-roundtrip-write.test.ts # NEW — mesh-configured Drive, assert no implicit advertise
  template-write-rejection.test.ts   # NEW — TEMPLATE_READ_ONLY round-trip

All new code stays inside the existing 300-line / 500-line file budgets; the changed drive.streams.ts currently sits at 148 lines and the per-scheme branching adds ~30 lines.

Trade-offs considered

  • Per-scheme set dispatch via if (scheme === "drive+template") vs. polymorphic adapter subclass. Chose the inline branch — only one scheme has the constraint and a subclass would duplicate the entire instrumented method body. If a fourth scheme appears, revisit.
  • Auto-advertise on mesh writes. Rejected: violates the direct-Drive contract, would surprise consumers who run GenerationTracker themselves, and forces every mesh-write call site through an advertise budget. The span event gives operators visibility without enforcing policy.
  • Reusing WRITE_UNSUPPORTED for template rejections. Rejected: it conflates a temporary state (the deferred scope this change closes) with a permanent architectural constraint (templates are signed). Distinct codes let consumers branch correctly.
  • Removing WRITE_UNSUPPORTED entirely. Rejected: it would churn the public surface (consumers may already assert on it for the clear() path) and §2c forbids deleting a registered code without a real reason. Narrowing the role is the right move.

Cross-reference

This change is the second consumer of structural DriveLike (after add-drive-range-reads), strengthening the case for hoist-drivelike-to-types. Independent landing — drive-uri imports DriveLike from @suaveplan/drive today and will move to @suaveplan/types once hoist-drivelike-to-types archives.