feat(storage): multi-backend storage phase 4.6 — one Space handle, backend-neutral application, and Workspace mount - #125
Draft
Yuge Zhang (ultmaster) wants to merge 20 commits into
Conversation
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) <noreply@anthropic.com>
…strap `SpaceNodes` had one read — by id — so every reader wanting more than one node had to go around the port to the legacy Disk store. Add the three shapes the application actually asks for: `readMany` for a named selection, `list` for work that genuinely spans the Space, and `stream` for a reader that can show partial results while the rest arrives. `readMany` is the one that matters for cost. Most readers want a handful of named nodes — a selection to describe, a neighbourhood to render, one View to serve — and expressing those as a whole-Space scan makes an unrelated node somewhere else part of the bill. Disk resolves each id through the same strict read `read()` uses, so a selection sees exactly what reading each id would, including the index rebuild that finds an externally renamed sidecar. `SpaceRepository.ensureWorld()` is the backend-neutral bootstrap hook. Every backend meets an empty namespace the first time it is mounted, and a Workspace with no World has no Portal target, so ensuring one cannot stay a Disk step run before the store exists. It delegates to the same idempotent Disk primitive Workspace preparation calls — one writer for one file, since the legacy preparation path still runs before the mount. The node contract now asserts the four read shapes never disagree about a node: an adapter whose scan parsed more leniently than its single read, or minted a different revision, would pass a suite written against one shape alone. The Space-collection contract covers both bootstrap branches, which needs a harness that can open a namespace nobody has mounted yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reaching a Space meant calling two unrelated functions that never said they
addressed the same thing: `getStructuredStore().space(id)` for its record and
nodes, `canvasBlobs(id)` for its bytes, `spaceDirectory(id)` for its
directory. Three entry points, one subject.
`Storage.space(canvasId)` joins them, with a free `space()` shorthand on the
barrel. It is a composition-layer facade, not a port type: `StructuredStore`
and `BlobStore` keep their interfaces and their independence, and they are
joined in the layer that already owns every cross-store rule — the blob-put
precondition and the blob-first delete saga. The join cannot move down into a
port, because the two axes are configured independently, deletion ordering
deliberately keeps blob I/O outside any database transaction, and blob scopes
exist that have no Space at all.
The Disk directory becomes `diskTree`, typed by its absence rather than
hidden behind a parallel import or stubbed to throw. A caller branching on
`null` is told the truth once. It stays unportable and stays out of `ports/`:
a backend that keeps Spaces in tables has no tree, and promising one would
mean fabricating it. The fence is the Disk name plus the census in
`module-boundaries.test.ts`, which may shrink and must not grow — every entry
is a family §6.4.3 assigns a disposition.
Two things fall out. `import-node-src` asked storage where a Space was in
order to classify a path it had already resolved in sandbox coordinates; it
now asks `fs-sandbox` for its own root, which is not a storage question at
all. And `space()` composes from the receiver rather than a captured local,
so `{...storage, blobs: fake}` — the obvious way to stub one axis, and what
the artifact tests already do — gets Spaces built on the store it substituted
instead of silently keeping the original.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The thread resolver and RFS node lookup reached the legacy Disk store for both the Space record and node content. Both now go through `space(canvasId)`, which makes the resolver chain async up to its already- async callers: the RFS route, the agent route, and the interactive-view owner-thread check. RFS's file → node mapping does not become portable, and should not. Every backend could mint `nodes/<label>.md` from records, but Disk inverts the *real* filename, because the file is really there and a user may have renamed it; the two agree only when nothing has touched the directory from outside, which is the one case Disk cannot assume. So it moves onto `diskTree.nodeIdForPath()` — named for the backend that has it, and listed in the census as disposition B, deferred (§6.4.3). The record and the sidecar around it come from the ports. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… ports The World resolvers, the spatial queries, and the prompt assemblers all reached the legacy synchronous Disk store. They now go through `space(canvasId)`, which makes each entry point async up to its already-async caller: the space-query facade, the agent tool executor, and the chat envelope builder. Three things worth calling out beyond the mechanical move. `node-prompt` stops taking a `CanvasStore`. It is a pure merge — the caller's authored fields win, the stored record fills the gaps — and it took a store only so it could lazily read a record the caller had not supplied. That made a pure function into a synchronous Disk dependency no async port can satisfy. The record is now an argument, so each caller reads what it needs in the shape that suits its request: `readMany` for a selection the wire already named, `list` where the work genuinely spans the Space (the outline describes every node, inspect filters over all of them). `readWorldTargetCanvasesStrict` walked the Workspace itself, reading `space.json` files, which made a reference resolver a consumer of the Disk record layout and cost it the whole Workspace per call. It now reads the ids it was asked for. Its duplicate-topology check moves to where duplicates are actually observable — the Disk directory scan, which resolved two directories claiming one `canvasId` last-wins and now raises. A Finder-side duplication becomes a loud failure of every catalogue read instead of a Space that silently resolves to an arbitrary copy. One behaviour change, deliberately. World reference resolution read source nodes strictly and rejected on malformed frontmatter, so one hand-edited file made the entire World view 500. The port's documented stance is the opposite and has been since phase 4: broken frontmatter is not a read failure, because a node whose YAML a user broke must stay repairable through the content PUT. The resolver now follows the port, and the test says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Agent Node placement, Task run launching, the SSE snapshot, web previews, Portal-Pin routing, snapshot rendering, Interactive Views, the executor prestate, external-note filtering, and the built-in file tools all read through `space(canvasId)` now. Only `canvas.route.ts` and the search adapter still hold a legacy store. Each read is expressed in the shape its request actually has, rather than whatever the synchronous store made convenient: - Executor prestate hydration and Interactive View listing scan the Space, because they genuinely span it. - Portal-Pin routing pre-reads exactly the source Spaces the command references. The set is knowable without running the passes — a source is either named by an update or referenced by a World node — so the memoized lazy reads become one batch and the passes stay synchronous. - Snapshot rendering, `fs-read`, and Interactive View `get` read one node. `importForeignNodeSources` stops taking a `CanvasStore` altogether: it used it for one topology read and for `store.canvasId`, both of which it already had as an argument. Two places needed care rather than a mechanical await. The external-note snapshot both reads and prunes pending items, and the caller depends on registering its listener and taking the snapshot with no await between them, so the known-node set is now read before that block and passed in. The built-in file tools' node lookup reads its Space record up front for the same reason — the lookup itself is called synchronously from the search passes, and only that half crosses the port. Its directory scan stays put: those tools are Disk-only, and mapping real filenames to records is what they are. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Space GET, single-node content, search, export, reveal, and the preprocess guard were the last production readers on the legacy Disk store. With them on `space(canvasId)`, `storage/canvas-store.js` has no importers left and is deleted — the first of the three Phase-4.5 forwarding shims to go entirely, rather than just shrink. Search moves to `SpaceNodes.stream`, which is what that member existed for: it emits meta-tier matches as each record lands instead of waiting for the whole scan, and the map it returns still feeds the content tier with no second read. Two Disk facts that were reached through the legacy store move onto `diskTree` rather than disappearing: - Duplicate sidecars. Only a filesystem can have two files claiming one node id, and the read path deliberately reports it as a non-blocking hint while a write hard-fails — a user who broke it by hand needs to see the node in order to fix it. `hydrateOneNode` now takes the record and the duplicate list as arguments and reaches nothing itself. - The explicit `revalidateNodeForRead` before a single-node read is gone: the port's own `read` already reconciles the adapter's cached index before answering, so the route was asking for something it now gets by default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Storage had no idea which Workspace it belonged to. The connections were built once, and only the handles derived from them carried a Workspace and rejected after a switch. That works while every adapter resolves paths lazily from module state; it does not survive a backend that opens a connection, because there is nothing to open it against and nothing to close when the Workspace moves. `Storage` now carries the Workspace it was opened for, and activation is a staged mount: prepare (child process) → stage connections + ensureWorld → publish Everything that can fail happens in the stage, while the previous Workspace and its connections are still serving: an unimplemented backend, a connection that will not open, a namespace whose World is malformed. Only `commit()` makes the new mount reachable, and it takes the caller's own publish step — for a switch, making the path active — so the path and the mount that serves it change in one synchronous block. A request cannot land in a gap where one moved and the other did not. That ordering is why the Disk connection is constructed with an explicit path: `ensureWorld()` runs before the Workspace is active, so it has to bootstrap the one being activated rather than the one being replaced. Writing the swap test surfaced a leak worth stating: committing a Workspace path detaches the mount that no longer describes it, so a `commit()` reading the holder at swap time would find nothing to close and drop the previous connections on the floor. The staged mount captures what it replaces when it is staged, not when it is published. Free mode gets the case it always needed: startup validates the profile without opening anything when no Workspace has been chosen yet, instead of requiring one to exist. And `app.close()` now closes the mount — Disk has nothing to release, but a mount outliving the process that owned it is exactly the leak that only shows up under the backend nobody has written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two families were files only because they always had been: memory-worker bookkeeping in `.memory/state.json`, and the debug prompt log under `.history/chat/`. Neither is a Space record and neither is a blob — they are some other module's own state, which happened to live next to storage's. The obvious repair is a port member per feature (`memory`, `promptLogs`), which obliges every future backend to model data it has no stake in — the thing §12.5.7 rejected when it declined a `SpaceChats.list()` port. The next-obvious one is a namespaced key/value member, and it is wrong for a reason worth recording: it fixes one access shape — whole-value rewrite, no queries, no indexes — for every owner forever, and an owner with real query needs then encodes its own index inside an opaque value. So `SpaceHandle.extension(namespace)` hands over a connection point and nothing else: a reserved directory on Disk today, a table prefix or schema when those adapters exist. The owner brings its own store and its own queries. Storage keeps lifecycle, because only it can — a namespace is created on demand and destroyed with the Space, which keeps `beginDelete()` whole without any owner registering a cleanup hook. The substrate returns null for a Space that does not exist, and that is load-bearing rather than defensive. The memory trigger carried its own resurrection guard: its op-counter hook fires *after* a delete has removed the Space, and a bare write would recreate the directory as a stub holding nothing but bookkeeping. Every owner writing through an ad-hoc path needed that guard. Refusing a substrate for a Space that is gone states it once, in the one place that can, and the guard is deleted rather than moved. The namespace grammar is narrower than it looks like it needs to be, and the reasons are recorded where it is defined: an owner prefix is required because storage cannot arbitrate a collision it never sees the data behind, and `_` is reserved because a backend keyed on identifiers has to fold the dots into something legal and the obvious fold is `_`. The contract asserts isolation and lifecycle only — there is no data behaviour to assert about data the port cannot read — so the harness supplies the read and write, and the suite says what must be true of whatever an owner stored. Destruction is checked by recreating the Space under the same id, because on Disk it falls out of placement and on another backend it will not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`BlobScopeRef` had one kind, mapped onto `.artifacts/`, so anything else a Space holds as bytes stayed a bare `readFileSync` against a path the caller assembled: the RFS access guide at the Space root, the agent's memory body, the upload scratch. Naming one blob `skill.md` would have put it in a hidden directory rather than where a user authors it, and the alternative — hierarchical blob names — §7.1 excluded for every backend. So there is now one kind per **user-visible area**: artifacts, guide, memory, upload. A kind is a union member and one placement rule per adapter; a path separator inside a name would have been a contract change. It also lets retention diverge later — scratch is not an artifact — without moving bytes a second time. The guide scope is the interesting one. Its area *is* the Space root, which also holds `space.json` and every node directory, so a scope that claimed the folder would list storage's own records and delete the Space on `deleteAll()`. It is therefore bounded by its member names instead — a fixed set is a tighter namespace than a directory, not a looser one — and the adapter refuses a name the scope does not own before it reaches the filesystem. The contract pins both halves, because "answers for the folder" is the shape an adapter would naturally reach for. Deletion now sweeps every per-Space scope, derived from one list so the saga cannot fall behind the union. On Disk the extra sweeps are redundant with removing the directory; on a backend where dropping the record does not remove the area the bytes sit in, an unswept kind is an orphan. Two consumers changed shape rather than just changing calls. The memory writers took an absolute path and a parent directory, which stopped being expressible once one of their three tiers was a blob and the other two were still Workspace-scoped files — so they take a document instead, and every rule about content above it stopped caring which. And the memory body's sandbox resolver is gone: the port's own precondition already refuses a write to a Space whose record does not exist, which is what that resolver's directory check was approximating. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…not be Six features are about a filesystem rather than about storage: revealing a Space in Finder, adopting a document dropped in from outside, a bundle that is a directory zipped up, the built-in file tools, and the Windows handle dance that lets a watched directory be renamed. A backend keeping Spaces in tables has no honest answer for any of them. Until now that was only knowable by reading the code, or by clicking the button. An outcome of "unavailable" is an acceptable product limitation rather than debt — but only if an operator can learn it when they select a profile, so the matrix sits beside profile validation and is reported at startup. Reported, not raised. A profile that offers fewer features is a stated limitation; a profile naming a backend with no adapter is a misconfiguration and still fails fast. Conflating the two would either refuse a legitimate deployment or let a real misconfiguration through as a warning. The matrix is an exception list, not an inventory. Listing the portable features too would mean editing it whenever anything was built, and it would go stale silently — so an id nobody wrote down is available by construction, and adding a portable feature is not a matrix edit. Each listed feature still refuses at its own call site, because a matrix nobody consults at runtime is documentation. Those refusals now share the matrix's wording, so the sentence an operator read when they chose the profile is the sentence they see in the failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 4.6's criterion is about code that does not exist yet: adding another `StructuredStore` must not require feature modules to learn its record layout. No suite can assert that directly, so this asserts the observable half — every durable thing the product does with a Space, driven through the portable surface, against a profile mounted the way a Server mounts one. `storage/testing.ts` does the mounting, and does it through the production lifecycle rather than by swapping in a stub. The distinction is the whole point: a suite over a stub proves the application talks to an interface, while this one proves a backend serves the product. A backend whose mount is broken now fails in the harness instead of surfacing later as a confusing product failure. Phase 5 adds SQLite to `PRODUCT_STORAGE_PROFILES` and every case runs against it unchanged. That only holds while the suite stays ignorant of the backend, so `module-boundaries.test.ts` reads its source and rejects Disk vocabulary and any import outside the portable surface. The failure mode this guards is not malice — it is a helpful-looking `existsSync` someone adds to make a case more specific, which would keep passing for Disk and fail for the first backend that has no directory. Writing it surfaced one trap already documented one layer down: a change record with an empty `revertDeltas` is silently dropped by `coalesceChanges`, so a hand-rolled fixture asserts nothing. The log contract had hit this and said so; the product suite builds its record through the engine for the same reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The architecture doc now describes what a reader will find: one `space(canvasId)` handle over both ports, a mount that belongs to one Workspace and is staged before it is published, one blob scope per user-visible area, an extension substrate that lends a place rather than a data API, and a capability matrix for the features that are about a filesystem rather than about storage. The proposal marks the phase implemented and carries four findings that came out of building rather than reading, because each is the kind of thing that would otherwise be rediscovered by the next adapter: - a pure merge cannot hold a lazy store read across an async port; - a staged mount must capture what it replaces when it is staged, not when it swaps, or committing the Workspace path leaves the previous connections unclosed; - a blob scope whose area is the Space root has to be bounded by its member names, since the folder holds storage's own records; - the resurrection guard several owners each carried belongs in the port, as a substrate that does not exist for a Space that does not exist. One item is recorded as deliberately narrowed. Upload scratch got its scope kind — named, swept on delete, free to diverge in retention — but its writers still reach it through the sandbox path, because RFS upload is a streaming handler and path classification rather than the bare file read the "simplifies Disk on its own merits" exception describes. It moves with RFS's path vocabulary, not before. Phase 5's owed list is rewritten against what actually landed. The honest measure of this phase is that all of it is additive: nothing SQLite owes asks a feature module to change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ndary The exit criterion says no production module outside `storage/` may name how a backend stores a Space. Three still did, and each was a different kind of leak rather than an oversight. The external-note watcher read the Space record off the file beside `nodes/`. That path read was equivalent only because Disk keeps the two together, and "what does this Space contain" is a question every backend answers — so it goes through the port. Reveal-nodes assembled the sidecar directory itself; the Disk capability now answers "which folder holds the notes", which is what having one owner for the layout is for. Bundle import was the real one. It owned the staging location, the title-derived directory name, the record filename, and the directory index entry — all four are placement, and none of them are the `.huabu.zip` format the route legitimately interprets. `stageSpaceImport` takes them, and the route keeps unzipping, the manifest, and artifact-URL remapping. Splitting it surfaced something worth naming: the bundle's record filename and Disk's record filename are the same string for a historical reason, not a shared one. One is a wire format frozen by every bundle already exported; the other is how a backend files a record today. They are separate constants now, because they drift the moment a backend that is not Disk exports a bundle. With those gone, `storage/paths.js` has one production importer left and it is a migration — exempt by construction, since rewriting a frozen historical shape is the one legitimate reason to know a layout that is no longer current. The guard the criterion asks for is now writable as specified: import-level, repo-wide, migrations and tests exempt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two capabilities are reachable by name from outside the boundary — the Space directory and bundle publication — and one guard keeps that list from growing. Both were missing from the architecture doc's account of what a reader will find. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`StructuredStore` vended a handle per Space while `BlobStore` made the caller assemble a `BlobScopeRef` first — the same subject reached two different ways, and the asymmetry showed up in the facade, where four scope refs were built by hand beside the structured handle. `BlobStore.space(canvasId)` now returns `SpaceBlobs`: one member per user-visible area, named rather than tagged. The areas are what the union's `kind`s already were, so nothing about placement, name bounding, or the delete sweep changes — only how a caller says which one it means. `Space` composes the two handles flat, so every durable part of a Space sits at one level and which axis stores it stays this module's business. The artifact area loses its unqualified spelling: `space(id).blobs` became `space(id).artifacts`, beside `.guide`, `.memory`, and `.uploads`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ufq2A7GReTSpRdtApkpdNZ
A mount belonged to a Workspace, so storage carried `workspacePath` and a staged-swap lifecycle — connections opened against a not-yet-active path, an atomic publish, a captured mount to close afterwards, leases to stop an operation being stranded mid-switch, and a stale-mount check on every access. All of it existed to move a live process from workspace A to B. Issue microsoft#126 says that is not a product requirement, and the price is visible: every layer had to answer "which workspace am I looking at?" before it could do its job, and a backend with no workspace at all — Postgres plus Azure Blob — would have had to answer it too. So the workspace is now fixed for a process. Storage mounts once, serves, and closes: no `workspacePath`, no `stageStorage`/`StagedStorage`, no stale-mount recovery, no operation lease, and the blob/structured admission gate keys on the Space alone. Choosing a different folder saves the choice and restarts — the desktop shell does it, since it owns both `workspace.json` and the server child; a browser tab says so instead. The shell hands its choice to the server as `HUABU_WORKSPACE_STARTUP`, deliberately not `HUABU_WORKSPACE`: the operator's variable locks the workspace, hides the path, and fails the boot when the folder cannot be opened, which is right for a deployment and wrong for a user whose drive is unplugged. A shell-chosen workspace that will not open leaves the process unconfigured with the reason in `WorkspaceInfo.startupError`, so the client shows the picker rather than the app being unusable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ufq2A7GReTSpRdtApkpdNZ
Two designs the code no longer has: a blob port reached through a scope descriptor, and a storage lifecycle built to move a live process between Workspaces. The proposal now says why the second one went — issue microsoft#126, and the staged-mount finding that reads as a bug report against it — rather than leaving §12.6.5 specifying machinery nothing implements. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ufq2A7GReTSpRdtApkpdNZ
Every Disk handle, repository, blob scope, and log/task/node writer captured the workspace active when it was built and refused to run once the active one moved. The registries did the same by other means: the `CanvasStore` LRU self-detected a switch and cleared itself, and its key — along with the node tombstone map's and the Task mutation mutex's — carried a workspace path beside the Space id. All of it answered a question a process can no longer ask. The workspace is fixed for a process lifetime, so a Space id already denotes one Space, and a handle can only have been built against the workspace being served. The guards are gone, the keys are Space ids, and the one thing that was genuinely load-bearing is now explicit: committing a workspace drops the mount, the instance cache, and the node fences together, which is what keeps a test moving through several temporary workspaces from reading a stale one. The blob adapter keeps resolving each operation's directory once before its first await — that was never about workspaces. It stops a Space directory renamed mid-write from landing the temp file in one place and the destination in another, so its test now says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ufq2A7GReTSpRdtApkpdNZ
…ailed The server reports why it could not open the workspace it was started on, and the client showed that reason — then fell straight through to activating the same path again, because an unconfigured free-mode server has always meant "try the remembered folder". On an unavailable drive that is a second 70-second wait after the first one already failed, and it ends by dropping the saved path so a drive that comes back is no longer remembered. A reported startup failure is the answer to that question, so stop asking it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ufq2A7GReTSpRdtApkpdNZ
This was referenced Aug 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes #114, which is withdrawn. That attempt 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.The proposal was reworked in the meantime (§6.4), and this branch starts fresh from
mainwith that direction respecified as a single Phase 4.6 — 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.No SQLite, Postgres, or Azure adapter here. No schema, no migration, no profile-selectability branch.
Exit criterion
Neutrality. Adding another
StructuredStorechanges adapter, composition, and migration code, but does not require Canvas, agent, web, RFS, interactive-view, Task, or Workspace feature modules to learn that backend's record layout.getCanvasStore, down from 21 files and 68 call sites.storage/canvas-store.jsreached zero importers and is deleted — the first Phase-4.5 forwarding shim to go entirely rather than shrink.storage/paths.jshas one production importer left and it is a migration, exempt by construction.storage/.One handle. Every storage capability for one Space is reached through one
space(canvasId)handle, and every family that is still a bare file is one the capability matrix declares Disk-only.What is in it
SpaceNodesgainsreadMany/list/stream.readManyis the one that matters for cost: most readers want a handful of named nodes, and expressing that as a whole-Space scan bills you for unrelated ones.Storage.space(canvasId)joins the structured handle, four blob scopes, the extension substrate, and — where the backend has one —diskTree. A composition-layer facade; both ports keep their independence.extension(namespace)hands over a connection point and nothing else. Memory-worker bookkeeping and the debug prompt log are the first two namespaces.BlobScopeRefkind per user-visible area — artifacts, guide, memory, upload — so the Disk paths a user sees are unchanged and retention can diverge later without moving bytes again.storage/testing.tsmounts a real profile through the production lifecycle;product-boundary.test.tsruns the criterion against every profile inPRODUCT_STORAGE_PROFILES. Phase 5 adds one entry and it all runs against SQLite unchanged.Five things found by building rather than reading
Each is recorded in the proposal, because each is the kind of thing the next adapter would otherwise rediscover.
node-prompttook aCanvasStoresolely to read a record its caller had not supplied. Making the record an argument is what madereadManyearn its place rather than merely have one.commit()reading the holder at swap time finds nothing to close. Invisible on Disk, whoseclose()is a no-op; a connection leak the moment SQLite lands. The swap test caught it.space.jsonand delete the Space ondeleteAll().space-guideis bounded by its member names instead — a fixed set is a tighter namespace than a directory, not a looser one.extension()returningnullfor an absent Space states it once; the per-owner guards are deleted rather than moved.Deliberately narrowed, and said so
fs-sandboxpath. RFS upload is a streaming HTTP handler plus path classification, not the barereadFileSync/writeFilethe "simplifies Disk on its own merits" exception describes, and routing it through the port would add machinery rather than retire any. It moves with RFS's path vocabulary (disposition B).Behaviour changes
canvasIdresolved last-wins; they now raise from the directory scan, so a Finder-side duplication is a loud failure of every catalogue read rather than a Space that silently resolves to an arbitrary copy.Verification
pnpm run checkpasses end to end — lint, format, typecheck, 1068 server tests, 990 web, the agenetes packages, i18n, skills, license headers.What Phase 5 owes
Rewritten against what landed, in §12.7. The honest measure of this phase is that all of it is additive: nothing on SQLite's list asks a feature module to change.