Skip to content

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
microsoft:mainfrom
ultmaster:feat/multi-backend-storage-phase-4-6-rework
Draft

feat(storage): multi-backend storage phase 4.6 — one Space handle, backend-neutral application, and Workspace mount#125
Yuge Zhang (ultmaster) wants to merge 20 commits into
microsoft:mainfrom
ultmaster:feat/multi-backend-storage-phase-4-6-rework

Conversation

@ultmaster

Copy link
Copy Markdown
Contributor

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 main with 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 StructuredStore changes 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.

  • Zero production files reach getCanvasStore, down from 21 files and 68 call sites.
  • storage/canvas-store.js reached zero importers and is deleted — the first Phase-4.5 forwarding shim to go entirely rather than shrink. storage/paths.js has one production importer left and it is a migration, exempt by construction.
  • A repo-wide, import-level guard rejects any Disk layout symbol outside 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

Portable reads SpaceNodes gains readMany / list / stream. readMany is 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.
One Space handle 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.
Workspace mount Activation stages connections and bootstraps the World before publishing the path and the mount together, in one synchronous block. Everything that can fail happens while the previous Workspace is still serving.
Extension substrate extension(namespace) hands over a connection point and nothing else. Memory-worker bookkeeping and the debug prompt log are the first two namespaces.
Blob scopes per area One BlobScopeRef kind 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.
Capability matrix Six features that are about a filesystem are declared unavailable off Disk, reported at startup, and refuse at their own call sites with the same wording.
Proof storage/testing.ts mounts a real profile through the production lifecycle; product-boundary.test.ts runs the criterion against every profile in PRODUCT_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.

  1. A pure merge cannot hold a lazy store read across an async port. node-prompt took a CanvasStore solely to read a record its caller had not supplied. Making the record an argument is what made readMany earn its place rather than merely have one.
  2. A staged mount must capture what it replaces when it is staged, not when it swaps. Committing a Workspace path detaches the mount that no longer describes it, so a commit() reading the holder at swap time finds nothing to close. Invisible on Disk, whose close() is a no-op; a connection leak the moment SQLite lands. The swap test caught it.
  3. A blob scope whose area is the Space root cannot be a directory scope. It would list space.json and delete the Space on deleteAll(). space-guide is bounded by its member names instead — a fixed set is a tighter namespace than a directory, not a looser one.
  4. The resurrection guard belonged in the port. Several owners each checked that a Space directory still existed before writing ad-hoc bookkeeping into it. extension() returning null for an absent Space states it once; the per-owner guards are deleted rather than moved.
  5. A 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. Separate constants now — they drift the first time a non-Disk backend exports a bundle.

Deliberately narrowed, and said so

  • Upload scratch got its scope kind — named, swept on delete, free to diverge in retention — but its writers still reach it through the fs-sandbox path. RFS upload is a streaming HTTP handler plus path classification, not the bare readFileSync/writeFile the "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).
  • No migration for memory-worker state. It resets on upgrade; the module's own docs say losing it costs at most one analysis pass.

Behaviour changes

  • 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 since phase 4 is the opposite — broken frontmatter is not a read failure, because a node whose YAML a user broke must stay repairable. The resolver follows the port now.
  • Two Space directories carrying the same canvasId resolved 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 check passes 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.

Yuge Zhang (ultmaster) and others added 20 commits August 19, 2026 15:15
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant