Skip to content

Give the residual per-Space files an owner, and prove the result - #134

Open
Yuge Zhang (ultmaster) wants to merge 7 commits into
microsoft:mainfrom
ultmaster:feat/space-dispositions
Open

Give the residual per-Space files an owner, and prove the result#134
Yuge Zhang (ultmaster) wants to merge 7 commits into
microsoft:mainfrom
ultmaster:feat/space-dispositions

Conversation

@ultmaster

Copy link
Copy Markdown
Contributor

The per-Space state that is still a file because it always was one gets an owner, the resulting profile declares what it cannot do, and the whole thing is proved against a real backend rather than a stub.

Last of three change sets, specified in docs/proposals/multi-backend-storage.md §6.4.2–§6.4.4 and §12.8. No SQLite schema, driver, migration, or profile-selectability branch.

Its own commits (57f5d46158d3b144):

  • The extension substrate. space(canvasId).extension(namespace) hands an owner an isolated place — a reserved directory on Disk — and nothing else. Storage validates the namespace, creates it on demand, and destroys it with the Space, which is the one operation an owner cannot perform itself. Its contract is isolation and lifecycle only; there is no data behaviour to assert because the port never sees the data. Memory-worker state and the debug chat prompt log are its first two owners, retiring two ad-hoc file formats.
  • The resurrection guard belonged in the port. Several owners each carried their own existsSync(spaceDir) check before writing into a Space that might be gone. extension() returning null for an absent Space states it once, and the per-owner guards were deleted rather than moved.
  • One blob area per user-visible family. BlobStore.space(id) returns SpaceBlobsartifacts, guide, memory, uploads — symmetric with StructuredStore.space(id), so the facade stops assembling scope descriptors by hand and the Disk paths a user sees are unchanged. The guide area is bounded by its member names rather than a directory: its area is the Space root, where a directory scope would let list() claim space.json and deleteAll() remove the Space. Upload scratch got its area but keeps its fs-sandbox writers — RFS upload is a streaming handler plus path classification, not the bare readFileSync/writeFile the exception covers.
  • What the profile cannot do, declared. Bundle export and import, reveal-in-file-manager, the built-in file tools, external-note discovery, and Windows directory-handle coordination become capability-matrix entries validateStorageProfile() consults. Startup logs what the selected profile does not offer and each refusal reuses that wording. An unavailable feature is a stated limitation and startup continues; a profile naming an unimplemented backend stays a misconfiguration that fails fast.
  • Proof. storage/testing.ts opens a real profile against a temporary Workspace through the production lifecycle — prepared Workspace, opened connections, ensureWorld(). product-boundary.test.ts runs the exit criterion against every entry in PRODUCT_STORAGE_PROFILES, and a guard reads that suite's own source and rejects a directory, a filename, or a readFileSync appearing in it. Phase 5 adds one list entry and the same behaviours are covered for SQLite.
  • closeStorage() closes both connections on graceful shutdown. On Disk it is close to a no-op, which is why it exists before a connection-holding backend does.

The exit criterion now holds on both halves. Neutrality: no production module outside storage/ imports a Disk layout or legacy CanvasStore symbol. One handle: every storage capability for one Space is reached through space(canvasId), and the five consumers still holding diskTree are the three the matrix declares Disk-only, RFS's sidecar-to-record mapping (deferred until a second backend has a file plane), and the ACP session path that leaves with the Agenetes Namespace change.

Stacking. Builds on #133#132#130; none of those branches exist on microsoft/Huabu, so the diff below carries their commits too. This PR's own work starts at 57f5d461.

pnpm run check passes at this branch's tip.

🤖 Generated with Claude Code

https://claude.ai/code/session_011EKZXxEWwZvAZo8YMaLv4m

Yuge Zhang (ultmaster) and others added 7 commits August 29, 2026 13:53
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>
Every product test so far either named the Disk tree or swapped in a stub. A
stub proves the application talks to an interface; it cannot prove a backend
serves the product, which is the half that decides whether a second adapter
works.

`storage/testing.ts` opens a real profile against a temporary Workspace
through the production lifecycle — prepared Workspace, opened connections,
`ensureWorld()` — and `product-boundary.test.ts` runs the criterion against
every entry in `PRODUCT_STORAGE_PROFILES`. Phase 5 adds one entry and the same
behaviours are covered for SQLite without a line of the suite changing, which
is also the check that the suite never learned a backend's layout. A guard
reads the suite's own source and rejects a directory, a filename, or a
`readFileSync` appearing in it, because the failure mode here is a
helpful-looking assertion someone adds later.

`closeStorage()` arrives with it, registered on graceful Server shutdown and
used by the harness between profiles. On Disk it is close to a no-op — which
is why it has to exist before a connection-holding backend does: a pool nobody
closes leaks on every restart, and the lifecycle is where that is visible
rather than the adapter.

The records the suite reads back are built through the write engine rather
than hand-rolled, because a fixture that skips the engine asserts nothing
about what the product actually stores.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011EKZXxEWwZvAZo8YMaLv4m
`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
§12.8 was the plan for the last change set; it now records what landed — the
substrate and its first two owners, the per-area blob scopes and why the guide
area cannot be a directory scope, the capability matrix, and the product
harness — along with the two things building surfaced: that the resurrection
guard belonged in the port rather than in each owner, and that upload scratch
earns its area without yet earning its writers.

The architecture doc's Disk diagram still showed `state.json` under `.memory/`
and no `.ext/` tier, which is now where per-namespace extension state lives,
and described the residual direct-filesystem capabilities as an undesigned gap
rather than a declared limitation. Both are the claim the matrix changes: off
Disk those features are unavailable and said so at startup, not a reason the
profile cannot be selected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011EKZXxEWwZvAZo8YMaLv4m
Mechanical: `pnpm run lint --fix` import ordering for the two new imports, and
Prettier on the two files the cherry-picks left unformatted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011EKZXxEWwZvAZo8YMaLv4m
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