Give the residual per-Space files an owner, and prove the result - #134
Open
Yuge Zhang (ultmaster) wants to merge 7 commits into
Open
Give the residual per-Space files an owner, and prove the result#134Yuge Zhang (ultmaster) wants to merge 7 commits into
Yuge Zhang (ultmaster) wants to merge 7 commits into
Conversation
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
Yuge Zhang (ultmaster)
force-pushed
the
feat/space-dispositions
branch
from
August 29, 2026 06:01
58d3b14 to
19c8995
Compare
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.
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 (
57f5d461…58d3b144):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.existsSync(spaceDir)check before writing into a Space that might be gone.extension()returningnullfor an absent Space states it once, and the per-owner guards were deleted rather than moved.BlobStore.space(id)returnsSpaceBlobs—artifacts,guide,memory,uploads— symmetric withStructuredStore.space(id), so the facade stops assembling scope descriptors by hand and the Disk paths a user sees are unchanged. Theguidearea is bounded by its member names rather than a directory: its area is the Space root, where a directory scope would letlist()claimspace.jsonanddeleteAll()remove the Space. Upload scratch got its area but keeps itsfs-sandboxwriters — RFS upload is a streaming handler plus path classification, not the barereadFileSync/writeFilethe exception covers.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.storage/testing.tsopens a real profile against a temporary Workspace through the production lifecycle — prepared Workspace, opened connections,ensureWorld().product-boundary.test.tsruns the exit criterion against every entry inPRODUCT_STORAGE_PROFILES, and a guard reads that suite's own source and rejects a directory, a filename, or areadFileSyncappearing 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 legacyCanvasStoresymbol. One handle: every storage capability for one Space is reached throughspace(canvasId), and the five consumers still holdingdiskTreeare 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 AgenetesNamespacechange.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 at57f5d461.pnpm run checkpasses at this branch's tip.🤖 Generated with Claude Code
https://claude.ai/code/session_011EKZXxEWwZvAZo8YMaLv4m