---
title: Store layout and path algebra
description: Four fixed top-level buckets, placement as a pure total function, and an archive mapping that can be inverted.
---

## 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, committed
```

The 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

`placementFor` (`packages/contracts/src/paths.ts:145`) applies six rules in order:

1. an explicit valid path;
2. an `arc` goes to `areas/arcs`;
3. a `task` is 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`);
4. a `person:` entity on a `semantic` memory goes to `resources/people`;
5. a named workspace goes to `projects/<slug>`;
6. a `semantic`, `procedural`, or `precedent` memory with a primary tag goes to `resources/<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

`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.

```d2 pad=20 src="_figures/archive-mapping.d2" title="Three paths stacked vertically as the rungs of a ladder: notes/x.html, then archive/2026/notes/x.html, then archive/2027/archive/2026/notes/x.html. A solid arrow labelled archivePathFor descends one rung at a time. A dashed arrow labelled originalPathFor ascends one rung at a time and never more than one, so from the bottom rung it reaches the middle one rather than the top."
```

**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

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

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 mv`s 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

`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.