2026-08-19 09:52:07 -07:00
|
|
|
# M0.2 — Domain types and sha256 identity
|
|
|
|
|
|
|
|
|
|
| Field | Value |
|
|
|
|
|
|---|---|
|
|
|
|
|
| Phase | M0 — Read-only spine |
|
|
|
|
|
| Size | S — under 1 day |
|
2026-08-22 21:43:23 -07:00
|
|
|
| Status | ✅ Done |
|
2026-08-19 09:52:07 -07:00
|
|
|
| Flags | — |
|
|
|
|
|
| Spec | inlined below |
|
|
|
|
|
| Blocks | M0.1 |
|
|
|
|
|
|
|
|
|
|
## Goal
|
|
|
|
|
|
|
|
|
|
The vocabulary every other crate speaks, and the content-hash identity the whole
|
|
|
|
|
provenance graph hangs on.
|
|
|
|
|
|
|
|
|
|
## Facts (inlined — no spec read needed)
|
|
|
|
|
|
|
|
|
|
```rust
|
|
|
|
|
/// Closed. L0 evidence, L1 per-query memory, L2 project synthesis.
|
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub enum Level { L0, L1, L2 }
|
|
|
|
|
|
|
|
|
|
/// A normalised unit from any source. Adapters produce these; nothing
|
|
|
|
|
/// downstream learns whether it came from pi, claude, or a socket.
|
|
|
|
|
pub struct Record {
|
|
|
|
|
pub role: Role, // User | Assistant | ToolResult | System
|
|
|
|
|
pub text: String,
|
|
|
|
|
pub timestamp: OffsetDateTime,
|
|
|
|
|
pub provenance: Provenance, // source id + offset within it
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One or more Records, under the token budget, never split mid-Record.
|
|
|
|
|
pub struct Chunk {
|
|
|
|
|
pub t: u32, // 1-based turn index within a run
|
|
|
|
|
pub records: Vec<Record>,
|
|
|
|
|
pub tokens: usize,
|
|
|
|
|
pub sha256: Sha256Hash,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub struct MemoryNode {
|
|
|
|
|
pub level: Level,
|
|
|
|
|
pub project: ProjectId,
|
|
|
|
|
pub query_id: Option<QueryId>, // None at L2
|
|
|
|
|
pub run_id: RunId,
|
|
|
|
|
pub t: u32,
|
|
|
|
|
pub text: String,
|
|
|
|
|
pub sha256: Sha256Hash,
|
|
|
|
|
pub parents: Vec<Sha256Hash>,
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**Identity is the content hash, not a counter.** `sha256` is computed over the
|
|
|
|
|
canonical serialization of the semantic content — for `Chunk`, the concatenated
|
|
|
|
|
record texts and their provenance; for `MemoryNode`, `(level, project, query_id,
|
|
|
|
|
text)`. It must **not** include the timestamp or the run id, or re-running the
|
|
|
|
|
same input produces different hashes and `mem rebuild` stops being idempotent.
|
|
|
|
|
|
|
|
|
|
Newtypes with no `Default`: `ProjectId`, `QueryId`, `RunId`, `Sha256Hash`. A
|
|
|
|
|
placeholder that type-checks is invisible — a hardcoded `"current"` compiles,
|
|
|
|
|
passes tests, and makes every downstream result unattributable.
|
|
|
|
|
|
|
|
|
|
## Steps
|
|
|
|
|
|
|
|
|
|
1. Declare `Level`, `Role`, `Record`, `Provenance`, `Chunk`, `MemoryNode`.
|
|
|
|
|
2. Declare the newtypes. None derives `Default`. None has `From<String>` without
|
|
|
|
|
validation.
|
|
|
|
|
3. `fn content_hash(&self) -> Sha256Hash` on `Chunk` and `MemoryNode`, over a
|
|
|
|
|
canonical byte encoding that excludes timestamps and run ids.
|
|
|
|
|
4. `Level` serializes as the literal strings `"L0" | "L1" | "L2"` — the JSONL and
|
|
|
|
|
the SQL `CHECK` constraint both depend on that spelling.
|
|
|
|
|
5. Round-trip serde tests for every type.
|
|
|
|
|
|
|
|
|
|
## Acceptance
|
|
|
|
|
|
|
|
|
|
- Two `Chunk`s built from identical records in different runs hash identically.
|
|
|
|
|
- Changing one character of any record text changes the hash.
|
|
|
|
|
- `Level` round-trips through JSON as `"L0"`, not `0` and not `"l0"`.
|
|
|
|
|
|
|
|
|
|
## Verify
|
|
|
|
|
|
|
|
|
|
**Harness:** unit tests in `mem-core`, plus a hash-stability fixture committed
|
|
|
|
|
as bytes.
|
|
|
|
|
|
|
|
|
|
**Integration test** — `tests/it_identity.rs`:
|
|
|
|
|
1. `a1_same_content_same_hash` — build the same chunk twice with different
|
|
|
|
|
`RunId` and timestamps, assert equal hashes.
|
|
|
|
|
2. `a2_text_change_changes_hash` — flip one byte, assert the hash differs.
|
|
|
|
|
3. `a3_level_wire_format` — `serde_json::to_string(&Level::L0) == "\"L0\""`.
|
|
|
|
|
4. `a4_hash_stability_across_versions` — hash a committed fixture record set,
|
|
|
|
|
assert it equals a hash literal written into the test. This catches a
|
|
|
|
|
canonicalization change that would silently orphan every stored node.
|
|
|
|
|
5. `a5_newtypes_have_no_default` — compile-fail test (`trybuild`) asserting
|
|
|
|
|
`ProjectId::default()` does not compile.
|
|
|
|
|
|
|
|
|
|
**Command:** `cargo test -p mem-core identity`
|
|
|
|
|
|
|
|
|
|
**False pass:**
|
|
|
|
|
- Asserting only that hashing is deterministic *within one process*. A hash that
|
|
|
|
|
includes the timestamp is deterministic per run and still breaks rebuild.
|
|
|
|
|
Assertion 1 must vary the run id and timestamp deliberately.
|
|
|
|
|
- Omitting assertion 4. Without a committed expected hash, any future
|
|
|
|
|
canonicalization change passes every other test and silently invalidates the
|
|
|
|
|
database.
|
|
|
|
|
|
|
|
|
|
## Traps
|
|
|
|
|
|
|
|
|
|
- Including `run_id` or `created_at` in the hash. Rebuild then produces new nodes
|
|
|
|
|
every time and `memory_edge` accumulates orphans.
|
|
|
|
|
- Deriving `Default` on an id newtype "for tests". That default reaches
|
|
|
|
|
production and every row attributes to the same fake project.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
Background: [DESIGN.md](../DESIGN.md) — The tier model, Storage schemas
|