Accepted

ADR 0037 — Plugin storage scoping

One database, one bucket, one KV namespace — each plugin sees only its own slice, enforced by host-built wrappers

Cusp runs on one D1 database, one R2 bucket, and one KV namespace — splitting at the data layer is forbidden. Plugins still get isolated storage, but it is a logical layer over the shared resources, enforced by wrappers the host constructs per plugin. The plugin imports the normal Cloudflare binding shape and calls it as it would in any Worker; the wrapper does the scoping underneath.

The decision

Each storage capability maps to a scoped wrapper built per plugin. The wrappers live under src/worker/plugins/storage/ and are the isolation enforcement point. Isolation never depends on the plugin behaving correctly.

KV — key-prefix scoped

Every key the plugin reads or writes is automatically prefixed with plugin/{pluginId}/. A plugin calling list with no prefix lists only its own keys, because the wrapper always supplies the plugin prefix; returned keys are stripped back to what the plugin stored. Writes (put and delete) are gated on the kv.write capability at the wrapper, since the platform sees a single namespace and would otherwise accept the write.

D1 — table-prefix scoped, query-rewrite enforced

Plugin tables are named plugin_{pluginId}_*, with hyphens in the id substituted for underscores. A hand-rolled tokeniser parses every prepared statement and rejects any reference to a table outside the plugin's prefix before the statement reaches D1. The check matches each referenced table against the plugin's declared table list, not a bare prefix test — a prefix-only check would let plugin id foo read tables belonging to plugin id foo-bar.

The grammar is a deliberately narrow subset of SQL: SELECT, INSERT, UPDATE, and DELETE against the plugin's own tables, with a fixed allowlist of expressions and functions. Anything outside it is rejected with a typed error code, even where SQLite would accept it.

  • Rejected outright — CTEs, sub-SELECTs in expression position, PRAGMA, ATTACH, all DDL, upsert clauses, window functions, FTS5 MATCH, and multi-statement strings.
  • exec and dump are not exposed at all, since they would bypass the rewriter. A plugin needing several statements uses batch over multiple prepared statements.
  • A hand-rolled tokeniser was chosen over off-the-shelf SQL parsers: it has no dependencies, its surface is its grammar, and the narrowness is the isolation property. "The parser could not prove it safe, so it rejected it" is the intended failure mode.

A shared key-value table for all plugins was rejected. It would lose indexability, lose query expressiveness, and lose the single-grep audit. Per-plugin tables let a plugin declare its own indexes and join its own tables naturally, and "what does this plugin store?" is one query against the table list.

R2 — key-prefix scoped

Runtime objects live under plugins/{pluginId}/data/. The bundle area one level up belongs to the install workflow and is never touched at runtime. list is forced-prefix and returned keys are stripped, the same as KV. Multipart uploads are scoped at both create and resume, so a plugin cannot resume another plugin's upload.

Email — capability gate and fixed sender

A plugin with email.send gets a wrapper that forces the from address to the deployment's verified sending domain and accepts only an exact field allowlist — to, replyTo, subject, and the text and html bodies. cc, bcc, and raw headers are dropped, and to, replyTo, and subject are rejected if they contain CR or LF, closing SMTP-header injection. Forging the sender is the single largest abuse vector for email.send and is closed at the wrapper. Invocation logging is applied by the hook dispatcher, not the proxy; per-plugin rate-limiting is not implemented in v1 — the invocation log records every call, but no quota is enforced against it yet.

Versioning and backups

Plugin installation metadata and configuration are versioned through the versioned-write helper, so time travel covers a plugin's settings history. Plugin-owned tables, KV keys, and R2 objects are operational state, not content — they are not versioned per write, because versioning every analytics or search-index write would explode the version-history budget. Time travel does not roll plugin data back.

Backups are the other axis. The nightly export dumps the core content, versioning, theme, and plugin-metadata tables to R2, alongside an inventory of the media and rendered prefixes. Plugin-owned data tables and the plugins/{pluginId}/data/ R2 prefix are operational state outside the standard backup — operators who expect time travel or a routine restore to recover a plugin's own table will be surprised, so a plugin's durability is its own concern.

Consequences

  • The architectural invariant holds — no new databases, buckets, or namespaces at install. Scoping is a logical layer.
  • The plugin sees the platform's binding shape unchanged, so standard Workers learning material translates directly to plugin authoring.
  • Audit is one identifier in. An operator inspecting a plugin's footprint runs one grep per binding using just the plugin id.
  • The D1 query rewriter is a parsing surface, and a parser bug that misses a table reference is a cross-plugin read. The narrow grammar is the mitigation; the rewriter rejects anything it cannot prove safe.
Read the plugin hook system record