4.3 KiB
M0.2 — Domain types and sha256 identity
| Field | Value |
|---|---|
| Phase | M0 — Read-only spine |
| Size | S — under 1 day |
| Status | ✅ Done |
| 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)
/// 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
- Declare
Level,Role,Record,Provenance,Chunk,MemoryNode. - Declare the newtypes. None derives
Default. None hasFrom<String>without validation. fn content_hash(&self) -> Sha256HashonChunkandMemoryNode, over a canonical byte encoding that excludes timestamps and run ids.Levelserializes as the literal strings"L0" | "L1" | "L2"— the JSONL and the SQLCHECKconstraint both depend on that spelling.- Round-trip serde tests for every type.
Acceptance
- Two
Chunks built from identical records in different runs hash identically. - Changing one character of any record text changes the hash.
Levelround-trips through JSON as"L0", not0and not"l0".
Verify
Harness: unit tests in mem-core, plus a hash-stability fixture committed
as bytes.
Integration test — tests/it_identity.rs:
a1_same_content_same_hash— build the same chunk twice with differentRunIdand timestamps, assert equal hashes.a2_text_change_changes_hash— flip one byte, assert the hash differs.a3_level_wire_format—serde_json::to_string(&Level::L0) == "\"L0\"".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.a5_newtypes_have_no_default— compile-fail test (trybuild) assertingProjectId::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_idorcreated_atin the hash. Rebuild then produces new nodes every time andmemory_edgeaccumulates orphans. - Deriving
Defaulton an id newtype "for tests". That default reaches production and every row attributes to the same fake project.
Background: DESIGN.md — The tier model, Storage schemas