Skip to content

feat(storage): make application reads backend-agnostic (phase 4.6) - #114

Closed
Yuge Zhang (ultmaster) wants to merge 6 commits into
microsoft:mainfrom
ultmaster:feat/multi-backend-storage-phase-4-6-backend-agnostic
Closed

feat(storage): make application reads backend-agnostic (phase 4.6)#114
Yuge Zhang (ultmaster) wants to merge 6 commits into
microsoft:mainfrom
ultmaster:feat/multi-backend-storage-phase-4-6-backend-agnostic

Conversation

@ultmaster

Copy link
Copy Markdown
Contributor

Summary

  • complete the backend-neutral structured read surface with Space node list/stream APIs and Canvas-owned read adapters
  • move Canvas, agent, World, RFS, web, interactive-view, and task consumers off the Disk-owned CanvasStore and layout helpers
  • add the explicit SpaceFiles materialization capability plus staged Workspace storage mounts, backend-neutral World bootstrap, and graceful connection shutdown
  • document Phase 4.6 and enforce the boundary with contract and module-import tests

Scope

This is the application-neutrality prerequisite for the SQLite preview in #92. It deliberately contains no SQLite adapter, schema, migration, or selectable SQLite profile.

Verification

  • pnpm format:check
  • pnpm lint (0 errors; existing warnings only)
  • pnpm typecheck
  • pnpm -r --if-present --no-bail run test
  • pnpm run build
  • node scripts/check-headers.mjs

@ultmaster

Copy link
Copy Markdown
Contributor Author

Adversarial review — Phase 4.6

Reviewed df9b23bf against §12.6 of docs/proposals/multi-backend-storage.md (the plan and the implementation land in the same commit; there is no separate planning commit as there was for 4.5 in d9b428f8).

Verdict: the phase does what §12.6 set out to do, with one regression that must not ship, one in-scope item undelivered, and two design costs worth a decision. One must-fix and five smaller findings are fixed in the follow-up commit; the two design questions are left for you.

Baseline on df9b23bf: tsc --noEmit clean · 119 files / 1007 tests pass · prettier --check clean · eslint 13 warnings, all pre-existing.


1. Plan coverage

§12.6 Status
12.6.1 SpaceNodes.list()/stream(); every production read via StructuredStore canvas/space-read.ts is the read model; no production getCanvasStore consumer remains
12.6.1 CanvasStore confined to adapter + compat tests, guarded ✅ the barrel-import, backends/, and compatibility/ rules together close every path
12.6.1 Pure Canvas context builders take pre-read values ✅ the cleanest part of the change — describeNode/nodeLabel lost their store parameter outright
12.6.2 One SpaceFiles capability for file-native features ⚠️ port exists and consumers use it, but it is not backend-selectable — F6
12.6.3 Staged mount, ensureWorld(), atomic swap, shutdown close ✅ with two good tests in workspace-activation.test.ts
12.6.4 Contracts cover list/stream equivalence + idempotent World bootstrap ⚠️ list/stream ✅; ensureWorld covered only for an existing World — the create-when-new branch was Disk-only
12.6.4 Guard rejects prod CanvasStore and non-storage Disk-layout imports ⚠️ the Disk-layout-symbol rule is scoped to modules/workspace*, not all non-storage production code. No violations exist today (grepped), but the guard is narrower than the plan
12.6.4 "the shared product-level backend harness needed for Phase 5" (listed in scope) not delivered — no new harness; setStorageForTesting is pre-existing and space-files.test.ts is Disk-specific

Correctly out of scope: no SQLite, no profile branch, no wire change — the diff touches zero files under packages/shared or apps/web.


2. Findings

F1 — duplicateNodeFiles() on every node read and write · must fix · fixed

space-nodes.ts called store.duplicateNodeFiles(nodeId) unconditionally in read(), list() (per node), stream() (twice per node) and put(). That method is readdirSync + readFileSync + frontmatter parse of every .md in the Space, per call. Its own docstring: "O(directory size); only called on the rare duplicate-resolution path … never on hot writes." The pre-4.6 hydrate path gated on store.isDuplicateNode() — a warm Set lookup — and only enumerated for nodes already flagged.

The route comment asserts the opposite of what the adapter does (canvas.route.ts:395): "the adapter attached this warning to the same node snapshot, so checking it adds no second storage read."

Measured, 300-node Space:

                     before      after
