1. What the ledger holds
107 requirements, grouped under 28 key prefixes. Each one is a single sentence that names a condition and the response the system owes under it, which is the style called EARS, the Easy Approach to Requirements Syntax.
Every requirement also carries the method that verifies it and, once implemented, the code that satisfies it. Counted by status:
| Status | Requirements | Keys |
|---|---|---|
approved |
3 | INV-1, INV-2, INV-3 |
draft |
10 | ANN-1, BACKFILL-1, CACHE-1, CODE-3, GYM-1, HOP-2, PERF-1, PROX-1, PROX-2, TASK-1 |
implemented |
94 | CI-1, CITE-1, CITE-2, CITE-3, CODE-1, CODE-2, CONF-1, CONF-2, CONF-3, CONF-4, CONF-5, DET-1, DET-2, DET-3, DET-4, DET-5, DET-6, DOCSYNC-1, ENT-1, ENT-2, EXT-1, EXT-2, EXT-3, FMT-1, FMT-2, HOP-1, HOP-3, HOP-4, IDX-1, IDX-2, IDX-3, IDX-4, IDX-5, IDX-6, IDX-7, IDX-8, IDX-9, INT-1, INT-2, INT-3, INT-4, INT-5, INT-6, NEAR-1, NEAR-2, NEAR-3, NEAR-4, NEAR-5, RET-1, RET-2, RET-3, RET-4, RET-5, ROUTE-1, ROUTE-2, SAL-1, SAL-2, SAL-3, SAL-4, SAL-5, SLEEP-1, SLEEP-2, SLEEP-3, SLEEP-4, SLEEP-5, SLEEP-6, SLEEP-7, SLEEP-8, SLEEP-9, SLEEP-10, SLEEP-11, SLEEP-12, SLEEP-13, STORE-1, STORE-2, STORE-3, STORE-4, STORE-5, STORE-6, STORE-7, STORE-8, TRACE-1, TRACE-2, TRACE-3, TRACE-4, TRACE-5, WIRE-1, WIRE-2, WIRE-3, WIRE-4, WIRE-5, WIRE-6, WIRE-7, WIRE-8 |
And counted by the method that verifies them:
| Verification method | Requirements |
|---|---|
analysis |
6 |
demonstration |
2 |
inspection |
7 |
test |
92 |
2. By prefix
A prefix is one part of the system, and the requirements under it are numbered within that part. Every prefix below has its own table of requirements further down this page.
| Prefix | System | Requirements |
|---|---|---|
ANN |
retrieval |
1 |
BACKFILL |
sleep cycle |
1 |
CACHE |
repo |
1 |
CI |
ci pipeline |
1 |
CITE |
forward walk, pinned citation resource, resolution answer |
3 |
CODE |
memhtml exec runtime, traversal gate |
3 |
CONF |
batch door, conflict assist |
5 |
DET |
write path, index rebuild, detector, detector module |
6 |
DOCSYNC |
design doc |
1 |
ENT |
write path, entity activity report |
2 |
EXT |
store, closed vocabulary, envelope vocabulary |
3 |
FMT |
memory file format, memory file parser |
2 |
GYM |
lived-in-corpus gym |
1 |
HOP |
memory_search, memory-graph walk |
4 |
IDX |
indexer, projection |
9 |
INT |
integrations |
6 |
INV |
every new assist, every index feature, every scheduled model call |
3 |
NEAR |
batch door, near-duplicate assist |
5 |
PERF |
write path |
1 |
PROX |
conflict assist |
2 |
RET |
retrieval |
5 |
ROUTE |
sleep cycle, structured decode |
2 |
SAL |
salience plane, salience arm, reinforce writer |
5 |
SLEEP |
sleep edge-typing phase, sleep cycle, sleep task-detection phase, sleep placement-triage phase, sleep merge, state plane, sleep plan, sleep preflight |
13 |
STORE |
store |
8 |
TASK |
mcp server |
1 |
TRACE |
trace-consolidation phase |
5 |
WIRE |
batch result envelope, mcp server, cli, cli manifest |
8 |
2.1. ANN
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
ANN-1 |
While a corpus approaches one hundred thousand chunks, the retrieval shall serve vector search through an approximate nearest neighbor index. | draft |
analysis |
data-triggered, not calendar-triggered; record corpus size at sleep time; a SQLite vector-index extension vs an external sidecar decided then |
2.2. BACKFILL
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
BACKFILL-1 |
While the corpus holds enough unlabeled code fences to matter, the sleep cycle shall propose data-lang stamps for unlabeled fences as reviewable commits. | draft |
test |
backlog 7 decision 5; mechanical now the threshold is measured; same detector, same 0.30 |
2.3. CACHE
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
CACHE-1 |
While a ci stage needs live search, the repo shall serve search from committed embeddings on a fresh clone without credentials. | draft |
demonstration |
opt-in; ~40MB at 10k memories; second-machine trigger died with dropped item 8 |
2.4. CI
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
CI-1 |
When a merge request or default-branch push lands, the ci pipeline shall run the full check gate without credentials. | implemented |
demonstration |
.github/workflows/check.yml; eval defaults to fake mode, integration tests set MEMHTML_EMBED=off themselves |
2.5. CITE
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
CITE-1 |
When a caller resolves a path a citation recorded, the forward walk shall report the path holding that memory now and shall state which bound stopped it. | implemented |
test |
resolveMemory (apps/cli/src/operations.ts) follows the only two mechanisms that move a memory: an authored supersedes edge and the archive mapping in files.origin_path. A correction points its supersedes link at the target’s ARCHIVE path, so a cited pre-archive path has no inbound edge and the mapping is read first for a path absent from files; a path present in files is never redirected by the mapping, because the live file at that path is the answer. Each node is named by the path holding it NOW, since a supersedes link is an element inside a file and archiving carries it along. stopReason is five values and only live is citable: archived is an eviction rather than a correction, unindexed may also mean the index does not describe the commit holding the path (indexedCommit names the one it does), and cycle and hop_limit are the two abnormal endings, the first from a visited set and the second from RESOLVE_MAX_HOPS, checked before the step so hops equals the bound rather than exceeding it. Nothing is fabricated in any case, and pinned_uri is withheld for unindexed because a citation this server would refuse is not one. The three doors a stale path actually reaches — SUGGESTIONS.PathNotFound (apps/cli/src/errors.ts), mcpSuggestionsFor (apps/mcp/src/failure.ts), and fileRefusal (apps/mcp/src/resources.ts) — all name the walk FIRST, because the commonest absent path is one a recorded move vacated and a semantic search re-derives an answer instead of following the record. apps/cli/tests/resolve.test.ts builds two chains corrected TWICE with reworded titles, so the walk crosses both mechanisms alternately and a NEIGHBOUR chain shares edges and files.origin_path; verified by mutation on the archive redirect (4 failures), the cycle guard, and the bound’s ordering. |
CITE-2 |
The pinned citation resource shall serve the bytes a commit held and shall refuse any reference that can move. | implemented |
test |
memhtml://at/<commit>/<path> (apps/mcp/src/resources.ts, PinnedResource) resolves the path in that commit’s tree with lsTreeR and reads the blob with catFileBatch, so a path since corrected, archived, or evicted still reads. The commit half must match seven to sixty-four lowercase hex characters: HEAD, a branch, and a tag are refused because a URI whose target can move is not a citation, and hex also keeps a leading - out of git’s argv. The route is multi-segment by construction, since the path hole is at least two segments for every memory and four for an archived one, and the capture is split at the FIRST slash because a commit cannot contain one. isValidMemoryPath gates the path half: git resolves README.html in the tree, so without it the resource would serve any file in any commit. It declares Store and not IndexRecorder, so it structurally cannot bump salience — state.access is path-keyed with no notion of a commit, and verifying a receipt is auditing rather than choosing. apps/mcp/tests/resources.test.ts reads a path the tree no longer holds; verified by mutation on the commit source (lsTreeR at HEAD), the ref-shape gate, and the path gate, which yields ERR_INVALID_MEMORY instead of ERR_PATH_NOT_FOUND because the read happened. |
CITE-3 |
The resolution answer shall name the commit the index describes rather than HEAD. | implemented |
test |
indexedCommit is index_state.head_sha, read through readIndexState (packages/index/src/index-state.ts), and it is what memory_resolve composes pinned_uri from. The index is a projection of git, so a resolution is a statement about the commit the projection describes and not about the working tree; reporting HEAD would pin a citation to bytes the answer was never taken against. It is null before the first rebuild and during one. pinned_uri is null under that condition AND for stop_reason unindexed, the one ending whose path the indexed commit does not hold, because a citation the same server refuses is not a citation; tests-integration/tests/mcp-stdio.test.ts takes both branches over one session and mutation-verifies the withheld one. memory_status.index_fresh answers whether the two agree. apps/cli/tests/resolve.test.ts asserts the value equals what memhtml index status reports rather than a literal. |
2.6. CODE
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
CODE-1 |
While the maintainer has decided to build code-mode ahead of the usage evidence, because the sleep cycle already depends on the same just-bash sandbox, the memhtml exec runtime shall preload the corpus helper in a read-only sandbox over a pinned commit. | implemented |
test |
7b, SHIPPED as memhtml exec. TWO AMENDMENTS RECORDED HERE. (1) The pre-condition was ‘real usage shows which cookbook recipes agents reach for’. That evidence never arrived and the item was built anyway, so the pre-condition now names the decision that actually triggered it rather than a condition that never occurred. The maintainer’s stated reasoning was that the capability is intuitive and novel and that the sleep cycle already depends on the same sandbox; only the second clause is in the pre-condition, because GTWR_R7_VAGUE rejects ‘intuitive’ as unmeasurable. The judgment is preserved here instead of being smuggled into a requirement sentence. (2) ‘and a read-only index handle’ was dropped from the response: memhtml exec covers the STRUCTURAL and LEXICAL planes only, which is the division ROADMAP item 7 itself draws: memhtml search finds entry points, code traverses from there, and a script needing ranked retrieval shells out to memhtml search and consumes its envelope. Verified by apps/cli/tests/exec.test.ts: the helper is preloaded at /workspace/lib/corpus.mjs and parses 305/305 claims and 410/410 edges, each asserted against a total derived independently outside the sandbox by grep. |
CODE-2 |
If a sandboxed script attempts a write to the store or the index, then the memhtml exec runtime shall not allow the mutation. | implemented |
test |
The one-commit-per-op, dedup, and conflict machinery must be unbypassable, and memhtml exec offers no write path at all. STORE HALF, demonstrable and MUTATION-VERIFIED: readOnly: true on the OverlayFs (apps/consolidator/src/mount.ts) answers EROFS, asserted by apps/cli/tests/exec.test.ts ‘REFUSES a write from inside the script with EROFS’; dropping readOnly: true makes both writes succeed and the test fails with “expected ‘wrote’ to match /EROFS/”. INDEX HALF, satisfied VACUOUSLY in v1, and this is stated rather than implied: no index handle is exposed, so there is no guard, only an absence. What IS guarded is reachability, because read-only is no barrier to a reader. The guest’s sqlite3 reads a read-only-mounted database fine (probed 2026-08-09). memhtml exec mounts a detached worktree of a pinned commit instead of the live tree, and .memhtml/index.db is gitignored, so a checkout omits it; asserted by ‘cannot read .memhtml/index.db, because a pinned worktree does not contain it’, which fails with “expected 8192 to be ‘refused’” when the live tree is mounted instead. |
CODE-3 |
While memhtml exec has landed, the traversal gate shall score code-mode against tool-chaining on the same multi-hop rows for accuracy and tokens. | draft |
analysis |
belongs beside the discrimination gate; ‘code beats chaining’ becomes a number on the same MAB rows as HOP-2 |
2.7. CONF
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
CONF-1 |
When detect_conflicts is on and an op’s claim frame-matches an active memory, the batch door shall attach the match to the op’s conflict field. | implemented |
test |
none stated |
CONF-2 |
When detect_conflicts is on and an op’s claim frame-matches an earlier op in the same batch, the batch door shall attach the earlier op’s caller-space index to the conflict field. | implemented |
test |
survivor-space bug caught by mutation; originOf translation locked |
CONF-3 |
If a conflict is detected, then the conflict assist shall not alter, block, or archive any write. | implemented |
test |
propose-only: removal mutation fires 14 tests across three layers. BEAM: the gold IS the contradiction |
CONF-4 |
When the frame lookup fails, the conflict assist shall degrade to a null conflict with a logged warning. | implemented |
test |
error channel structurally never; write never blocked by assist plumbing |
CONF-5 |
The conflict assist shall resolve all of a batch’s frame keys in one lookup query. | implemented |
test |
quadratic-write-cost lesson; per-op-lookup mutation fires exactly 1 shape test |
2.8. DET
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
DET-1 |
When an unlabeled fence is written through either door, the write path shall run the pinned language detector at threshold 0.30. | implemented |
test |
hljs 11.11.1 exact; port-fidelity vs all 332 eval corpus rows |
DET-2 |
While a detection is below threshold or outside the canonical vocabulary, the write path shall omit the data-lang attribute. | implemented |
test |
none stated |
DET-3 |
While the author’s info string names a language, the write path shall keep the author’s token without running the detector. | implemented |
test |
author precedence structural via short-circuit; blast radius exactly 1 test |
DET-4 |
If a stamped fence is reprojected, then the index rebuild shall not re-run language detection. | implemented |
test |
grep+resolve locks; rebuild reads the attribute back |
DET-5 |
When a fence exceeds a length of 4096 characters, the detector shall abstain before invoking the highlighter. | implemented |
test |
DETECT_MAX_CHARS; measured 40KB ~ 20s blocking CPU without it; guard proven to fire |
DET-6 |
The detector module shall load the highlighter lazily on first detection. | implemented |
test |
createRequire; read path stops paying ~100ms/~30MB |
2.9. DOCSYNC
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
DOCSYNC-1 |
The design doc shall derive the response-type list from the enum. | implemented |
test |
docs/design.md cites RESPONSE_TYPES (apps/cli/src/envelope.ts:12) and ERROR_CODES (apps/cli/src/envelope.ts:67) as the append-only lists rather than restating their members, so the doc names zero of the response types and cannot drift from the enum however many it holds — the count is deliberately not restated here for the same reason. AGENTS.md takes the stronger form of the same rule: it is generated from the COMMANDS table by memhtml agents-doc and gated byte-for-byte by apps/cli/tests/agents-doc.test.ts. Verified against the tree 2026-08-26; the doc-sync sweep is recorded closed in the fine-grained ledger. |
2.10. ENT
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
ENT-1 |
While MEMHTML_EXTRACT_ENTITIES is on, the write path shall add one model call per write batch that extracts the memhtml-entity metas the ops did not declare. | implemented |
test |
apps/cli/src/config.ts declares it with fallback off, opt-in unlike MEMHTML_EMBED because it changes what a write STORES: extracted entities land in the files as if authored. The write never waits on or fails with the model, and a failed extraction is a logged warning plus an unextracted batch. ExtractorPort (apps/cli/src/api-layer.ts:345) resolves to an absent extractor unless the variable is on, which is why apps/mcp/src/tools.ts carries it in the WRITES service set. |
ENT-2 |
The entity activity report shall aggregate write-side activity per entity and shall never be a ranking or decay input. | implemented |
test |
entityActivity (apps/cli/src/operations.ts) groups file_entities JOIN files on (entity_type, entity_name), reporting max(coalesce(event_at, updated_at)) beside max(event_at) and max(updated_at) so a caller acts on a named clock rather than on a coalesced one. The --type filter folds case, the way --entity folds at both retrieval doors, because a report that alone demanded the stored capitalization answers an empty page with no marker; a row still carries the spelling the corpus authored, so two spellings of one reference are two rows until entity-resolution folds them. Report-only is held in two directions: the project-reference graph refuses an import from @memhtml/index or @memhtml/domain (both sit BELOW apps/cli), and apps/cli/tests/entity-activity.test.ts reads every emitted .js in those two dists and asserts the symbol appears in none, with the CLI’s own dist as the control, which is the direction the graph does not cover. Reads are excluded because state.access is path-keyed with no foreign key and lives in the separately ATTACHed state plane, whose lifetime differs from the index’s; the salience arm is the one statement that crosses that boundary. apps/cli/tests/entity-activity.test.ts seeds a NEIGHBOUR entity sharing one memory, a second entity with the same NAME under a different type, and a second corpus whose stored type is capitalized; it asserts the plan groups through file_entities_name. Verified by mutation: dropping the case fold, dropping entity_type from the GROUP BY, counting join rows instead of groups, and naming the symbol from packages/index each failed the case that names them. |
2.11. EXT
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
EXT-1 |
The store shall index and filter a value on an open axis without interpreting its meaning. | implemented |
test |
Five axes are open: dl facets projected into file_facets (0001_files.sql) and queried through --facet; memhtml-tag, repeatable with an open vocabulary (packages/html/src/vocabulary.ts); the entity TYPE half of a type:name reference, where the shape is fixed and the vocabulary is not, so unknown: is a valid stored type (apps/cli/src/extraction.ts); workspace, an open string that routes to projects/<slug> with no workspaces table; and any explicit path legal under isValidMemoryPath. Matching is on TEXT with no case folding, and file_facets.numeric_value is UNITLESS with no inequality offered over it, because the unit lives in the prose beside the value. A facet half is stored as the element’s TEXT CONTENT, which the PARSER collapses whitespace in and trims (packages/html/src/parse.ts:45, applied at :304-308) while the filter side only trims (packages/index/src/scope.ts:57-62), so the collapsed form is the queryable one. Open does NOT mean uninterpreted, and the docs page names each RESERVED value rather than claiming there are none: the projection MINTS concept: from a dfn, lang: from a code data-lang, and unknown: as the fallback for an untyped reference (packages/index/src/project.ts:321-338), which is also the row memhtml doctor samples as untypedEntities; person: is read by placement AND by sleep phase 4, which mints resources/people/<slug>.html and splices a memhtml-about-person link into every memory claiming the name (packages/sleep/src/phases/person-links.ts:29-36), a corpus mutation rather than a directory choice; a primary tag names a resources/<tag> directory; and the tag VALUES detected and machine-closed are read POSITIONALLY by task detection, which requires tags[0] === detected and reads tags[1] as the detector (packages/sleep/src/tasks.ts:641, :678-679). packages/index/tests/retrieval-sql.test.ts asserts the composition rule directly against the assembled statement (OR within one name, AND across names), which is the half a caller cannot verify by reading rows back: read either way round it acts on a superset or on an empty set. tests-integration/tests/contracts.test.ts drives the axis through both doors. |
EXT-2 |
Each closed vocabulary shall carry the reason it is closed beside the declaration that closes it. | implemented |
inspection |
Six vocabularies a consumer meets while modelling are closed, and each is interpreted rather than merely stored, which is what closing buys — the site’s generated Reference page derives NINE closed value-sets from their declarations (apps/docs/src/loaders/registry.ts:441-451) and is the exhaustive list: the element/attribute vocabulary and the memhtml-* meta names (packages/html/src/vocabulary.ts, whose head comment states that the vocabulary IS the policy, so the module holds data and no sanitizer); the ten memory types, where the MEMORY_TYPES doc comment records that three overlapping type vocabularies is what made the predecessor memory system’s classification unanswerable, and a type selects a retention half-life, a placement rule, and a default search scope; PARA’s four buckets, where archive is a bucket rather than a status value because eviction is a git mv and the path records the state; the four task statuses, enforced twice (packages/contracts/src/types.ts:82 and a SQL CHECK at packages/index/migrations/0008_tasks.sql:72); the edge classes, the fourteen rel tokens, and the three provenances, whose reasons live in packages/contracts/src/edges.ts beside each array and whose failure is SILENT — an unknown rel is a recorded constraint violation that readLinks then drops, so the file parses and the edge never exists (packages/html/src/constraints.ts:257, packages/html/src/parse.ts:283-286); and the envelope type/code values, closed in the weaker APPEND-ONLY sense. The docs page internals/the-extension-contract CITES each reason rather than restating it, since a second copy of a rationale drifts from the first. What a TEST holds is narrower than the sentence: apps/docs/tests/census.test.ts derives the vocabulary page from the declaring constants and asserts every vocabulary and every VALUE reaches the page, so a vocabulary that grew is documented at the commit that grows it — it does not assert that a reason exists beside the declaration, which is held by inspection. |
EXT-3 |
If a caller branches on an envelope type or code, then a later release shall not change that value’s meaning or remove it. | implemented |
test |
RESPONSE_TYPES and ERROR_CODES are append-only (apps/cli/src/envelope.ts): a shipped value never changes meaning and is never removed, and a new condition takes a new value. A consumer therefore branches on code and never on the error prose, which changes freely as wording improves, and must treat an unrecognized value as unknown rather than refusing it — a closed reading of an append-only list breaks on the first addition. Growth is a minor release and a removal or a meaning change is a major, which the Conventional Commit subject is what states. apps/cli/tests/cli.test.ts asserts every raised code is a member and that suggestions name only real commands; the docs error-code page is generated from the same arrays, so a value added without documentation fails the census. |
2.12. FMT
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
FMT-1 |
The memory file format shall admit only a bare ISO date or a canonical UTC instant on every time datetime attribute. | implemented |
test |
ISO_DATETIME (packages/html/src/constraints.ts:64) admits YYYY-MM-DD or YYYY-MM-DDThh:mm:ssZ and nothing else, checked per element by checkTimes (:212). Narrower than HTML’s own datetime grammar because files.event_at, files.due_at and files.valid_until are compared and ordered as RAW STRINGS, so every admitted value must sort lexicographically as it sorts chronologically: a space separator sorts before T, a non-UTC offset sorts by its clock face rather than its instant, and optional seconds or fractions make …T13:00:30Z sort before the …T13:00Z it extends. A bare date is a prefix of every instant on its day, so the two forms mix safely. Ranges live in the character classes and the calendar day is range-checked, so 2026-13-45 and an hour of 25 are refused rather than stored unsortable; 60 seconds is admitted because a leap second is a real instant. packages/html/tests/constraints.test.ts ‘refuses the ISO variants whose string order disagrees with their instant order’. |
FMT-2 |
When a datetime meta states a value outside the time datetime grammar, the memory file parser shall report a document violation rather than dropping the value. | implemented |
test |
datetimeViolations (packages/html/src/parse.ts:200) over DATETIME_METAS (:173): memhtml-created, memhtml-updated, memhtml-valid-from, memhtml-valid-until and memhtml-archived, with memhtml-due taking the same rule in taskViolations (:247). A violation and not a dropped optional, because a drop is only safe where the files table owns a default and an instant has none: dropping an unsortable memhtml-valid-until WIDENS the window to always-valid, so the file would answer an as-of query for instants it said the fact was already dead. Refusing at the parse boundary is what keeps the column clean, since every consumer downstream compares these values without re-parsing them. |
2.13. GYM
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
GYM-1 |
The lived-in-corpus gym shall provide age, supersedence chains, access history, and returning-user ground truth as a benchmark. | draft |
analysis |
no campaign benchmark exercises salience (fresh store per row); T2’s rule is argued from mechanism, not measured. One gym serves salience validation, RRF weight tuning, AND the PROX-1 threshold |
2.14. HOP
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
HOP-1 |
When a search request carries an entity scope taken from a prior hit’s entities list, the memory_search shall restrict candidate memories to those carrying that entity. | implemented |
test |
entity-scoped search exists; the new part is accepting the scope on the wire |
HOP-2 |
The memory_search shall resolve a two-hop entity chain in at most two tool calls. | draft |
analysis |
measure on the same MAB multi-hop rows; baseline 4+ calls under the 8-turn budget |
HOP-3 |
If an entity scope matches no active memory, then the memory_search shall not fall back to unscoped results silently. | implemented |
test |
an empty scoped result must be visibly empty. Silent widening lies about provenance |
HOP-4 |
The memory-graph walk shall probe an index on every arm in both directions rather than scanning the edges table. | implemented |
test |
edges_src and edges_dst carry NO predicate (0011_edge_indexes.sql). SQLite uses a partial index only when the query’s WHERE implies the index’s, and the walk filters edge_class while SELECTing derived, so a WHERE derived = 0 index is not a candidate: measured 2026-08-26 with the predicate restored, each dst_path arm falls back to a full SCAN of edges while the src_path arms probe the primary key’s automatic index. The constraint LIST is therefore the assertion, since a check for “does something probe” is satisfied by the primary key alone and says nothing about the dst_path arms: (src_path=? AND edge_class=?) and (dst_path=? AND edge_class=?). packages/index/tests/migrations.test.ts takes the plan with and without the predicate over one schema; apps/cli/tests/e2e.test.ts EXPLAINs the statement neighborsQuery itself builds, for the unfiltered walk and for a rel-scoped one — the rel filter is a filter and not the reason an arm probes, since hop one binds the directional indexes with or without it and naming a rel only moves hop two’s inner arm onto edges_rel. |
2.15. IDX
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
IDX-1 |
The indexer shall derive every index row from the git tree alone. | implemented |
test |
rebuild determinism suite; rm index.db && rebuild is a pure function of the tree |
IDX-2 |
When a batch write lands, the indexer shall reindex exactly once for the batch. | implemented |
test |
none stated |
IDX-3 |
When update runs with embeddings on, the indexer shall scan only the batch’s own chunk ids for pending embeddings. | implemented |
test |
probe-embed-cost.mjs: 56ms@10k -> 4ms flat; shape-asserted SQL |
IDX-4 |
When rebuild runs with embeddings on, the indexer shall scan the whole chunks table for pending embeddings. | implemented |
test |
model-migration path keeps the full scan |
IDX-5 |
When an embedding call fails, the indexer shall keep the lexical index serving. | implemented |
test |
embed failures non-fatal; lexical floor |
IDX-6 |
The projection shall derive the frame_key column from the gist via the pure ported frame function. | implemented |
test |
differential vs eval reference over 20,060 inputs, 0 mismatches; migration 0009 |
IDX-7 |
When rebuild runs and the stored embed model equals the configured one, the indexer shall preserve every embeddings row whose chunk id exists after the reprojection. | implemented |
test |
truncateForRebuild (packages/index/src/indexer.ts) copies the embeddings rows in the configured space into temp.embeddings_stash in the same transaction as the deletes, and restoreEmbeddings re-inserts every row whose chunk_id is in chunks after the projections land and before the watermark is written, so the IndexStale window closes only once the vectors are back. Chunk ids are sha256(content_hash:ordinal), so a chunk that comes back with the same id has the same text. On a model change the stash is empty by construction (WHERE model = configured), which keeps rebuild --embed the migration path with one statement rather than two branches. RebuildReport.embeddingsPreserved is the count. packages/index/tests/indexer.test.ts (issue #142 block) asserts count and byte-identical vec across a forced --no-embed rebuild, zero embed calls on a --embed rebuild over an unchanged tree, no orphan after a body edit and a removal, and an empty plane after a model change; tests-integration/tests/embed-backfill.test.ts asserts the hex snapshot through the CLI. Mutation-verified 2026-09-05: WHERE 0 on the restore INSERT fails four tests with expected +0 to be 20. Motivating incident: a --no-embed rebuild plus ten hours of updates left 183 embeddings under 9,332 chunks. |
IDX-8 |
If index rebuild is asked with embed off over a store whose embeddings table is non-empty in the configured space and force is not set, then the indexer shall refuse with RebuildNoEmbedRefused naming the count. | implemented |
test |
refuseNoEmbedOverVectors (packages/index/src/indexer.ts) runs after guardEmbedModel, so every vector it counts is in the configured space, logs a WARN naming the count to stderr, and fails with RebuildNoEmbedRefused(embeddings, model); the CLI maps it to ERR_REBUILD_NO_EMBED_REFUSED (exit 1) with the count in the error prose and suggestions naming rebuild --embed, rebuild --no-embed --force, and index embed. --force is a declared flag on index rebuild. update’s first-index fallthrough calls reproject directly, so an update can never raise it. --no-embed is the harness flag and it was run against a live store by accident. packages/index/tests/indexer.test.ts asserts the refusal, the untouched count, the forced success, and no refusal over an empty plane; tests-integration/tests/embed-backfill.test.ts asserts the code, exit 1, the count in the prose, and the WARN on the built binary’s stderr under MEMHTML_EMBED=off. Mutation-verified 2026-09-05: flipping === 0 to !== 0 fails the refusal test with expected Success to be Failure. |
IDX-9 |
The indexer shall expose a backfill that runs the unscoped pending scan in persisted slices and reports the vectors written and the chunks still without one. | implemented |
test |
IndexerShape.backfill (packages/index/src/indexer.ts) is the bare embedMissing() plus countPending over the same PENDING_JOIN and PENDING_PREDICATE the scan uses, answered as BackfillReport (headSha, chunks, embeddings, embeddingsWritten, embeddingsRemaining); dryRun writes nothing. memhtml index embed (apps/cli/src/commands.ts, apps/cli/src/run.ts) answers index.report with mode embed. With no embedder configured it writes 0 and reports the remaining gap. It touches only embeddings, so search keeps serving on every arm while it runs and it is safe to rerun. packages/index/tests/indexer.test.ts asserts that update --embed never revisits a chunk that lost its vector and that backfill closes the gap to 0 and is idempotent; tests-integration/tests/embed-backfill.test.ts drives the same through the CLI and through the built binary under MEMHTML_EMBED=off. |
2.16. INT
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
INT-1 |
The integrations install shall write exactly the files and fragments its receipt’s entries name, and shall write that receipt last. | implemented |
test |
The receipt is the whole ownership story: list reads it to say what is installed, doctor re-hashes disk against it to tell an upgrade from an operator’s edit, and uninstall removes only the entries it names, so a hook or MCP entry a human added is never collateral. Receipt LAST because the ordering is what keeps the two honest under a crash: a receipt written first would claim writes that never landed and license a delete of bytes nobody wrote. packages/integrations/tests/transaction.test.ts asserts the receipt is the final write and that every entry it names exists with the sha256 it claims; tests-integration/tests/integrations.test.ts installs each host through the built binary under a throwaway HOME and asserts the host’s whole file set on disk. |
INT-2 |
If a managed file or fragment differs from what the receipt claims, then the integrations shall not write anything: install without --force and uninstall shall both refuse with ERR_INTEGRATION_MODIFIED. |
implemented |
test |
A modified fragment is a human’s edit sitting inside a file this package co-owns, so overwriting it and deleting it are the same defect. compareEntries (packages/integrations/src/receipt.ts) hashes every entry against disk and reports modified, and a MISSING fragment is drift rather than its own state: someone deleting our hook and someone editing it both mean the config no longer matches the receipt. --force is the only door through, and it keeps the prior bytes beside the file as a timestamped backup the report names. packages/integrations/tests/transaction.test.ts refuses both verbs on a hand-edited fragment and asserts the tree is byte-identical afterwards; tests-integration/tests/integrations.test.ts asserts uninstall restores every shared file to its pre-install bytes and leaves nothing of the skill behind. |
INT-3 |
When an install fails part way through, the integrations shall restore every file it touched to its prior bytes. | implemented |
test |
One install writes into several files a host is already using, so a half-applied install leaves an agent with an MCP entry and no hooks, or a hooks file whose JSON no longer parses. Every write is snapshotted before it lands and the transaction unwinds in reverse order on any failure, including a file that did not exist, which is removed rather than left empty. packages/integrations/tests/transaction.test.ts injects a failure at each step and asserts a tree digest identical to the pre-install one, which is the same literal reading of byte-identical STORE-2 uses. |
INT-4 |
The integrations hook shall write the host’s own protocol or nothing at all, shall exit 0 on every failure, and shall never write a memory. | implemented |
test |
A hook runs in front of a user’s turn on a machine this project does not own, so the failure posture is the requirement: exit 0 always, because a non-zero exit blocks a turn on some hosts, and print nothing rather than an error, because stdout is the model’s context. It is also the one command whose stdout is NOT the envelope, which is declared in RESPONSE_TYPES as hook.output for the manifest’s sake. Read-only by construction: the engine runs recall and transcript indexing and holds no writer. packages/integrations/tests/render-hooks.test.ts pins each host’s entry shape and timeout; apps/cli/tests/hook.test.ts asserts exit 0 and empty stdout on an absent store, an unreadable payload, and a timeout, and that no commit is made; scripts/smoke-package.mjs drives it from the installed tarball and refuses stdout carrying apiVersion. |
INT-5 |
While a host cannot inject context on an event, the integrations shall install no hook for that event. | implemented |
test |
Cursor’s beforeSubmitPrompt can cancel a turn and cannot add context, so a per-prompt hook there would spawn a process per prompt and throw the answer away. The same rule keeps a compaction hook off Codex, where no transcript reader for that host’s format exists. Which events a host can inject on is a vendor fact recorded in packages/integrations/src/dialect.ts and consumed by the per-host renderers rather than restated per call site. packages/integrations/tests/render-hooks.test.ts asserts cursorHooks writes sessionStart alone at every hook mode, and render-hooks plus the dialect suite assert renderHookOutput answers the empty string for every event a host ignores. |
INT-6 |
The integrations shall render the same RECALL_DISCIPLINE paragraphs into the instruction block, the skill, and the manifest’s recall-discipline guide topic. | implemented |
test |
Three surfaces tell an agent when to search and when to trust what it read, and prose duplicated across three renderers drifts one surface at a time, which is invisible: each artifact reads complete. RECALL_DISCIPLINE in packages/contracts/src/guidance.ts is the source: the block and the skill spread its paragraphs, and the manifest’s recall-discipline guide block is its RECALL_DISCIPLINE_TEXT joining (apps/cli/src/commands.ts). packages/integrations/tests/render-block.test.ts and render-skill.test.ts assert each rendered artifact carries every paragraph of the constant, so the manifest, AGENTS.md, and the docs site’s guide topic all read one text. |
2.17. INV
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
INV-1 |
The every new assist shall propose rather than mutate. | approved |
inspection |
BEAM doctrine: the store never auto-archives on a heuristic; applies to TRACE-5, PROX-1, BACKFILL-1 |
INV-2 |
The every index feature shall keep rebuild a pure function of the tree. | approved |
test |
rebuildability contract; DET-4 is the shipped instance; BACKFILL-1 must stamp files, never index rows |
INV-3 |
The every scheduled model call shall degrade to a skipped-not-failed report without credentials. | approved |
test |
MEMHTML_LLM=off honesty; TRACE-4 is the next instance; CI stays credential-free |
2.18. NEAR
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
NEAR-1 |
When detect_near_duplicates is on and an op’s text sits at or above the near-duplicate cosine floor against an active memory’s first-chunk embedding, the batch door shall attach the match, its measured similarity, and the stored claim to the op’s near_duplicates field. | implemented |
test |
the floor is @memhtml/domain’s NEAR_DUPLICATE_THRESHOLD (0.92), the same constant dedup-merge mines at; at-or-above locked by an exact-floor unit test, killed by a > mutation |
NEAR-2 |
When detect_near_duplicates is on and an op’s text sits at or above the floor against an earlier op in the same batch, the batch door shall attach the earlier op’s caller-space index to the later op’s near_duplicates field. | implemented |
test |
asymmetric fold (later reports on earlier); survivor-space translation through originOf locked at the MCP door by the XOR-shift wire test, the same trap CONF-2 recorded |
NEAR-3 |
If a near-duplicate is detected, then the near-duplicate assist shall not alter, block, or archive any write. | implemented |
test |
propose-only: severing the assist from batchWrite fires 5 tests; cosine is geometry and misses polarity, so folding belongs to dedup-merge’s guarded night pass or an explicit memory_correct |
NEAR-4 |
When no document embedder is bound, the embed call fails, or the corpus vector read fails, the near-duplicate assist shall report near_duplicates_degraded true with every finding null and every write unchanged. | implemented |
test |
unlike CONF-4’s silent degrade, the outcome is reported in-band: an embedding assist has a standing off state (MEMHTML_EMBED=off) and a null finding under it must read as UNCHECKED, never as unique |
NEAR-5 |
The near-duplicate assist shall embed the whole batch in one document-space call and read the corpus vectors in one query. | implemented |
test |
CONF-5’s quadratic-write-cost lesson, one lane over; document input_type, not search_query, because stored chunk vectors are document-space (Cohere splits the regions); batching locked by a query-count test |
2.19. PERF
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
PERF-1 |
While stores pass fifty thousand files, the write path shall attribute and reduce the residual embeddings-on per-batch cost. | draft |
analysis |
probe rig probe-embed-cost.mjs is the instrument |
2.20. PROX
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
PROX-1 |
While the proximity layer is enabled and a measured threshold exists, the conflict assist shall propose paraphrase conflicts from vector proximity through the same per-op conflict field. | draft |
analysis |
threshold MEASURED on a labeled near-duplicate corpus first (fence-detector discipline); frame keys stay the credential-free floor |
PROX-2 |
If the proximity layer runs without a measured threshold, then the conflict assist shall not stamp or propose anything. | draft |
inspection |
none stated |
2.21. RET
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
RET-1 |
The retrieval shall fuse the fts, vector, recency, and salience arms with reciprocal rank fusion. | implemented |
test |
none stated |
RET-2 |
While the state plane is absent, the retrieval shall drop the salience arm structurally. | implemented |
test |
none stated |
RET-3 |
The retrieval shall exclude task memories from unscoped search by default. | implemented |
test |
scope.ts EXCLUDED_BY_DEFAULT |
RET-4 |
When a caller names facet predicates, the retrieval shall require every distinct facet name and admit any named value under one name. | implemented |
test |
scope.ts facetConditions; retrieval-sql.test.ts the facet axis |
RET-5 |
While the share of indexed chunks carrying a vector in the configured space is below the coverage floor, the retrieval shall drop the vector arm without embedding the query, report degraded, and report the coverage ratio. | implemented |
test |
packages/index/src/vector-coverage.ts readVectorCoverage (one statement, configured model only, 1 on zero chunks); retrieval.ts gatedQueryVector; retrieval.test.ts the vector coverage gate (2 percent coverage on the newest files, exact title ranks first, degraded true, zero embed calls); MEMHTML_VECTOR_COVERAGE_FLOOR through layerRetrievalPolicy (apps/cli/src/api-layer.ts). Mutation: flipping the comparison fails all four gate tests. |
2.22. ROUTE
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
ROUTE-1 |
The sleep cycle shall route every structured phase to a model whose strict json_schema mode does constrained decoding. | implemented |
test |
DEFAULT_MODELS and modelFor (packages/sleep/src/env.ts:76, :88) assign a ModelKey per LLM_PHASES member, resolving caller override, then this map, then sonnet-5. Every structured phase names gpt-5.6-sol for one wire property rather than a model-quality judgment: its strict json_schema mode does constrained decoding on Bedrock, so an off-schema answer cannot be generated at all, including the double-encoded-string shape that skipped 13 batches in one Claude-5 run (issue #53). The Claude 5 models reject strict and output_config.format on every Bedrock surface (probed live 2026-08-22), so with them the schema is a request the decode enforces after the fact and a violated batch is work lost. trace-consolidation names opus-5 to AGREE with the pin its eve agent carries in apps/consolidator/agent/agent.ts, which this map cannot reach, so a reader comparing the two finds one answer. |
ROUTE-2 |
If a model answers a phase’s tool with a payload the schema does not satisfy, then the structured decode shall not supply a default, coerce a field, or accept an extra key. | implemented |
test |
packages/llm/src/structured.ts: every path out returns either a value satisfying the schema or a typed LlmContractViolation. There is no lenient decode, because downstream code cannot tell a coerced object from a real one and the phases consuming these objects archive and rewrite files. ONE repair is allowed and it recovers the payload the model meant rather than inventing one: a top-level array or object field arriving double-encoded as a JSON STRING is parsed once and the SAME strict decode re-runs, so a payload the repair cannot make satisfy the schema still fails with the original violation. A numeric field is declared Schema.Finite, not Schema.Number, whose derived anyOf carries a string branch that invites the string ‘NaN’. The raw payload on a violation is capped at MAX_RAW. |
2.23. SAL
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
SAL-1 |
When memory_read opens a specific path, the salience plane shall bump the path’s access count. | implemented |
test |
guard 1, mutation-proven (remove bump -> 3 failures) |
SAL-2 |
If search or recall merely returns a path, then the salience plane shall not bump that path’s access count. | implemented |
test |
guard 2, mutation-proven (re-add bump -> 2 failures) |
SAL-3 |
The salience arm shall exclude task rows and person-reference records from ranking contribution. | implemented |
test |
guard 3 asserts the assembled SUM(s), not hit.score (MMR proxy trap) |
SAL-4 |
When a second bump arrives for a path inside the 900 second cooldown, the salience plane shall report the path as cooled down without moving the access row. | implemented |
test |
SQL guard + pure twin pinned to agree at the boundary by property test |
SAL-5 |
The reinforce writer shall remain the only writer of the access table. | implemented |
inspection |
one-SQL-writer rule; swap moved callers, never added a writer |
2.24. SLEEP
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
SLEEP-1 |
The sleep edge-typing phase shall report conflicts without resolving them. | implemented |
inspection |
detection-only by design; choosing a winner is a one-way door |
SLEEP-2 |
If a sleep phase reads the corpus, then the sleep cycle shall not bump salience access counts. | implemented |
inspection |
sleep bypasses the tool path |
SLEEP-3 |
When an active memory not itself a task states an unresolved commitment in its own text, the sleep task-detection phase shall mint the task as its own reviewable commit carrying the evidence it was detected from. | implemented |
test |
packages/sleep/src/phases/task-detection.ts, phase 13 of SLEEP_PHASES and a member of LLM_PHASES. Absent from NON_COMMITTING_PHASES because it commits what it mints. Deterministic guards after the model answers: the key must resolve to an offered member, the confidence must clear TASK_DETECT_FLOOR, and the sentence must exist VERBATIM in the cited file’s own article text, which mintDetectedTask checks by reading the file. recentActiveMemories excludes task in SQL so the detector cannot scan its own output; mutation-verified in packages/sleep/tests/task-detection.test.ts (deleting the SQL filter fails it). |
SLEEP-4 |
When a task-detection night has no model bound, no candidate, or nothing above its floor, the sleep task-detection phase shall report ok with counts and a reason. | implemented |
test |
INV-3 in this phase: a credential-free run is not a broken run. packages/sleep/src/phases/task-detection.ts. closeVanishedDetections runs only on a night that reached every batch, because liveKeys otherwise describes what the phase managed to look at rather than what still exists. |
SLEEP-5 |
While a run passes --deep, the sleep placement-triage phase shall propose a topic directory for each communityless inbox memory and git mv the confident ones in one commit. |
implemented |
test |
packages/sleep/src/phases/placement-triage.ts, phase 14 of SLEEP_PHASES (issue #63). It rewrites inbound hrefs itself and therefore precedes integrity, whose dangling-href repair chases a target into the ARCHIVE only. Guardrails enforced in code after the model answers: a destination must be an existing directory or one of at most PLACEMENT_NEW_DIR_CAP new areas/<topic> or resources/<topic> per run; the inbox itself, areas/arcs, resources/people, anything under archive/ and anything outside the PARA buckets refuse; tasks never move; and a file a phase whose write carries a DECISION touched this run never moves, nor does one this phase already moved. That set is the union of the per-commit diffs of base..HEAD commits whose Memhtml-Phase trailer is not in SWEEP_PHASES (packages/sleep/src/touched.ts), so a corpus-wide metadata sweep does not pin the corpus while a phase that authors a <link> or mints a path does — issue #81, where counting confidence-decay’s restamp of every eligible file refused 1685 of 1744 proposals. A commit whose trailer is missing or names no known phase pins. An unreadable trailer or diff WIDENS the set to the whole range rather than emptying it, and a set neither read can establish leaves the phase reporting a reason before its first model call. Each refusal class is its own count beside the refused total (PLACEMENT_REFUSALS). packages/sleep/tests/deep.test.ts guard g; packages/store/tests/git.test.ts diffTreeNames. |
SLEEP-6 |
If a run does not pass --deep, then the sleep placement-triage phase shall not read, write, or commit anything. |
implemented |
test |
The whole no-flag contract: packages/sleep/src/phases/placement-triage.ts returns immediately with a reason before any read, so a run without the flag behaves as it does with the phase absent from SLEEP_PHASES. Same shape compress has for a missing model. |
SLEEP-7 |
If preflight fails, then the sleep cycle shall not commit anything from a later phase. | implemented |
test |
HARD_PREREQUISITES (packages/sleep/src/contract.ts:107) spells preflight against each of the sixteen later phases as a literal pair, so a failed preflight blocks the whole run rather than one dependent. Each preflight failure makes every later commit wrong rather than merely unhelpful: requireCleanTree failing means a later phase commits the operator’s own bytes under sleep’s trailers, EmbedModelMismatch is a half-migrated vector space that degrades every cosine while each vector stays well-formed, and IndexStale means every later phase’s counts describe a corpus fragment. packages/sleep/tests/units.test.ts asserts dependentsOf(‘preflight’) is every later phase, so a phase added to SLEEP_PHASES without a pair fails a test. |
SLEEP-8 |
When a phase earns a state-plane write that no branch discard could undo, the sleep cycle shall record the write in the run’s committed pending ledger instead of performing it. | implemented |
test |
pendingMarksPath and recordPendingMarks (packages/sleep/src/contract.ts:351, :501) write .memhtml/sleep/<run-id>.pending.jsonl on the run’s own branch, staged by the phase that earned the mark. Three kinds, from trace-consolidation, edge-typing and entity-resolution: session-consolidated, edge-promoted, entity-promoted. A committed artifact rather than a table, for the reason the trailers are the resume mechanism: a run’s facts are its commits, and a table would disagree with the branch exactly on a branch that was reviewed and thrown away. The consolidation watermark is an ANTI-JOIN, so a row written during a phase would leave a transcript gone with a row asserting it was handled. packages/sleep/tests/abort-marks.test.ts. A fourth kind, commitment-below-floor, is a RECORD rather than a deferred write: applyPendingMarks (packages/sleep/src/sql.ts) counts it as applied and executes no SQL for it (isStateWriteMark); the report renders it under a fold (packages/sleep/src/report.ts). |
SLEEP-9 |
When a merge’s fast-forward of main succeeds, the sleep merge shall apply the run’s pending ledger and report both marksPending and marksApplied. | implemented |
test |
applyMarks (packages/sleep/src/review.ts) reads the ledger as a BLOB at the branch tip, never off the working tree, so an uncommitted file a discarded run of the same date left behind cannot be honoured. TWO numbers because they answer different questions: marksPending is what the branch earned and marksApplied is what the plane took, so a disagreement is the operator-visible reading of a plane write that did not land. A failed apply does not fail the merge, because main has already moved and every mark is bookkeeping whose absence costs a repeat rather than a loss. packages/sleep/tests/abort-marks.test.ts ‘applies every mark once, reports what it applied, and stays put on a second apply’ and ‘applies NOTHING when the pre-merge gate refuses’. |
SLEEP-10 |
If a sleep branch is discarded with git branch -D, then the state plane shall not carry a write that branch earned. | implemented |
test |
git branch -D is this design’s abort and main never moves during a run, so discarding a branch discards everything the run decided, the deferred state-plane writes included. The sessions in a discarded run are re-offered and every promotion stays unset, which costs a model call next cycle and loses nothing. Entity corroboration COUNTERS deliberately stay at phase time: detections counts nights on which a model read the corpus and proposed the merge, and a discarded night did both. packages/sleep/tests/abort-marks.test.ts ‘re-offers every session and leaves every promotion unset after git branch -D’. |
SLEEP-11 |
When a caller asks whether a run would change anything, the sleep plan shall answer from index counts and shall report an explicit unknown for any phase whose candidate count it did not compute. | implemented |
test |
plan (packages/sleep/src/plan.ts) issues a FIXED number of aggregates and runs no phase, which is what makes it cheaper than --dry-run: a dry run executes all seventeen phases, including the neighbor scan that exhausted 70 GB of RSS on a 2,907-file corpus (issue #40), and each model-calling phase then checks env.dryRun. Every signal is a phase’s own predicate CALLED or its clause SHARED — settledSessionCount binds the same WHERE unconsolidatedSessions binds — so a plan cannot report a number the phase disagrees with. dedup-merge and relationship-mining select candidate PAIRS from an n-by-n scan, so their candidate count is not cheaply computable; they carry inputCount plus unknownReason and NO count field, and verdict is three-valued because of them: no-signal requires every counted signal zero, every uncountable input empty, AND indexFresh, so unknown is what a corpus with unreachable work returns. Every count is index.db’s and index.db is a disposable projection, so the plan takes the tree’s HEAD and publishes indexFresh (memhtml status’s own predicate) beside indexedCommit; a clone, a git pull, or a deleted index answers every aggregate with zero for a corpus in any state, and a failed read fails the watermark too, so neither can reach no-signal. packages/sleep/tests/plan.test.ts asserts the statement count is identical over a corpus ten times larger and that no statement’s plan LOOPS one table twice, resolving each plan step’s alias to its table because EXPLAIN QUERY PLAN names the alias and a guard matched against a table name holds by construction. Verified by mutation: collapsing unknown into no-signal, dropping the freshness gate, self-joining one aggregate, fabricating count: 0 on an unknown entry, and adding a per-row statement each failed the case that names them. |
SLEEP-12 |
While the vector plane is in use and covers under half of the indexed chunks, the sleep preflight shall fail with VectorCoverageLow and block every later phase. | implemented |
test |
packages/sleep/src/phases/preflight.ts after the index update; in use means some vector exists or an embedder is bound, so an embedder-less lexical-only store passes; between 0.5 and the soft floor it warns and records vectorCoverage in its counts. run-isolation.test.ts preflight gates the night on vector coverage. Mutation: removing the refusal reports the phase ok. |
SLEEP-13 |
When a merge lands the run’s branch on main, the sleep merge shall project the merged commit into the index and report indexUpdated with the update’s counts. | implemented |
test |
reindex (packages/sleep/src/review.ts) runs the indexer’s incremental update with embeddings on after the fast-forward or merge commit and after the run row is rewritten as merged, so index_state.head_sha names headSha and every memory the run distilled, rewrote, or archived is searchable when the command returns (issue #145). The report carries indexUpdated, indexHeadSha, indexAdded, indexModified, indexRemoved, indexRenamed, embeddingsWritten, indexSkipped. A failed update never fails the merge, because main has already moved: the report says indexUpdated: false with indexError and a stderr WARN names the recovery. tests-integration/tests/sleep.test.ts ‘leaves the index describing the merged head, so the run is searchable when merge returns’ (mutation-verified: with the update call unreachable the watermark stays at the pre-merge commit); packages/sleep/tests/review.test.ts ‘reports a failed index update without failing a merge whose main has moved’. |
2.25. STORE
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
STORE-1 |
When a write op passes the render gate, the store shall commit the memory file in exactly one git commit. | implemented |
test |
tests-integration spawned.ts one-commit lock; commit-per-file mutant killed |
STORE-2 |
When a batch op fails validation before the write phase, the store shall leave the working tree byte-identical. | implemented |
test |
treeDigest assertion, spawned.ts:275 |
STORE-3 |
When a write op’s content hash matches an active non-task memory, the store shall report the existing path as a dedupe. | implemented |
test |
none stated |
STORE-4 |
If a dedupe is reported, then the store shall not write a duplicate file. | implemented |
test |
none stated |
STORE-5 |
While a memory is archived, the store shall exclude its content hash from dedupe matching. | implemented |
test |
partial unique index WHERE archived=0; contracts.test.ts:73 |
STORE-6 |
When memory_correct names an existing target path, the store shall archive the target and write the correction in one commit. | implemented |
test |
none stated |
STORE-7 |
When a write op’s explicit path names a file that already exists or that an earlier op in the same batch claimed, the store shall refuse with a write conflict and write nothing. | implemented |
test |
freePathFor (packages/store/src/store.ts:395) checks an explicit path against BOTH disk and the batch’s claimed set and gets no -2 collision suffix: the caller named one path, so a suffix would hand back a path with no file behind it and an overwrite would delete a memory in a corpus where eviction is a git mv into archive/. The one exemption is vacating, correctMemory’s own target, which the same commit moves into the archive before the correction’s bytes are written. ERR_WRITE_CONFLICT at the CLI edge, whose suggestions are memhtml read <path> then memhtml correct <path> (apps/cli/src/errors.ts:147). packages/store/tests/store.test.ts ‘fails with WriteConflict and leaves the occupant byte-identical’. |
STORE-8 |
When a write op asks for strict placement and its explicit path is not a usable memory path, the store shall refuse with an invalid memory and leave the working tree byte-identical. | implemented |
test |
strictPathRefusal runs ahead of the render gate and the dedupe question in writeMemory, validateOp, and correctMemory (packages/store/src/store.ts), so the refusal is decided before anything touches disk. The reason is memoryPathViolation’s own, which is the same rule isValidMemoryPath asks as a boolean. InvalidMemory rather than WriteConflict: an unusable path cannot be occupied, so ERR_WRITE_CONFLICT’s published recovery (memhtml read then memhtml correct on that path) is two calls that cannot succeed. The lenient re-derivation stays the default because it is shipped behavior. An ABSENT path is the one no-op, so a caller can set the flag once and still let the rule file what it leaves unplaced; a PRESENT blank path is named rather than absent and is refused, which is what a caller’s own path template renders when it produced nothing and is also what stops the two blank spellings from disagreeing. packages/store/tests/store.test.ts ‘refuses with InvalidMemory under strictPath and leaves the tree byte-identical’; tests-integration/tests/contracts.test.ts and mcp-stdio.test.ts assert a tree digest across the refusal at each door. |
2.26. TASK
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
TASK-1 |
The mcp server shall expose the task family as task_add, task_status, and task_list tools. | draft |
test |
shape settled (backlog 4); ToolFailure discipline applies |
2.27. TRACE
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
TRACE-1 |
When the sleep cycle reaches the trace-consolidation phase with unconsolidated trace windows, the trace-consolidation phase shall distill candidate memories from the raw transcript files. | implemented |
test |
Opus 5 global endpoint on Bedrock via @ai-sdk/amazon-bedrock (AI SDK v7), high reasoning effort; agent stack is eve (vercel/eve, beta: ground node_modules/eve/docs at build time) with a LOCAL sandbox backend (Docker/just-bash adapter, NEVER Vercel Sandbox), transcript grep/jq through eve’s sandbox tools; no cost ceiling (decided 2026-08-07, revised from Strands same day) |
TRACE-2 |
The trace-consolidation phase shall write only candidate memories whose signal exceeds what one transcript grep surfaces. | implemented |
inspection |
the very-high-scrutiny bar: raw data already sits in transcripts any agent can jq/grep; high-frequency error shapes are one lens, not exhaustive (decided 2026-08-07) |
TRACE-3 |
The trace-consolidation phase shall land every synthesized memory as a reviewable commit behind the discrimination gate. | implemented |
test |
same discipline as every other sleep mutation |
TRACE-4 |
If the model call fails or the budget of a run is exhausted, then the trace-consolidation phase shall not leave the sleep run red or partially written. | implemented |
test |
skipped-not-failed discipline, mirroring MEMHTML_LLM=off honesty |
TRACE-5 |
When a distilled candidate frame-matches an active memory, the trace-consolidation phase shall surface the conflict through the detect_conflicts assist rather than writing blind. | implemented |
test |
the second producer of synthesized memories consumes the H4 machinery. This is why item 11 waited for item 1 |
2.28. WIRE
| Key | Requirement | Status | Verification | Satisfied by |
|---|---|---|---|---|
WIRE-1 |
The batch result envelope shall carry every field present and nullable rather than optional. | implemented |
test |
NullOr discipline, tools.test.ts key-set locks |
WIRE-2 |
The mcp server shall declare a failure schema on every tool. | implemented |
test |
effect masks undeclared failures as internal server error; wire tests lock |
WIRE-3 |
The cli shall derive the manifest, help, and agent guide from the one commands table. | implemented |
test |
AGENTS.md byte-lock; suggestion strings validated through parseArgv since T3. Per-command help is the third projection: memhtml help <command> / --help / -h render the same COMMANDS entry as a cli.help envelope (piped or --json) or Markdown (terminal), apps/cli/src/help.ts; helpData carries the manifest’s own entry verbatim (apps/cli/tests/help.test.ts), every table example passes parseArgv + validate, and the help path builds no layer (the uncreated-repo probe). |
WIRE-4 |
The cli manifest shall disclose every real configuration variable. | implemented |
test |
MEMHTML_MCP_BIN joined CONFIG_VARS (T3) |
WIRE-5 |
When a sleep run or resume ends with any failed phase, the cli shall exit 1 while emitting the sleep.report success envelope carrying every phase result. | implemented |
test |
sleepExit (apps/cli/src/run.ts:284). A partially-failed run and a fully-aborted run exit the same, because a caller reading the exit code asks one question and both answers are no; the payload already distinguishes them precisely, an abort being every selected phase failed with headSha === baseSha and no commits. The envelope stays a SUCCESS sleep.report because a failure envelope carries no data and the per-phase detail IS the data. sleep status and sleep review are excluded: they report a run they did not perform, and a read that exited non-zero would make ‘tell me what happened’ indistinguishable from ‘I could not tell you’. apps/cli/tests/e2e.test.ts ‘exits 1 on an aborted run, with the report intact’. |
WIRE-6 |
When an invocation carries a positional past what the command declares, the cli shall refuse with ERR_UNEXPECTED_ARGUMENT at exit 2. | implemented |
test |
surplusArgs (apps/cli/src/run.ts:950). A distinct append-only code rather than a reused one: the offending token is not a flag, so it is not ERR_INVALID_FLAG, and it is surplus rather than absent, so it is not ERR_MISSING_ARGUMENT. A repeatable last argument turns the check off, declared in the COMMANDS table so the manifest states it. Without the check memhtml read a.html b.html reads ONE memory and reports nothing about the second. A bare - is exempt on apply and exec, where it names stdin. apps/cli/tests/cli.test.ts:463. |
WIRE-7 |
When an invocation carries a flag this command does not declare, the cli shall refuse with ERR_INVALID_FLAG naming the commands that do take it. | implemented |
test |
validate (apps/cli/src/run.ts:985) builds the allowed set from THIS command’s spec plus GLOBAL_FLAGS, never the union of every command’s flags. An agent that typed memhtml list --status todo meant task list, and silently ignoring the flag would return an unfiltered answer that looks filtered. A flag real elsewhere gets the commands that take it; a flag real nowhere gets the nearest spellings this command does take. apps/cli/tests/cli.test.ts:375 asserts ‘--status is not a flag of list’. |
WIRE-8 |
When a retrieval command carries an --as-of value outside the sortable datetime grammar, or none at all, the cli shall refuse with ERR_INVALID_FLAG at exit 2 before building any service. |
implemented |
test |
asOfFlag (apps/cli/src/run.ts:892) uses the same isValidDatetime the format enforces on <time datetime>, so the values a caller may ASK ABOUT are exactly the values a file may STATE. The flag binds twice into coalesce(valid_from, event_at, created_at) <= ? AND (valid_until IS NULL OR valid_until > ?) where SQLite compares TEXT to TEXT and nothing parses it, so an unsortable value does not error: it silently answers a DIFFERENT question, and the result set looks like a plausible point-in-time view. A bare --as-of parses as boolean true and would answer unscoped, so it is refused too. apps/cli/tests/cli.test.ts:560 and :575. |
3. Provenance
A loader generates this page from spec/memhtml.symspec.json while the site builds, so no file in the repository holds it: the ledger is the source; retiring or adding a requirement is a commit against it. Change the registry and this page changes with it.