Store layout and path algebra
1. The layout
Section titled “1. The layout”$MEMHTML_ROOT/ a git repo, one global memory store projects/<workspace-slug>/ a workspace is a directory; there is no workspaces table projects/<slug>/tasks/ task files: one named segment, not a fifth bucket areas/<area>/ areas/arcs/ areas/inbox/ resources/<topic>/ resources/people/<person>.html archive/<YYYY>/<original-path> soft-evicted, path-preserving .memhtml/index.db gitignored, rebuildable .memhtml/state.db gitignored, NOT rebuildable from git .memhtml/state/access.jsonl COMMITTED sidecar: the state plane's durability .memhtml/sleep/<run-id>.html committed run reports sitemap.xml, index.html generated by publish, committedThe top level is fixed at four buckets: projects, areas, resources, and archive (packages/contracts/src/types.ts:63). That scheme is borrowed from the PARA method of organizing personal files, and this page uses the name PARA for it. archive is a bucket rather than a status value because the path itself is what records the state.
A workspace is a directory and nothing else. There is no workspaces table, so creating a workspace costs a mkdir inside a commit, and deleting one is a git mv.
2. Placement is a pure total function
Section titled “2. Placement is a pure total function”placementFor (packages/contracts/src/paths.ts:145) applies six rules in order:
- an explicit valid path;
- an
arcgoes toareas/arcs; - a
taskis placed by workspace alone, before the person and topic rules run, so a task about a person does not land in the durable identity surface (packages/contracts/src/paths.ts:158-162); - a
person:entity on asemanticmemory goes toresources/people; - a named workspace goes to
projects/<slug>; - a
semantic,procedural, orprecedentmemory with a primary tag goes toresources/<tag>.
Anything remaining goes to areas/inbox. The function always returns a directory rooted in a bucket, so the write path never guesses twice and never fails to place a memory.
Rule 1 reads “an explicit valid path”, and the validity clause is where a caller gets a choice. An explicit path that is not a usable memory path — not rooted in a bucket, not ending in .html, or carrying a . or .. segment — is re-derived through the five rules below it, so the write succeeds at a path the caller did not name and the response reports that other path as the outcome. memoryPathViolation (packages/contracts/src/paths.ts) is the rule and its explanation in one function, and isValidMemoryPath is the same question asked as a boolean, so a refusal’s reason cannot name a clause the predicate does not check.
--strict-path on memhtml write, strict_path on memory_write and on each memory_write_batch op, and strict_path on a memhtml apply line make that re-derivation a refusal instead: ERR_INVALID_MEMORY, naming the clause the path broke, with nothing written, staged, or committed. It is opt-in because the lenient branch is shipped behavior, and it matters to a caller that computes its paths — which is how a consumer models its own document types on top of the open axes. The refusal is strictPathRefusal in @memhtml/store, and it runs ahead of both the render gate and the dedupe question in writeMemory, in each batch op’s validation, and in correctMemory. That ordering is the contract: a strict check placed after the dedupe question would answer “deduped, at some other path” for content the store already holds and “refused” for content it does not, so identical malformed input would report two different outcomes and only one of them would mention the path.
ERR_INVALID_MEMORY rather than ERR_WRITE_CONFLICT, which is what an occupied explicit path earns. That error carries two blob shas and its published recovery is memhtml read <path> then memhtml correct <path>; against a path no file can occupy, both of those are calls that cannot succeed. An unusable path is malformed caller input, which is what InvalidMemory is for and what every other decode on the write path already uses.
memoryPathFor (packages/contracts/src/paths.ts:190) adds the filename. It date-prefixes an episodic entry, because time is part of that memory’s identity, while every other type has to stay correctable in place.
3. The archive mapping can be inverted
Section titled “3. The archive mapping can be inverted”archivePathFor (packages/contracts/src/paths.ts:213) mirrors the whole original path beneath archive/<YYYY>/, and originalPathFor (packages/contracts/src/paths.ts:221) strips exactly one such prefix. Stripping one rather than all of them keeps it a left inverse even for a memory archived twice. git log --follow reads through the move, and diff -M reports R100 instead of a delete plus an add. No memory is removed from the repository.
A test pins the property: originalPathFor(archivePathFor(p, y)) === p for every generated path. That invertibility is what lets the sleep pipeline’s integrity phase derive an archived target’s new href rather than searching for it, and no rename-similarity score is consulted anywhere in the system.
Figure 1 draws the mapping as a ladder rather than as a round trip. From a twice-evicted path the inverse climbs one rung, reaching the once-evicted path, which is what “exactly one prefix” means.
Figure 1: archivePathFor descends one rung and originalPathFor climbs exactly one. Descending is a git mv inside a commit, which diff -M reports as R100. Ascending is a pure function of the path, which is why those edges are dashed. Because each application moves one rung, the inverse stays a left inverse for a memory evicted twice, and a single return edge to the live path would describe that wrongly.
4. A path is an id
Section titled “4. A path is an id”Path validation refuses . and .. segments (packages/contracts/src/paths.ts:90-96), which keeps a caller-supplied path inside the repository. The path is the id of a memory, and there is no separate uuid anywhere in the system (packages/contracts/src/types.ts:102-107). files.path is the primary key of the index’s central table, and because that key moves when a memory is evicted, every child table declares ON UPDATE CASCADE alongside ON DELETE CASCADE.
An href value in the HTML plane carries the same path with a leading slash. That leading slash is a document-reference form, converted at the HTML boundary and never stored (packages/contracts/src/types.ts:102-107, packages/index/src/project.ts:336-344).
5. A citation resolves forward, or pins a commit
Section titled “5. A citation resolves forward, or pins a commit”Because the path is the id and the path is derived from the title, correcting a memory with a reworded title lands the corrected fact at a different path and git mvs the original into archive/<YYYY>/. Anything outside the store that recorded the old path — a receipt, a report, another agent’s note — now names a path the tree does not hold, through no fault of its own. Two reads answer that, and they answer different questions.
memhtml resolve <path> walks forward. It follows the two mechanisms that move a memory, and only those two: an authored supersedes link, and the archive move recorded by origin_path. A correction points its supersedes link at the target’s ARCHIVE path, so a cited pre-archive path has no inbound edge at all and the archive mapping is the only thing that knows where its bytes went; the walk therefore reads the mapping first for a path absent from the index, and the edge for a path present in it. Every node it reports is named by the path holding that memory NOW: a supersedes link is an element inside a file, so archiving that file carries the link with it, and a chain over two corrections reads cited → archive(cited) → archive(middle) → live with the middle’s own live-at-the-time path appearing nowhere.
The answer’s stopReason is the field that decides whether to cite, and only live means yes. archived is a memory that was evicted rather than corrected, so nothing supersedes it. unindexed is no such path, which can also mean the index does not yet describe the commit holding it — indexedCommit names the commit it does describe. cycle and hop_limit are the two abnormal endings: two memories each claiming to supersede the other is an authoring defect the walk refuses to resolve, and the hop bound means the answer is where the walk stopped rather than the end of the chain. Nothing is fabricated in any of the five cases, which is the point — a resolver that answered “not found” for all four of the non-live ones would collapse an eviction, a stale index, and a corpus defect into one word.
Forward resolution cannot answer one thing, and it says so: a correction whose title did NOT change lands at the same path, so the path is live at zero hops while the bytes behind it state a different fact. That grain is the pinned citation, memhtml://at/{commit}/{path}, an MCP resource that reads the git object at a named commit rather than the working tree. A commit sha is immutable, so those bytes cannot move; a branch name or HEAD is refused, because a URI whose target can move is not a citation. memory_resolve publishes such a URI ready-made as pinned_uri.
6. memhtml init converges on one end state
Section titled “6. memhtml init converges on one end state”memhtml init (packages/store/src/layout.ts:183) is the only code path that creates the root. Each step asks the repository what is already true, so the command reaches the same end state from an empty directory, from a live repository, and from one an interrupted run left half-staged.
It re-sets merge.ours.driver on every run (packages/store/src/layout.ts:197), because that config is per-clone and the .gitattributes a fresh clone inherits does nothing without it. Those two together are what make the generated artifacts, the per-directory index.html and the root sitemap.xml, resolvable by regenerating them instead of by editing them.