From 9641f7d2c8255080cc44361316cca63744fac5f0 Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Wed, 19 Aug 2026 15:15:08 +0800 Subject: [PATCH 01/20] docs(storage): respecify phase 4.6 around the four dispositions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first Phase 4.6 attempt is withdrawn. It moved application reads onto the portable ports correctly, but sorted every residual per-Space file into one disposition — Disk-only, with the HTTP API as the single route out — and fenced the Disk tree behind a free function rather than the Space it describes. Review found that right for the families that are genuinely about a filesystem and wrong for the rest: worker state, the memory body, skill.md, and upload scratch want a port, and an API hop in front of unportable state does not make it portable. Carry the reworked proposal onto a fresh base and respecify the phase around it. §6.4 keeps its settled direction — one `space(canvasId)` handle joining both ports above them, four dispositions, and an extension substrate that hands out a connection point rather than a data API. §12.6 becomes one planned phase that builds all of it, absorbing what the withdrawn attempt would have deferred to a 4.7, so everything the second structured adapter needs is in place before SQLite arrives. Phase references throughout now describe the attempt as withdrawn rather than landed, and §12.5.6 goes back to naming `spaceDirectory()`, which is what Phase 4.5 actually shipped. Co-Authored-By: Claude Opus 5 (1M context) --- docs/proposals/multi-backend-storage.md | 644 +++++++++++++++++++++++- 1 file changed, 629 insertions(+), 15 deletions(-) diff --git a/docs/proposals/multi-backend-storage.md b/docs/proposals/multi-backend-storage.md index b787dd494..454f9a6a6 100644 --- a/docs/proposals/multi-backend-storage.md +++ b/docs/proposals/multi-backend-storage.md @@ -1,15 +1,15 @@ # Multi-Backend Storage -Status: Phases 1–4 implemented -Last updated: 2026-08-11 +Status: Phases 1–4.5 implemented; Phase 4.6 specified in §6.4 and §12.6 +Last updated: 2026-08-19 > **Scope and decision confidence.** This proposal records the two-port > `StructuredStore` / `BlobStore` split and their target backend families as > the settled direction. The Blob contract, the structured module/repository > shape, the Space catalogue/lifecycle, async node repository, and minimal ordered > writer are accepted. Exact schemas, stronger transactional APIs, migration -> mechanics, backend-selection scope, virtual filesystem behavior, agent -> workspace materialization, and write-back are still design space. Remaining +> mechanics, backend-selection scope, and virtual-filesystem and agent +> write-back semantics are still design space. Remaining > candidate interfaces below are discussion aids, not implementation > instructions. > @@ -47,11 +47,28 @@ Last updated: 2026-08-11 > Corrections made during implementation and adversarial > review are recorded in place, including the CAS race ordering (§12.2.5), > log-family interface segregation (§12.2.6), and retained-handle Workspace -> guards (§12.2.4). Remaining Disk-only read and physical capabilities still -> keep non-Disk profiles unselectable. No SQLite, Postgres, or Azure adapter -> exists. §12 is the +> guards (§12.2.4). Phase 4.5 moved storage-owned Disk layout behind the +> storage boundary in PR #93 and is the newest implemented phase. Phase 4.6 is +> specified in §6.4 and §12.6 and is **not implemented**: it is the whole +> remaining bridge between the portable contracts and a second structured +> adapter — application reads off `CanvasStore`, one `space(canvasId)` handle, +> a disposition for every residual per-Space file, and a Workspace mount +> lifecycle. No SQLite, Postgres, or Azure adapter exists on `main`. §12 is the > authoritative phase plan; the decision table in §2 marks what each phase > has actually settled. +> +> **Revision, 2026-08-19.** A first attempt at Phase 4.6 was written and +> withdrawn without merging. It collapsed every consumer that needs a real +> directory into a single residual "Space-file" capability and gave them one +> route out: stop needing a tree and reach a Space over the HTTP API. Review +> found that route correct for some of those consumers and wrong for the rest +> — several of them want a _port_, not an API — and found the capability +> fenced off behind a free function rather than reachable from the Space it +> describes. §6.4 replaces the one capability with four dispositions and +> states the composition-layer Space handle that joins both ports; §12.6 is +> respecified around them and now carries the work the withdrawn attempt would +> have split across two phases. Nothing implemented is affected: the attempt +> reached no branch that Phase 4.6 builds on. --- @@ -91,7 +108,10 @@ built above these ports, but its form is intentionally unresolved here. | Blob key, staging, deletion, and GC semantics | Proposed / open | Names are the existing `` keys; `deleteAll()` covers Space destruction. Staging, reference counting, and GC remain undesigned. Per-key deletion stays out of the public port, but the absence of any cleanup path is what makes atomic replace mandatory (§6.2). | | Space-handle identity and caching | **Corrected** (P1) | `space(id)` returning a stable handle is bounded by the LRU behind it, not guaranteed. In-memory tombstones and the filename index are therefore adapter-local caches, never durable state (§12.1.1, §12.2.4). | | Backend selection scope | Open | Process-global today because the profile is read from env. Per-Workspace or per-Space selection has not been fixed. | -| Logical filesystem view | Open | A possible `SpaceFileView` above both stores; name and contract are not accepted yet. | +| Unified per-Space handle | **Settled direction** | One composition-layer `space(canvasId)` vends every storage capability for one Space: its structured parts, its blob scope, and any backend-specific extras. The two ports stay independent and are joined above them, never inside one (§6.4.1). | +| Storage for owners outside this port | **Settled direction** | The Space handle vends an isolated _substrate_ per namespace — a directory, a table prefix, a schema — and the owner brings its own store implementation and SQL. Storage owns namespace isolation and lifecycle only, and never sees the data (§6.4.4). | +| Disposition of residual per-Space files | **Settled direction** | Four outcomes, not one: Disk-only and unimplemented elsewhere; portable capability with a per-backend implementation; a structured record; or a blob. The withdrawn Phase 4.6 attempt applied the first of the four to all of them; §6.4.3 assigns each family individually and §12.6 builds them (§6.4.2). | +| Logical filesystem view | Open | A possible `SpaceFileView` above both stores; name and contract are not accepted yet. §6.4 narrows what such a view would have to synthesize — named files become blobs — without deciding to build it. | | Real agent workspace | Open | Materialized directory, OS mount, protocol-only access, or a combination remain under evaluation. | | Agent-authored filesystem write-back | Open | Read-only projection, explicit checkout/commit, and live bidirectional sync are alternatives, not decisions. | @@ -204,6 +224,11 @@ remains the sole owner of its existing `ThreadStore`, `EventLogStore`, and backend family and inject matching adapters into both domains, but it must not move L2 persistence ownership back into `CanvasStore`. +The one thing this port offers another domain is a _connection point_ +(§6.4.4): an isolated substrate per namespace — a directory, a table prefix, a +schema — that the owner builds its own store on. Storage never sees the data, +so no ownership moves and no schema is shared. + Every current Canvas structured port is asynchronous so a synchronous Disk or SQLite implementation does not constrain Postgres or another remote adapter. The corresponding migration for Agenetes ports that are synchronous today @@ -316,6 +341,274 @@ rejects kinds that are named but not implemented, so an unsupported profile fails at startup with an actionable message rather than nondeterministically while serving data. +### 6.4 One Space handle, four dispositions — revised direction + +Phases 1–4.5 established the two ports and pulled storage-owned layout inside +the boundary. What no phase has settled is the residue: the per-Space state +that is still a file because it always was one, and the fact that reaching a +Space means calling two unrelated functions. This section settles both and +supersedes the single "Space materialization" framing of §12.5.4. Phase 4.6 +(§12.6) builds what it settles. + +Confidence: §6.4.1, the four-outcome test in §6.4.2, the assignments in +§6.4.3, and the opaque-state member in §6.4.4 are a **settled direction**. +What remains open is scheduling — which adapter pays for which move (§12.6, +§12.7) — and the concrete member names, which are still discussion aids. + +#### 6.4.1 One handle per Space + +A Space's durable state spans both ports — its record and nodes are +structured, its files are bytes — so the application should reach all of it +through one object, from one function, on the object that already holds both +ports: + +```ts +interface Storage { + readonly profile: StorageProfile; + readonly workspacePath: string; + readonly structured: StructuredStore; + readonly blobs: BlobStore; + space(canvasId: string): Space; +} +``` + +`Storage` is the composition root's own type — it is what `getStorage()` +already returns, and it is the only object in the process that holds both +ports, so it is where the two are allowed to meet. The barrel exports a free +`space(canvasId)` that is exactly `getStorage().space(canvasId)`, matching how +`canvasBlobs()` is already called; that is an ergonomic shorthand for the same +method, not a second design. + +`Space` is a **composition-layer facade, not a port type**. +`StructuredStore.space()` keeps returning the structured-only `SpaceHandle`, +`BlobStore.scope()` keeps returning a `BlobScope`, and neither port imports the +other (§6.3). They are joined in the layer that already owns every cross-store +rule: the blob-put precondition ("bytes only for a Space whose record exists") +and the blob-first delete saga. + +The join cannot move down into a port, for three separate reasons: + +- the two axes are configured independently, so a `SpaceHandle` that vended + blobs would oblige the Disk structured adapter to construct an Azure blob + scope; +- deletion ordering deliberately keeps remote blob I/O outside any database + transaction (§6.1); a handle owning both would move that ordering inside an + adapter; +- `BlobScopeRef` is a union whose only member is `canvas` today. Workspace + assets and agent scratch have no Space, and a blob store reachable only + through a Space handle could not serve them. + +What is wrong today is therefore only the spelling. +`getStructuredStore().space(id)` and `canvasBlobs(id)` are two entry points — +37 and 21 production call sites — that never say they address one Space. + +**Backend-specific members hang off the same handle.** A capability only some +backends implement is named for the backend that has it and typed by its +absence (`null`), not hidden behind a parallel free function and not present as +a stub that throws: + +```ts +interface Space { + readonly canvasId: string; + read(): Promise; + write(input: SpaceWriteInput): Promise; + readonly nodes: SpaceNodes; + readonly changes: SpaceChanges; + readonly tasks: SpaceTasks; + readonly events: SpaceEvents; + extension(namespace: string): SpaceSubstrate; // §6.4.4 + readonly blobs: BlobScope; + /** Disk's directory for this Space. `null` on every other backend. */ + readonly diskTree: DiskSpaceTree | null; +} +``` + +A caller branching on `null` is told the truth once; a caller that must +remember a second import is being asked to know the storage module's internal +topology. The withdrawn Phase 4.6 attempt exposed the Disk tree as a standalone +`diskSpaceTree()` free function to keep an unportable surface from looking +portable; the fence that actually does that work is the name and the enumerated +consumer list, and both survive the move onto the handle. + +#### 6.4.2 The disposition test + +§12.5.2 asked of a _symbol_: is it still useful, unchanged, when the structured +backend becomes SQLite? That question sorted `paths.ts`. Asked of a _consumer_ +it sorts the residual filesystem population — and it returns four answers, not +one: + +| Disposition | The question under SQLite | Consequence | +| ------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------- | +| **A. Disk-only** | Meaningless — the feature is _about_ a filesystem | Not implemented off Disk. The feature is **unavailable**, not emulated. | +| **B. Portable, re-implemented** | Survives; only the mechanism dies | A declared capability every backend answers in its own way. | +| **C. Structured record** | It was a record wearing a file's clothes | Moves to `StructuredStore`. | +| **D. Blob** | It is genuinely a named file | Moves to `BlobStore`. | + +The withdrawn Phase 4.6 attempt assigned **A** to all of them and offered one +route out: features stop needing a tree, and an agent reaches a Space over +Huabu's HTTP API. That is right for A and wrong for the other three. B, C, and +D do not need the HTTP API — they need a port, and routing them through an API +instead would leave the same state unportable behind a network hop. + +An outcome of A is an acceptable, stated product limitation, not debt. It +belongs in a capability matrix that `validateStorageProfile()` can consult, +alongside the existing rule that an unimplemented kind fails at startup. + +**A is the default answer, and the cheap one.** B, C, and D each cost a port +change, a contract suite, and a migration; A costs a row in the matrix. A +family earns B, C, or D by a product need that survives being told plainly +"this is not available on that backend" — not by being technically portable. +Where the two are close, take A and say so. A workaround that makes a feature +_nearly_ work on a backend is worse than its absence: it has to be built, +tested, and explained, and it hides the limitation instead of stating it. + +**Nothing here is built before a backend needs it.** Assigning a disposition +fixes the direction; it does not schedule the work. B and the open parts of C +and D land with the adapter that first requires them, so the second backend +pays for its own portability rather than Disk paying in advance for a +requirement nobody has stated. The exception is a move that simplifies Disk on +its own merits — deleting an ad-hoc file format in favour of a record the Space +handle already writes — which is worth doing whenever it comes up. + +#### 6.4.3 Inventory + +Every consumer that reaches a Space as a filesystem tree today, with its +disposition. Every assignment is settled; what is deferred is _when_ each is +built, not _where_ it goes — §12.6 builds most of them (§6.4.2, §12.7). + +| Consumer | What it does today | Disposition | +| ---------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET /:id/export` — Space bundle | `archiver.glob('**/*', {cwd: spaceDir, dot: true})` | **A**, accepted. A _portable_ export generated from records plus reachable blob references is a separate later design (§11); the current bundle is a Disk projection. | +| `POST /import` — Space bundle | Unzip into a staging dir, rename into place | **A**, accepted. Same pairing as export. | +| `POST /:id/reveal-nodes` | `openInFileManager(nodesDir)` | **A**, accepted. The feature _is_ "show me this in Finder". | +| Built-in `read`/`write`/`glob`/`grep` tools (`fs-sandbox`) | Space directory as the sandbox root | **A**, accepted. Off Disk the first-party agent uses RFS/HTTP, which is what external agents already use (§9). | +| `import-node-src` artifact-ref classification | `path.relative(spaceDir, abs)` | **A**, accepted — and it should ask `fs-sandbox` for its root, not storage. Not a storage consumer at all. | +| Windows directory-handle coordination | `registerHandleOwner` around `fs.watch` handles | **A**, accepted. Exists so a directory rename can succeed; no directory, no problem. | +| External-note observation and claim | `fs.watch` on `nodes/`, then read + unlink | **A** as a product feature; **B** for the notification underneath it. Nothing is built until a second backend exists — see below. | +| RFS path → node resolution | `nodeIdForPath('nodes/Foo.md')` inverts Disk's filename | **B**, deferred. Every backend can mint `nodes/