P0.7.2 — Step 1 Audit
Inventory of the worktree against the task spec for P0.7.2 ζ Thought Record CRUD (ζ Decision Trail task group, second ζ task, Wave D parallel dispatch). Scope: what already exists that the new repository + MCP tools must integrate with, what the P0.7.1 primitives lock, and what is still absent.
Baseline: worktree E:/AMS/.worktrees/claude/p0-7-2-thought-crud/ at commit 6c26bb58 (P0.7.1 merged as PR #124, on top of P0.6.1 cf1250ca + P0.3.1 b8a036a6 + P0.2.3 92df616d).
§1. Pre-clean state
$ git status
On branch feature/p0-7-2-thought-crud
Your branch is up to date with 'origin/main'.
nothing to commit, working tree clean
$ git diff --stat
(empty)
$ git log -1 --format=%s
feat(p0-7-1): ζ hash-chained record schema — SHA-256 canonical JSON + ZERO_HASH genesis (#124)
No cross-worktree leak. The Wave C P0.7.1 bug (cross-worktree leak of src/server.ts edit) is not present here. Starting state is pristine main.
§2. Surface being added
Targets this task creates:
src/domains/trail/repository.ts— new module. HoldscreateThoughtRecord(db, input),getThoughtRecord(db, id),listThoughtRecords(db, filters),registerThoughtTools(ctx, db). Consumes P0.7.1 primitives (computeHash,canonicalize,ZERO_HASH,ThoughtType,ThoughtRecord,ThoughtRecordSchema).src/__tests__/domains/trail/repository.test.ts— new nested test file. First use ofsrc/__tests__/domains/<concept>/layout (Wave A-C used flatsrc/__tests__/<concept>-<suffix>.test.ts). JesttestMatch: ['**/__tests__/**/*.test.ts', ...]supports nested discovery;roots: ['<rootDir>/src']covers it.src/db/migrations/004_thought_records.sql— number 004 pre-assigned by Sigma (P0.3.2 owns 002, P0.6.2 owns 003). Introducesthought_recordstable + two indexes.src/db/schema.sql— append thought_records ownership block (comment-only asset, non-executable).src/server.ts— small edit inbootstrap(): register ζ tools afterserver_ping. Expected 3-way rebase collision with P0.2.4 / P0.6.2.
A worktree scan confirms absence of authoring targets:
$ ls src/domains/trail/
schema.ts # P0.7.1 only — repository.ts absent
$ ls src/db/migrations/
001_init.sql # 002/003/004 all absent
$ ls src/__tests__/domains/ # directory absent
§3. Adjacent code — consumers and dependencies
3a. src/domains/trail/schema.ts (P0.7.1 — 188 LOC, commit 6c26bb58)
Exports exactly 7 identifiers this task will consume:
| Export | Kind | Consumed by |
|---|---|---|
THOUGHT_TYPES |
readonly [...] tuple of 4 literals |
createThoughtRecord input validation (via Zod z.enum) |
ThoughtType |
type alias union | createThoughtRecord input type + tool Zod schema |
ZERO_HASH |
string (64 zeros) |
createThoughtRecord genesis fallback when per-task chain empty |
ThoughtRecordSchema |
z.ZodObject |
Not directly consumed — repository enforces stricter shape via explicit types |
ThoughtRecord |
z.infer<...> type |
Return type for createThoughtRecord / getThoughtRecord |
canonicalize |
pure fn | Not directly consumed — internal to computeHash |
computeHash |
pure fn over 6-field subset | Invoked once in createThoughtRecord to produce each record’s hash |
Load-bearing field constraints from P0.7.1 Zod schema (schema.ts lines 80-89):
export const ThoughtRecordSchema = z.object({
id: z.string().min(1), // REQUIRED non-empty
type: z.enum(THOUGHT_TYPES), // REQUIRED, one of 4 values
task_id: z.string().min(1), // REQUIRED non-empty (NOT nullable)
agent_id: z.string().min(1), // REQUIRED non-empty (NOT nullable)
content: z.string(), // REQUIRED, empty-string allowed
timestamp: z.string().min(1), // REQUIRED non-empty
prev_hash: z.string().length(64), // REQUIRED exactly 64 chars
hash: z.string().length(64), // REQUIRED exactly 64 chars
});
Deviation from dispatch prompt §7: The prompt baseline says task_id TEXT (nullable — global thoughts have no task) and agent_id excluded from hash per P0.7.1. However, P0.7.1’s Zod schema has both task_id and agent_id as .min(1) required non-empty strings. The prompt itself §5 says “reuse P0.7.1 primitives; do NOT reimplement”. Decision: honour P0.7.1’s Zod schema as source of truth. Both task_id and agent_id are required non-empty. There is no “global thought” path in Phase 0 — every thought record belongs to a task. If a caller lacks a task_id, they must mint one (or use a sentinel string agreed upstream). This matches P0.7.1 contract §3 “task_id — scopes the chain to a task. Prevents cross-task forgery.”
The agent_id default “claude” is acceptable since all Phase 0 execution is Claude (T4). Callers may override.
3b. src/__tests__/trail-schema.test.ts (P0.7.1 — 353 LOC)
- Fixture value:
VALID_SUBSETwith{id:'r1', type:'plan', task_id:'t1', content:'hello', timestamp:'2026-04-17T00:00:00Z', prev_hash:ZERO_HASH}→ pinned hash6a2f9597f563d5515cfa69891a51806d0f93bfbe222997d3ba37c365ceee3f1a. - Verified via
node -e: the canonical-JSON + SHA-256 of that input reproduces the pinned hash exactly. P0.7.2 tests will re-assert this hash againstcomputeHash(VALID_SUBSET)— serves as a cross-test determinism anchor. - Insertion-order-agnostic tests, Unicode, 1 KB content, type-sensitivity all pinned.
- Test style: flat
describeblocks, no env mutation, nojest.isolateModulesAsync.
3c. src/db/index.ts (P0.2.2 — 320 LOC)
initDb(path) + getDb() + closeDb(). Migration runner applies NNN_*.sql files in numeric order, bumps PRAGMA user_version per migration, wraps each in a transaction.
Consumption pattern for P0.7.2:
- Tests: construct in-memory DBs via
new Database(':memory:')directly, then apply migration 004 SQL manually (pattern:db.exec(migrationSql)). Matches P0.3.1 / P0.6.1 test style when DB-backed (here: first such test in the suite). - Production:
startup.tscallsinitDb(config.COLIBRI_DB_PATH)in Phase 2, which discovers and applies 004_thought_records.sql automatically. - Repository functions take a
Database.Databaseparameter (dependency injection) so tests don’t need to touchgetDb().
Migration file naming: NNN_<slug>.sql where NNN is a positive integer prefix, followed by underscore, followed by a human-readable slug. Collision detection is built-in (throws on duplicate prefix). 004 is pre-assigned to this task. Filename: 004_thought_records.sql.
3d. src/server.ts (P0.2.1 — 568 LOC) + src/startup.ts (P0.2.3 — 424 LOC)
registerColibriTool(ctx, name, config, handler)— 5-stage α middleware wrapper. Input name is validated viaTOOL_NAME_RE = /^[a-z_][a-z0-9_]*$/(snake_case, leading letter/underscore).thought_recordandthought_record_listboth match.- Handler signature:
(args: z.infer<z.ZodObject<I>>) => Promise<unknown> | unknown. Return value becomesenvelope.datain the{ok: true, data: ...}wire envelope. bootstrap()registersserver_pingthen callsstart(). This task must extendbootstrap()to register ζ tools afterserver_ping. Signature change needed:bootstrap()must thread a DB handle through, or register ζ tools via a post-Phase-2 hook. Currentbootstrap()runs entirely in Phase 1 (no DB) — but the ζ tools need a DB handle at call time, not at registration time.
Resolution: Register ζ tool handlers that lazy-resolve the DB at each invocation via getDb(). This avoids changing the bootstrap() signature and matches the spirit of the Phase 2 handoff (DB is initialized by startup.ts Phase 2 before any tool call can fire; registerColibriTool executes only at registerTool time, not at handler-invocation time, so registering at bootstrap() is safe). The registerThoughtTools(ctx) function doesn’t even need a db argument — handlers call getDb() internally.
For test injection: the repository core functions (createThoughtRecord, getThoughtRecord, listThoughtRecords) take an explicit db argument. Only the registerThoughtTools wrapper closure reaches for getDb(). Tests exercise the core directly (with in-memory DB) AND validate the MCP tool envelope via InMemoryTransport + a DB-setter seam.
Diff to server.ts bootstrap() — minimal, 2 lines imported + 1 call site:
// +import { registerThoughtTools } from './domains/trail/repository.js';
// inside bootstrap(), after registerColibriTool(ctx, 'server_ping', ...):
// +registerThoughtTools(ctx);
Expected 3-way merge collision: P0.2.4 (health tools) + P0.6.2 (skill_list) + this (thought tools) all add similar 1-line post-ping registrations. Sigma to resolve at merge time; packet minimizes the diff to exactly the registerThoughtTools(ctx) addition + the import.
3e. src/__tests__/server.test.ts (P0.2.1 — 1387 LOC)
- Test pattern
makeLinkedPair({ register })— preconnect tool registration. For repository tests, I will reproduce this helper locally (or import via subrelative path if convenient). Each test gets a fresh in-memory DB + in-memory transport linked pair + a Client. - Tool response inspection via
response.structuredContent as { ok, data }.
3f. src/db/migrations/001_init.sql
Empty migration slot; sets precedent that migration files carry a leading header comment block + SQL. 004 will follow the same style.
3g. docs/concepts/ζ-decision-trail.md
File does not exist. R75 Wave B spec gap — flagged by P0.2.2’s audit (per memory). docs/spec/s17-mcp-surface.md §1 does specify the tool list (thought_record, thought_list, audit_session_start, audit_verify_chain, merkle_finalize, merkle_root — 6 tools in Category 2). The spec names the Phase 0 tool thought_list, but the task-breakdown.md line 343 (canonical for this task) names it thought_record_list. Dispatch prompt locks thought_record_list. Packet does not attempt spec reconciliation — that is deferred (probable R76 docs cleanup).
3h. docs/spec/s17-mcp-surface.md §1 + §4 + §6
- §1 Category 2: 6 tools including
thought_recordandthought_list(task-breakdown:thought_record_list). - §4 Middleware: 5-stage α chain. Registered via
registerColibriTool. - §6 Response shape: uniform envelope
{ok: true, data: ...}/{ok: false, error: {...}}. The wrapper handles this; handlers return thedatapayload.
3i. docs/reference/extractions/zeta-decision-trail-extraction.md (donor ref)
- Donor algorithm: two-hash (
content_hash+chain_hash). Colibri simplifies to one hash. P0.7.1 already implemented; P0.7.2 does not revisit. - Donor scope:
session_id. Colibri Phase 0 analog:task_id(P0.7.1 contract §3: “scopes the chain to a task. Prevents cross-task forgery.”) - Donor
thought_recordtool signature:{session_id, type, content, parent_id?, metadata?}. Colibri’s Zod input is different (reflects task_id + agent_id + auto-id). - Donor chain was a tree (multi-child via shared parent_id). Colibri Phase 0 is strict linear chain via
prev_hash(P0.7.1 audit §7 explicitly says “Phase-0 uses a strict linear chain viaprev_hash”; donor branching is deferred to Phase 1+).
3j. docs/reference/mcp-tools-phase-0.md
Referenced by s17 as the per-tool catalogue. Not read in full — the task-breakdown.md line 343 is the canonical tool-name source.
§4. Chain scope resolution — per-task vs global
This is a load-bearing design question. The dispatch prompt §8 says:
“Default:
prev_hash= thehashof the most recent thought_record in the ENTIRE table (global chain), orZERO_HASHif the table is empty. Per-task chains are queried by filter but the underlying chain is global.”
However, the prompt qualifies: “Verify your interpretation in Step 1 Audit by reading P0.7.1 schema + audit/contract/packet docs.”
Reading P0.7.1:
P0.7.1 audit §7 (docs/audits/p0-7-1-trail-schema-audit.md):
“Colibri flattens
task_idandagent_idto top-level fields and renamesparent.chain_hash→prev_hash… Phase-0 uses a strict linear chain viaprev_hash.”
P0.7.1 contract §3 (docs/contracts/p0-7-1-trail-schema-contract.md):
“Chain integrity inputs:
task_id— scopes the chain to a task. Prevents cross-task forgery.”
This language — “scopes the chain to a task” and “prevents cross-task forgery” — explicitly endorses per-task chains. Cross-task forgery can only be prevented if each task has its own chain; a global chain would by construction interleave all tasks and have no “forgery” to prevent.
P0.7.3 dispatch prompt (same task-prompts file, lines 330-410): the verification tool test says “create 5 record chain, verify → valid=true” — singular chain, plural records, matches per-task scoping. And donor (which the extraction endorses) scoped by session_id; Phase 0 analog is task_id.
Decision: per-task chain. createThoughtRecord(input) resolves prev_hash as: the hash of the latest record WHERE task_id = input.task_id (ORDER BY created_at DESC LIMIT 1), or ZERO_HASH if no such record exists.
This is the P0.7.1-contracted interpretation and aligns with P0.7.3’s verifier logic. The prompt’s “global chain default” override is declined; the audit documents the deviation with reasoning.
4a. Implications
createThoughtRecordmust queryWHERE task_id = ?ORDER BY created_at DESC LIMIT 1 to fetchprev_hash.- Index strategy:
(task_id, created_at)ASC is the natural index for bothlistThoughtRecords({task_id})AND the latest-for-task lookup (scan backwards). This matches prompt §7’s baseline index. listThoughtRecords({task_id, limit})filters bytask_id; iftask_idis omitted, returns all records (matches spec). Order is ASC bycreated_at(insertion order, per spec: “returns chain in insertion order”).- An empty
task_idfilter is still valid in the listing method — returns interleaved records from all tasks. This may be useful for ops-level inspection but is NOT a “chain” at that point.
4b. agent_id default
Dispatch prompt §7 baseline shows agent_id TEXT (nullable per prompt’s English), but P0.7.1 Zod schema requires .min(1). Decision: agent_id is required, input-mandatory. Callers supply it. No sensible default exists (the repository doesn’t know what agent is calling). The MCP tool Zod schema requires it.
§5. thought_records table schema
Per dispatch prompt §7 baseline + P0.7.1 Zod constraints, the migration introduces:
CREATE TABLE thought_records (
id TEXT PRIMARY KEY, -- UUID v4 via crypto.randomUUID()
type TEXT NOT NULL, -- one of THOUGHT_TYPES
task_id TEXT NOT NULL, -- required non-empty per P0.7.1 Zod
agent_id TEXT NOT NULL, -- required non-empty per P0.7.1 Zod
content TEXT NOT NULL, -- empty string allowed; NOT NULL
timestamp TEXT NOT NULL, -- ISO-8601, included in hash
prev_hash TEXT NOT NULL, -- 64 hex chars (ZERO_HASH for genesis)
hash TEXT NOT NULL UNIQUE, -- 64 hex chars; UNIQUE enforces P0.7.3 tamper detection
created_at TEXT NOT NULL -- ISO-8601 insertion-time (distinct from timestamp)
);
CREATE INDEX idx_trail_task ON thought_records(task_id, created_at);
CREATE INDEX idx_trail_prev ON thought_records(prev_hash);
Design notes:
hash UNIQUE— two records with identical hash are by construction identical (same 6 hash-input fields). A second insert with the same hash means either a hash collision (astronomically unlikely for SHA-256) or a duplicate attempt. UNIQUE catches the latter cleanly.content NOT NULL— P0.7.1 schema allows empty string; SQLite''is not NULL. Keep NOT NULL.timestampvscreated_at—timestampis caller-supplied (may be set to any ISO-8601 string, is HASH-INCLUDED).created_atis insertion-time set by the repository (NEVER in the hash, ops metadata for ordering). Both ISO-8601; stable string sorting = stable time order.idx_trail_tasksupports bothlistThoughtRecords({task_id})ordered scans and the latest-for-task lookup.idx_trail_prev— prompt §7 requested; used by P0.7.3 chain verifier to walkprev_hash → idlinks. Not strictly needed by P0.7.2 but shipping now saves P0.7.3 a migration.
All columns are TEXT — matches P0.7.1 schema (all string-typed). No CHECK constraints (avoid duplicating Zod; Zod is source of truth).
§6. Acceptance criteria mapping
Spec acceptance criteria (from docs/guides/implementation/task-breakdown.md §P0.7.2 lines 333-343):
| Criterion | Source | Audit observation |
|---|---|---|
createThoughtRecord(input) computes hash, links to previous |
L339 | Repository function; input = {type, task_id, agent_id, content}. Generates id (UUID v4), timestamp (now ISO), resolves prev_hash (latest for task OR ZERO_HASH), computes hash via schema.computeHash, inserts in single transaction. |
getThoughtRecord(id) returns record with hash |
L340 | Repository SELECT * FROM thought_records WHERE id = ?. Returns ThoughtRecord shape or null. |
listThoughtRecords({task_id?, limit?}) returns chain in insertion order |
L341 | Repository SELECT * FROM thought_records WHERE (task_id = ? OR ?1 IS NULL) ORDER BY created_at ASC LIMIT ?. Maps rows to ThoughtRecord shape. |
thought_record MCP tool: Zod input, returns record with computed hash |
L342 | registerColibriTool(ctx, 'thought_record', {inputSchema: ...}, handler). Input schema: {type, task_id, agent_id, content} (no hash, no prev_hash — computed server-side). Output payload: the full ThoughtRecord. |
thought_record_list MCP tool: returns chain for given task_id |
L343 | registerColibriTool(ctx, 'thought_record_list', ...). Input: {task_id?, limit?}. Output: {records: ThoughtRecord[]}. |
All 5 criteria have direct code paths.
§7. Test matrix scope (sketch — packet binds exact count)
Coverage target: ≥25 tests, 100% branch on repository.ts. Distribution plan:
| describe | Count | Focus |
|---|---|---|
createThoughtRecord |
8 | genesis (ZERO_HASH prev), chain link, auto-id, auto-timestamp, required fields, empty-string content, all 4 types, agent_id preservation |
getThoughtRecord |
4 | returns shape, returns null on missing, cross-task retrieval, preserves hash exactly |
listThoughtRecords |
6 | empty, task_id filter, no filter, limit, insertion-order (ASC), cross-task isolation |
| determinism | 2 | re-computed hash matches stored hash; fixed-input pinned hash (P0.7.1 snapshot) |
thought_record MCP tool |
3 | envelope, 4-type parse, INVALID_PARAMS on bad input |
thought_record_list MCP tool |
3 | envelope, filter by task_id, empty |
| registration hygiene | 1 | registers both tool names idempotently |
Total: 27 tests. Packet may expand to 30.
§8. Parallel-wave collision check (Wave D)
Wave D dispatches four tasks in parallel:
| Task | Owner files | Collision with P0.7.2? |
|---|---|---|
| P0.2.4 (health tools) | src/server.ts (bootstrap edit), likely src/domains/system/* |
YES on src/server.ts bootstrap() — both add a registerXxxTools(ctx) line after server_ping. Trivial 3-way merge. |
| P0.3.2 (task CRUD) | src/domains/tasks/repository.ts + src/db/migrations/002_tasks.sql + src/db/schema.sql + src/server.ts |
YES on src/server.ts bootstrap() + src/db/schema.sql (append). Migration numbers disjoint (002 vs 004). |
| P0.6.2 (skill CRUD + skill_list) | src/domains/skills/repository.ts + src/db/migrations/003_skills.sql + src/db/schema.sql + src/server.ts |
YES on src/server.ts bootstrap() + src/db/schema.sql (append). Migration numbers disjoint (003 vs 004). |
| P0.7.2 (this task) | src/domains/trail/repository.ts + src/db/migrations/004_thought_records.sql + src/db/schema.sql + src/server.ts + tests + docs |
n/a |
Expected merge collisions:
src/server.tsbootstrap — 4 tasks each add 1-2 lines. Sigma resolves via keep-all. Diffs are disjoint (different symbols, same file region).src/db/schema.sql— 3 tasks each append an ownership block at the bottom. Order of appends is append-insensitive (same content either way).
Not a collision: migration files (disjoint numbers), domain directories (disjoint).
Mitigation: keep this task’s src/server.ts diff to exactly one import + one call. Keep schema.sql diff to one block at EOF.
§9. Baseline verification
$ git rev-parse HEAD
6c26bb58 (P0.7.1 trail schema merged as PR #124)
$ git log -1 --format=%s
feat(p0-7-1): ζ hash-chained record schema — SHA-256 canonical JSON + ZERO_HASH genesis (#124)
$ node --version
v20.x (CI matrix, P0.1.3)
$ cat package.json | grep -E "zod|better-sqlite3|@modelcontextprotocol"
"@modelcontextprotocol/sdk": "...",
"better-sqlite3": "^11.5.0",
"zod": "^3.23.8",
All P0.7.2 dependencies present:
- P0.7.1 primitives (
computeHash,ZERO_HASH,ThoughtType,ThoughtRecord) atsrc/domains/trail/schema.ts. - P0.2.2 DB init (
src/db/index.ts) + migration runner. - P0.2.1 server (
registerColibriTool) + P0.2.3 startup (Phase 2 DB open). zod+better-sqlite3+node:cryptoalready declared.
No new runtime dependencies required.
§10. Non-obvious gotchas
10a. exactOptionalPropertyTypes: true in tsconfig
TypeScript flag is ON. An optional field (limit?: number) CANNOT be assigned undefined explicitly — it must be omitted from the object literal. Handlers using {...base, ...(limit !== undefined ? {limit} : {})} pattern (as in server.ts line 372) must be replicated in repository result construction. Similarly, listThoughtRecords({task_id}) must not pass task_id: undefined into its internal filter spec.
10b. noUncheckedIndexedAccess: true
Array/object indexed access returns T | undefined. For db.prepare(...).get(id) this already returns T | undefined. For db.prepare(...).all(...) this returns unknown[]; cast to ThoughtRecord[] is still type-unsafe — prefer mapping rows one-by-one through a row decoder that narrows.
10c. crypto.randomUUID() uses
P0.7.1 contract §7 says: “No crypto.randomUUID() call. Callers supply id. P0.7.2 will generate.” Confirmed: P0.7.2 is where UUIDs enter. Use import { randomUUID } from 'node:crypto'.
10d. Timestamp generation
P0.7.1 contract §7 says: “No new Date().toISOString(). Callers supply timestamp. P0.7.2 will generate.” Confirmed: new Date().toISOString() in the repository. Inject a now() seam for test determinism.
10e. Test file path must be src/__tests__/domains/trail/repository.test.ts
Dispatch prompt locks this path. jest.config.ts roots: ['<rootDir>/src'] + testMatch: ['**/__tests__/**/*.test.ts'] discovers nested paths. Wave A-C used flat paths (src/__tests__/<name>.test.ts) for simpler modules; the nested layout is new in Wave D — validated by the test matcher, no jest.config change needed.
10f. Better-sqlite3 transaction + last-record-lookup
createThoughtRecord must be atomic: (a) SELECT latest for task, (b) compute hash, (c) INSERT. If steps (a) and (c) are in separate transactions, two concurrent inserts for the same task could both read the same “latest” and then both insert with the same prev_hash, producing a chain fork. Wrap in db.transaction(() => {...})(input) with BEGIN IMMEDIATE (or rely on the default which serializes writes on WAL). Better-sqlite3’s db.transaction() returns a function that runs the body inside a savepoint/transaction. For concurrent protection we rely on SQLite’s write serialization + the α tool-lock middleware (per-tool mutex). Both together are belt-and-suspenders.
Actually: α tool-lock alone serializes thought_record invocations — no two createThoughtRecord calls can race inside one process. For cross-process safety, the DB-level transaction is still needed (minor concern; Phase 0 is single-process stdio only). Ship the transaction wrapper.
10g. P0.7.1 schema UNIQUE + idempotent inserts
hash UNIQUE prevents duplicate-hash inserts. If a caller passes the same {type, task_id, agent_id, content, timestamp} twice AND the prev_hash is the same (same task, no prior inserts), the computed hash is identical → second insert fails. This is correct: the same 6-field subset IS the same record. The repository should propagate this error cleanly (Zod+SQLite error → HANDLER_ERROR envelope).
10h. Cross-worktree leak surveillance (Wave C feedback)
Step 1 git status was clean. During Step 4, before each commit, I will re-run git status and verify only expected files are staged. No known Wave D leak source (all 4 agents started from same main, no shared files beyond bootstrap lines).
§11. Exit criteria for Step 1
- Target files catalogued as absent (§2).
- P0.7.1 primitives + consumption contract documented (§3a, §3b).
- DB + server integration points documented (§3c, §3d).
- Chain scope = per-task, with rationale from P0.7.1 contract §3 (§4).
- Table schema confirmed (§5).
- All 5 acceptance criteria map to code paths (§6).
- Test matrix sketched with ≥25 tests (§7).
- Wave D collision points identified (§8).
- Baseline + dependencies verified (§9).
- Gotchas enumerated (§10).
Ready to proceed to Step 2 (contract).