readAllNodes()       25.2 ms     27.0 ms   (baseline, unchanged)
SpaceNodes.list()  2101.0 ms     18.1 ms
SpaceNodes.stream()  ~2x list    19.1 ms
SpaceNodes.read(1)    7.2 ms      0.45 ms

~116x on list(), and it is synchronous — it stalls the event loop, not just the request. It sits on GET /api/canvas/:id, on executeOnServer/applyDeltasOnServer inside withCanvasMutex, on search, and on every single-node read.

Fixed by gating on the warm duplicate set (#duplicates()), plus a regression test that spies on duplicateNodeFiles and asserts a healthy Space never reaches it, and a test pinning that the warning itself still surfaces on list()/read().

F2 — whole-Space node reads for single-node work · not fixed — your call

Purifying describeNode was right, but callers compensated with readCanvasSnapshot (= nodes.list(), every node in the Space):

  • interactive-view.service.ts:193,219get(canvasId, nodeId) reads all nodes to serve one view; same on runtimeSnapshot
  • envelope.ts:213 — reads all nodes to describe a handful of selected ones
  • node-neighbourhood.ts:79 — all nodes for an ~8-node neighbourhood (previously memoized targeted reads)
  • snapshot-nodes.ts:866, canvas-spatial.ts:288,455
  • canvas-executor.ts:706,1126nodes.list() on every write, inside the canvas mutex

Even with F1 fixed this turns O(k) into O(N). A SpaceNodes.readMany(ids) member would keep the purity win without the amplification and maps to one WHERE id IN (…) on a SQL backend. Left alone because it is a port-shape decision, not a defect.

F3 — makeNodeLookup became eager · not fixed

fs-sandbox.ts:300 — was lazy behind CANVAS_NODE_RE, so a grep/find touching no nodes/*.md never paid the scan. It now unconditionally readdirSync + readFileSync-es every node file at the top of handleGrep/handleFind. Restoring laziness means an async memo; flagging rather than reshaping the helper mid-review.

F4 — readWorldTargetCanvasesStrict lost its strictness · fixed

The old reader threw WorldTargetAccessError on malformed topology (state.nodes/state.edges not arrays). The new one delegates to space(id).read(), which does not validate the state arrays (canvas-store.ts:357) — so malformed topology silently read as empty, and downstream (canvas.state.nodes ?? []) hid it. Restored, as a record-shape check rather than a filesystem one, so it survives a table-backed backend.

Related and not changed: the duplicate-canvasId check moved into scanWorkspace() (canvas-dirs.ts:129) as a plain Error. That is a blast-radius change — two Space folders sharing an id now make every catalogue read and canvasRoot() throw, where it previously degraded to last-wins. It reads deliberate and matches the adjacent World/index guard, but it is not called out in §12.6 and only one test covers it. Worth a line in the doc.

F5 — RFS re-derived node identity from Disk record shape · fixed

node-meta.ts replaced store.nodeIdForFilename() with "read the .md, parse frontmatter, take id:, else filename stem" — a feature module inferring the backend's record encoding, which §12.6.2 explicitly rules out. It also read the file twice.

Fixed by giving the materialization capability the mapping it already owns: SpaceFileScope.nodeIdForPath(relativePath). Disk answers from the sidecar index; an id-addressed projection answers from the name. This is the one fix that touches a port — flagging it in case you would rather solve it differently. It costs Phase 5 nothing, since the SQLite preview does not implement SpaceFiles.

F6 — SpaceFiles is declared but not selectable · not fixed — the real Phase 5 gap

  • ports/files.ts pins readonly kind: 'disk' as a literal
  • storage.ts:107 buildSpaceFiles() ignores profile and always returns DiskSpaceFiles
  • DiskSpaceFiles.directory()canvasRoot()canvasDirName() → the space.json-scanning index. Under a SQLite structured backend no space.json exists, so the index is empty; stageImport().publish() would still write a space.json and register a title-derived directory; and nothing would create the Space directory at all.

So "a later SQLite profile may compose a stable id-addressed materialization without changing feature modules" is currently true of the type, not of the composition. Fine as a staging move — but the doc claims more than the code delivers, and this is what still gates SQLite selectability.

F7 — dead surface · fixed

space-read.ts streamCanvasNodes was never called (canvas-search goes straight to space.nodes.stream(), bypassing the read model this commit introduced). SpaceFileScope.withHandlesReleased was never called — the Disk repository uses the module-level withSpaceDirHandlesReleased. Both removed.

F8 — activation ordering contradicts its own doc · fixed

workspace-activation.ts commits the Workspace path before staged.activate(). If activate() threw, abort() closed the new connections but the path stayed committed — against "A failed stage leaves the old Workspace and connections active." Unreachable today (files.activate()refreshCanvasDirIndex()scanned = false, which cannot throw), so latent rather than live. The commit has to lead, because activation refreshes process-global locators that only resolve once the path is committed — so the fix restores the previous path on failure instead of reordering.

F9 — stale doc link · fixed

canvas.route.ts:1551 still pointed at {@link nodesDir}, no longer reachable from that module.

F10 — stream() accepts strict and drops it · fixed (found while fixing F1)

streamAllNodes passes options?.strict to the file read but calls markdownToNodeContent(id, raw) without it, while readAllNodes passes it through. So list() parsed strictly and stream() did not — the two could return different records for a malformed node, directly violating the new "streams the same snapshot returned by list" contract. The contract test misses it because its fixtures are well-formed. Also hardened stream() to reuse the delivered snapshots for its returned map, since the adapter republishes its duplicate set only when the scan ends.

Contract gap · fixed

Added openUnbootstrapped() to the SpaceRepository harness and a case asserting ensureWorld() creates exactly one World in an empty namespace, is idempotent, and does not list it as an ordinary Space. This is the branch a new backend meets first, and it was previously Disk-only. Making it required means the SQLite adapter has to prove it too.


3. Interfaces changed

No wire/protocol change — nothing under packages/shared or apps/web, no status/schema/SSE change.

PortsSpaceNodes + list(), stream(); NodeSnapshot + warnings?; + NodeReadWarning. SpaceRepository + ensureWorld(). New ports/files.ts (SpaceFiles, SpaceFileScope, SpaceImportStaging, SpaceFileHandleOwner). SpaceDirHandleOwner.release/reacquire: voidPromise<void> | void.

Storage barrelgetWorldCanvasId/isWorldCanvasId/requireWorldCanvasId sync → async and re-homed from canvas-dirs to storage.ts; I audited every call site, all correctly awaited (TS would not have caught if (isWorldCanvasId(x))). Added getSpaceFiles, stageStorageForWorkspace, closeStorage, StagedStorageMount, StorageRuntime. Removed registerSpaceDirHandleOwner, withSpaceDirHandlesReleased, SpaceDirHandleOwner. initStorage() now returns StorageRuntime; Storage + workspacePath, files.

Dropping resetStorageCache() from workspace.route.ts and refreshCanvasDirIndex() from commitWorkspacePath() is safe — canvas-store-cache.ts:52 auto-detects the workspace change, and DiskSpaceFiles' constructor/activate() refresh the index.

Application, all sync → asyncdescribeNode(input, level, meta) / nodeLabel(meta) (store param dropped) · searchCanvas(space: SpaceHandle, …) · importForeignNodeSources(canvasId, cmds) · makeNodeLookup · lookupNodeByPath · getNodeNeighbourhood · resolveWorldReadCanvasId / readWorldTargetCanvasesStrict · the three assertWorldPortal* guards · AgentThreadResolver.*, agentThreadService.resolveFixedTarget/resolveExternalTarget · InteractiveViewService.list/get · resolveAgentNodePosition / resolveRootAgentPosition · new canvas/space-read.ts.


4. Ready for Phase 5?

Not as-is, but the remaining work is mechanical except F6.

feat/multi-backend-storage-phase-5-sqlite-preview (2bc7a6bb) is a sibling of this branch, not a descendant — both fork from cc8026d8. Rebasing it onto 4.6 will not compile:

  • SqliteSpaceRepository has worldId() but no ensureWorld()
  • SqliteSpaceNodes has read() but no list() / stream() (trivial in SQL — one SELECT)
  • StructuredBackendKind / profile.ts diverge (4.6 has 'disk'; the preview adds the AVAILABLE vs SELECTABLE split)
  • space-tasks.contract.ts exists only on the preview branch
  • both branches rewrote structured-store.ts, space-nodes.ts, space-repository.ts, module-boundaries.test.ts, storage.ts and the two contracts — expect real conflicts

Against the preview branch's own blocker list ("physical Disk reads, World bootstrap, Blob placement, import/export, and Workspace remounting still have one authority only in the Disk profile"): World bootstrap ✅, Workspace remounting ✅, physical reads and import/export behind a named capability ✅. Still open: F6SpaceFiles and blob placement resolve through the space.json-derived Disk index, so a SQLite profile has no materialization story — and the missing product-level harness.


5. What the follow-up commit changes

F1 F4 F5 F7 F8 F9 F10 + the ensureWorld contract gap. 12 files, +171/−57.

After: tsc --noEmit clean · 119 files / 1010 tests pass · prettier --check clean · eslint 6 warnings, all pre-existing.

Left for you: F2 (readMany vs whole-Space reads), F3 (eager makeNodeLookup), F6 (SpaceFiles selectability), the undelivered product-level harness, widening the Disk-layout-symbol guard beyond modules/workspace*, and documenting the new workspace-wide duplicate-canvasId throw.

Yuge Zhang (ultmaster) and others added 2 commits August 18, 2026 17:42
The node adapter asked the Disk store to enumerate every physical name
claiming an id on every read, list, stream and write. That enumeration
opens and parses every file in the Space, so a whole-Space list became
quadratic and synchronous: 2101ms against 25ms for the scan it wraps, on
a 300-node Space, blocking the event loop rather than one request. The
warm duplicate set already answers the question, so ask it first and
enumerate only for an id it flags.

Streaming took `strict` and dropped it on the parse while `list` passed
it through, so the two could return different records for a malformed
node — the equivalence the new contract asserts, missed because its
fixtures are well-formed. The delivered snapshots now also back the
returned map, since the adapter republishes its duplicate set only once
the scan ends.

`readWorldTargetCanvasesStrict` lost the check its name is about when it
moved onto the port: malformed topology read as empty instead of
refusing. It is restored as a record-shape check, so it survives a
backend that keeps topology in tables.

RFS had started deriving a node's identity from the markdown's own
frontmatter, which is this backend's record encoding leaking back into a
feature module. The materialization capability owns that mapping, so it
answers it: Disk from the sidecar index, an id-addressed projection from
the name.

Also: put the previous Workspace path back when a mount swap fails (the
commit has to lead, because activation refreshes locators that only
resolve once the path is committed); cover the World bootstrap branch a
new backend meets first — an empty namespace — in the reusable contract
rather than only on Disk; and drop two members nothing called.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three gaps the review left open, all of them about the shape of the
interface rather than its Disk implementation.

Most readers want a handful of named nodes, not a Space: a selection to
describe, a neighbourhood to render, one View to serve. Expressing those
as a whole-Space list made an unrelated node somewhere else cost the
request. `SpaceNodes.readMany` is the shape they actually want and the
one every backend serves better than a scan — one `WHERE id IN` rather
than every row. Whole-Space reads stay where the work genuinely is
whole-Space: executor prestate, the Space GET, the outline, inspection.
The neighbourhood runs its pure geometry once to learn which nodes it
wants before reading any of them.

`SpaceFiles` was a port with one implementation, a `'disk'` literal for
a kind, and a composition root that ignored the profile — a seam in the
type system only. It is now an axis with two implementations behind one
reusable contract: the title-addressed layout Huabu ships, and the
id-addressed one a structured backend that keeps Spaces in tables needs.
It is derived from the structured backend rather than configured,
because a backend that stores each Space as a directory has already
chosen where that Space lives and the materialization has to name the
same one — pair them wrongly and a Space's blobs and its records land in
different directories, neither looking wrong. Deriving it means there is
no knob to get that wrong with, and validation catches a profile built
by hand.

The phase listed a product-level backend harness in scope and did not
deliver one. Contracts prove an adapter honours a port in isolation;
they cannot answer whether the application still works when the backend
changes. `storage/testing.ts` mounts a real profile through the
production lifecycle, and `product-boundary.test.ts` exercises the exit
criterion against every mounted profile without naming a directory, a
filename, or `space.json`. Phase 5 adds one list entry and inherits the
coverage.

Also: restore the laziness the built-in file tools lost, so a grep that
never touches a node file no longer reads every one of them first; widen
the Disk-layout guard from the workspace module to all production code,
import-level so a local `artifactPath` is not a violation; and record in
the proposal what Phase 5 owes the rebased port.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ultmaster

Copy link
Copy Markdown
Contributor Author

Follow-up: the three open items from the review

5c6ba969 closes what the review left for you — F2, F3, F6, the undelivered product-level harness, and the guard scope. Interfaces first; implementations are whatever fits behind them.

F2 — SpaceNodes.readMany(nodeIds)

Most readers want a handful of named nodes, not a Space. readMany is that shape, and it is the one every backend serves better than a scan — one WHERE id IN (…) instead of every row. Ids with no record are omitted, so the map is the same shape list() returns and callers move between them freely.

Adopted where the request is a subset — interactive-View list/get, envelope selection, node neighbourhood, snapshot-nodes. Left on list() where the work genuinely spans the Space: executor prestate hydration, the Space GET, buildCanvasOutline, inspectNodes.

The neighbourhood needed a wrinkle worth naming: which nodes it wants is the algorithm's own decision. It now runs its pure geometry once to learn the set, then reads exactly those. Running an in-memory pure function twice is far cheaper than reading a Space's worth of records to describe a dozen.

F6 — SpaceFiles is now an axis, and I had the coupling backwards

The review said a title-addressed layout can't sit on non-file structured records. True, but it's the other direction that bites first: Disk's structured store owns <ws>/<safe(title)>/space.json, so pairing it with id-addressed files would put a Space's blobs in one directory and its records in another — neither looking wrong on its own.

So the materialization is determined by the structured backend, not chosen:

  • disk-titled — Space under its title, moves on rename; the Finder-visible layout Huabu ships. Resolves its locator from title-bearing structured records.
  • disk-addressed — Space under its stable id, consults nothing. What a tables-based structured backend composes with.

materializationFor(structured) derives it, parseStorageProfile applies it, and validateStorageProfile rejects a hand-built profile that mismatches. There is deliberately no HUABU_SPACE_FILES env knob: no value a deployment could supply differs from the one its structured backend forces, so the knob would only be a way to break a Workspace. The resolved kind is logged at startup since it's the one part of the profile nobody chose.

Both implementations are real and both pass a new reusable SpaceFiles contract, which asserts relationships (nodes dir under Space dir, published import reachable through the ordinary scope, retained scope fenced after a Workspace switch) and never a path — a suite that asserted a path would be asserting one addressing scheme.

disk-addressed is unreachable in production until a non-disk structured backend lands. That's intentional: it is exactly what Phase 5 selects, and a port with two implementations and a shared contract is a proven interface rather than a described one.

Product-level harness (§12.6.4, was undelivered)

storage/testing.ts mounts a real profile onto a temp Workspace through the production lifecycle — prepared Workspace, staged connections, ensureWorld(), atomic swap — rather than swapping in a stub. setStorageForTesting() is still the right tool for isolating a caller from storage; this is the opposite tool.

product-boundary.test.ts runs the phase's exit criterion against every entry in PRODUCT_STORAGE_PROFILES: World bootstrap, Space + node round-trip through the application read model, blobs landing in the materialized tree, file→record mapping, cross-store delete. It names no directory, no filename, no space.json. Phase 5 adds one list entry and inherits all of it.

F3 — laziness restored

makeNodeLookup reads the Space record eagerly (one small read) but defers the nodes/ scan to the first lookup that could use it. A grep whose hits all live in .memory/ or an uploaded document no longer opens and parses every node file first.

Guard widened

The Disk-layout-symbol rule covered only modules/workspace*; it now covers every production module outside storage/. It is import-level, so const artifactPath = … in preprocessing/ is not a violation while importing canvasRoot is. Migrations stay exempt (frozen historical on-disk shapes); tests stay exempt for the same reason they may name an adapter, and the shim-importer snapshots already pin which ones do. The stricter identifier-level rule stays on the workspace module, where a re-implementation under a local name would restore the same coupling with a clean import list.

Phase 5 rebase obligations, now written down

Recorded in §12.7. The SQLite preview owes: ensureWorld(), readMany/list/stream, an entry in PRODUCT_STORAGE_PROFILES, and IMPLEMENTED_STRUCTURED + StructuredBackendKind. It does not owe a materialization — disk-addressed exists and materializationFor already pairs it with any non-disk structured backend.

Verification

tsc --noEmit clean · 120 files / 1045 tests pass (was 1007 on df9b23bf) · prettier --check clean · eslint 1 warning, pre-existing.

Docs updated in place: §12.6.1/2/4 and §12.7 of the proposal, and the architecture doc — including the workspace-wide duplicate-canvasId throw, which was a real behavioural hardening that had gone unrecorded.

Yuge Zhang (ultmaster) and others added 3 commits August 18, 2026 18:20
The settled architecture is two backend ports, and `ports/files.ts` was a
third. A port abstracts a backend family — swap the adapter, the same
contract is served by SQLite or Postgres or Azure. Materialization has no
family to abstract: it is the local filesystem under every profile,
because its whole purpose is handing a real path to something that cannot
take a record. §12.5.4 had already placed it correctly as an explicit
capability, the Space-level counterpart to `BlobScope.materialize()`, one
level below the ports; promoting it to a peer of them was drift, and
giving it a `kind`, a profile axis and a contract entrenched the drift
rather than catching it.

The tell was there and got worked around instead of read: the "axis" had
to be derived rather than configured, because a structured backend that
stores each Space as a directory has already chosen where that Space
lives. A knob with exactly one correct value per deployment is not a
knob, and a configuration axis nobody may configure is not an axis.

So it moves to `storage/materialization.ts` beside the composition root,
`StorageProfile` goes back to two axes, and the composition root derives
the placement policy from the structured backend. Names now say which
tier they belong to: `SpaceMaterialization` holds `SpaceTree`s, and the
two policies are `titled` and `addressed` rather than `disk-*` kinds that
read like backend families. Nothing about the capability's behaviour
changes, and both policies still pass one shared contract.

A boundary test now pins `ports/` to exactly the two ports, so the next
interface that needs a home has to be put somewhere deliberately instead
of finding `ports/` convenient.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Space directory is Disk's, and dressing it as portable was the mistake
underneath the last two commits. Only two features ever wanted it — a
filesystem tree for RFS, the note watcher, the file-tool sandbox, and the
agent domain's own files — and none of that is a storage contract. A
backend that keeps Spaces in tables has no directory, and the right
answer there is that the capability is absent, not that every backend
fabricates one.

So the interface says what it is. `diskSpaceTree(canvasId)` and
`stageDiskSpaceImport()` are the whole surface, named so that Disk is
legible at each of the thirteen call sites, and they refuse on a non-Disk
profile rather than improvise. The boundary test holds the exported names
and that call-site list exactly, and asserts the barrel exposes nothing
that reads as a portable path API, so the surface can only shrink. Each
entry is already a reason a non-Disk structured profile is unselectable,
which is the fence §12.4 put around ZIP import and RFS upload.

Gone with the pretence: the second placement policy, the kind, the
reusable contract, the derived profile axis, and the port file. The
policy pair existed to make materialization portable and proved nothing
without it — its contract passed for both while never checking where a
file actually lands. The Disk blob adapter also stops borrowing the Space
tree and resolves its own `.artifacts/` placement again, so one module
answers "where is this Space" for each population instead of two agreeing
by luck.

The route out of the remaining list is not a portable materialization. It
is those features no longer needing a tree — an agent can reach a Space
over the HTTP API rather than a projected filesystem — which is now
written down as the Phase 5 decision it actually is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…substrate

Phase 4.6 landed a single residual "Space-file" capability covering every
consumer that needs a real directory, and gave them one route out: stop
needing a tree and reach a Space over the HTTP API. That is right for some of
them and wrong for the rest — several want a port, not a network hop in front
of state that is still unportable.

New §6.4 replaces the single capability:

- 6.4.1 puts `space(canvasId)` on the `Storage` interface, joining the
  structured handle with the Space's blob scope. The ports stay independent
  and are joined above them, never inside one. Disk's tree becomes
  `space(id).diskTree`, typed by its absence, replacing `diskSpaceTree()`.
- 6.4.2 sorts consumers four ways instead of one — Disk-only, portable with a
  per-backend implementation, structured record, blob — and makes Disk-only
  the default: it costs a row in a capability matrix, while the others cost a
  port change, a contract suite, and a migration.
- 6.4.3 assigns all fifteen current consumers.
- 6.4.4 makes the extension point a connection, not a data API. The Space
  handle vends an isolated substrate per namespace — a directory, a table
  prefix, a schema — and the owner brings its own store and its own SQL, as
  octostaff's bubble extensions already do. Storage owns namespace isolation
  and lifecycle only, and never sees the data.

§12.7 gains Phase 4.7 for this work and keeps it small: the facade, the
substrate with a Disk case, and only the moves that pay for themselves on Disk
alone. Change notification, RFS's backend-neutral path vocabulary, and the ACP
session relocation are deferred to the adapter or phase that first needs them.

Also corrects stale text: §12.7 claimed SQLite "does not owe a materialization
— `materializationFor` already pairs it", and canvas-storage.md still listed
`ports/files.ts` and derived materialization in its module table. Both
described code deleted in 80900df and 513da5a.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ultmaster

Copy link
Copy Markdown
Contributor Author

superceded by #125

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