# M2.5 — Obsidian projector | Field | Value | |---|---| | Phase | M2 — Projections | | Size | M — 1–3 days | | Status | ⬜ Not started | | Flags | — | | Spec | inlined below | | Blocks | M1.6 | ## Goal Render the log as a vault a human reads, with the tier graph as the link graph. ## Facts (inlined — no spec read needed) ``` vault// index.md L2 synthesis, links every L1 note .md L1, one per standing query evidence/-.md L0, only with --emit-evidence-notes ``` ```markdown --- project: poimen level: L1 query_id: infra-root-causes updated: 2026-08-17 chunks_seen: 412 chunks_used: 17 run_id: 01HXYZ... --- # Infra root causes — poimen ## Provenance - [[pi-2026-07-21-019f857d]] chunk 66 — Kong body buffer ``` **Deterministic output is the requirement, not a nicety.** M2.8 asserts that rebuilding produces a byte-identical vault. That means: stable key order in frontmatter, no timestamp of *generation* (only `updated` derived from the log), sorted provenance lists, and `\n` line endings. `updated` comes from the run's timestamp in the log — not `now()`. A generation timestamp makes every rebuild a diff and destroys the gate. L0 notes default off: 17 per query is fine, but it grows unbounded across projects and queries. Citations inline give the same provenance without the file count. Wikilinks are `[[]]`. The link target may not exist as a file when evidence notes are off — that is fine and normal in Obsidian, and it still shows in the graph view as an unresolved node. ## Steps 1. `ObsidianProjector::project(log_dir, vault_dir, opts)` in `mem-store`. 2. Read the log; take the final `memory` record per query for L1, and the L2 record for `index.md`. 3. Frontmatter with a fixed key order; `updated` from the log. 4. Provenance section from `parents`, sorted by source then `t`. 5. `--emit-evidence-notes` writes L0 notes; default off. 6. Write with `\n`, no trailing whitespace, exactly one trailing newline. 7. A note whose L1 memory is empty is still written, with a body saying no evidence was found — an absent file is indistinguishable from a failed run. ## Acceptance - Two projections of the same log produce byte-identical files. - Frontmatter key order is stable. - `updated` reflects the run, not the projection. - Every L1 note links to its L2 index and vice versa. ## Verify **Harness:** a committed log fixture and a committed expected vault tree. **Integration test** — `tests/it_projector.rs`: 1. `a1_byte_identical_twice` — project into two temp dirs, assert every file's bytes are equal. This is M2.8's core property, tested early. 2. `a2_no_generation_timestamp` — project, sleep 1s, project again, assert equal. Catches `now()` leaking into output. 3. `a3_frontmatter_key_order` — assert the exact key sequence. 4. `a4_golden_tree` — diff the whole output against `expected/vault/`, empty diff. 5. `a5_empty_memory_still_writes` — a log with zero updates produces a note saying so. 6. `a6_links_bidirectional` — every L1 note appears in `index.md` and links back. 7. `a7_evidence_notes_flag` — off by default; on, produces one note per L0 node. 8. `a8_line_endings` — no `\r`, exactly one trailing `\n`. **Command:** `cargo test -p mem-store projector` **False pass:** - Comparing files by parsed content rather than bytes. Key reordering and whitespace churn both pass, and both fail M2.8 later, where the cause is much harder to find. - Testing with a single query. Assertion 6 needs at least two L1 notes to catch a link built from the wrong id. ## Traps - `updated: {now}`. The most natural thing to write, and it makes every rebuild dirty, which trains everyone to ignore the diff that M2.8 depends on. - Serializing frontmatter from a `HashMap`. Iteration order is unspecified and the output churns between runs on the same input. --- Background: [DESIGN.md](../DESIGN.md) — Obsidian vault