# 2026-05-25 — DAG live-coupling E1: schema v28 + dirty-flag plumbing

**Status:** Draft v2.1 (R2 plan-eng-critic CRIT institutional gap folded — VALID_AUDIT_OPS Sets in cli.ts + server.ts lockstep with AuditOp union extension; new audit-query round-trip test case)
**Episode:** 01KSG3W24150QWM7JCPANN57HE
**Branch:** feat-dag-e1-schema-v28 (off master == 39011a0 = v1.12.11)
**Owner:** Claude (Keith review)

## Why this exists

E1 of 5-episode arc spun out of the `/plan-ceo-review` decomposition pass on the memory-pinned direction "combine 3-layer bio system with DAG summarization + SQLite backbone + decay layered on top".

**Critical reframe (decomposition agent reproduce-check):** The DAG layer's static structure is **already shipped** as of v1.7.x — schema v25 has the DAG columns, `buildDag` clusters level-1 facts into level-2 summaries during sleep, `search.ts` does overflow-substitution, `drillDown` walks summaries, `api.assemble` does strength-weighted eviction, 13 `tests/dag-*` files exist. The real work is making the DAG layer *live* — coupling it to hippo's bio mechanics that today treat summary nodes as inert text rows.

E1 is the schema-first slice (decomposition discipline rule #3). Adds the persistence shape that E2 (child-write propagation), E3 (sleep-cycle rebuild), and E5 (level-3 build path) all read/write. No behavior changes in this episode.

## R1 → v2 changes (critic carry-over)

R1 plan-eng-critic FAILed v1 (score 4) with 3 CRIT + 2 HIGH + 4 MED + 2 LOW. v2 addresses every must-fix:

**CRIT-1 (compile)**: `appendAuditEvent` signature is camelCase (`{tenantId, targetId}`), v1 wrote snake_case. v2 calls the existing `audit()` wrapper in `src/store.ts:32-52` (handles arg shape + try/catch for missing audit_log) instead of `appendAuditEvent` directly.

**CRIT-2 (type)**: `'summary_marked_dirty'` not in `AuditOp` union (`src/audit.ts:130-141`). v2 adds it as part of S1 scope; type-checks now.

**CRIT-3 (runtime)**: v1 test INSERTs named only 6 columns, leaving ~10 NOT-NULL-without-DEFAULT columns unfilled → all 4 seeded tests would throw `SQLITE_CONSTRAINT_NOTNULL`. v2 rewrites tests to use `initStore + createMemory + writeEntry` (matches `tests/dag-schema.test.ts:1-51` pattern — the canonical DAG-test fixture).

**HIGH-1 (contract)**: `rowToEntry` doesn't read the 3 new columns from a `MemoryRow`, so `loadDirtySummaries`'s claim "returns full MemoryEntry shape with v28 fields populated" was a broken contract. v2 extends `MEMORY_SELECT_COLUMNS` + `MemoryRow` + `rowToEntry` to map them — symmetric with the v25 `descendant_count` / `earliest_at` / `latest_at` pattern; single source of truth.

**HIGH-2 (rationale)**: v1 cited v26 (`db.ts:878`) as the precedent for `tableHasColumn`-only guards; v26 actually guards with `tableExists && !tableHasColumn` because of the v16 partial-state bug. v2 cites v25 (`db.ts:827`) — the actual precedent for column-only guards on `memories` (which always exists).

**MED-1 (Q3)**: v1 deviated from Keith's verbatim recommended pick ("add `dag_level_3_built_at` to ep1 migration so ep5 doesn't need a 2nd migration") by routing to a `meta` key-value. v2 reverts to a column on `memories`. The column is only ever non-null on level-3 rows (set by E5's `buildEntityProfiles`).

**MED-2 (asymmetry)**: v1 left `MEMORY_SELECT_COLUMNS` unchanged, making the 3 new fields populate only via the dedicated helper. v2 folds the symmetry fix from HIGH-1 (extends the recall hot-path read).

**MED-3 (test gap)**: v1 missed the `markSummaryDirty` on non-existent ID case. v2 adds it.

**MED-4 (audit wrap)**: v1 called `appendAuditEvent` directly, would throw on missing audit_log. v2 uses the `audit()` helper which try/catches.

**LOW-1 (metadata)**: v1 passed `metadata: {}`. v2 passes `{dag_level: 2, source: 'E1'}` so E2-E5 debugging has a breadcrumb.

**LOW-2 (NULLS LAST)**: v1's `ORDER BY latest_at DESC` puts NULL first in SQLite. v2 adds `NULLS LAST`.

## R2 → v2.1 changes (CRIT institutional carry-over)

R2 plan-eng-critic FAILed v2 (score 7). The 11 R1 must-fixes are correctly folded, but a NEW institutional defect surfaced:

**CRIT (v2.1)**: AuditOp union extension without lockstep extension of `VALID_AUDIT_OPS` Sets at `src/cli.ts:4726-4738` and `src/server.ts:71-83`. This is the EXACT bug class the v1.11.5 hardening pass plan (`docs/plans/2026-05-23-v1.11.5-hardening-pass.md`, landed 2 days ago) flagged as CRIT A — extending the union alone makes new audit rows writable-but-unqueryable (`hippo audit list --op summary_marked_dirty` hits `process.exit(1)` at cli.ts:4752; `GET /v1/audit?op=summary_marked_dirty` hits HTTP 400 at server.ts:922). v2.1 folds the lockstep extension into S1 with a cross-reference comment.

**MED (v2.1)**: Test gap on the audit-query wiring. v2.1 adds a 7th test case (`hippo_audit list --op summary_marked_dirty round-trip`) so the institutional pattern is locked against future drift.

**LOW (v2.1)**: `markSummaryDirty` UPDATE missing `kind != 'archived'` guard (asymmetric with `loadDirtySummaries`). v2.1 adds the guard for consistency.

## Goal

Migration v28 adds **4** additive columns to `memories` (all DEFAULTed/nullable so backfill is automatic + no `min_compatible_binary` bump). Extend `MEMORY_SELECT_COLUMNS` + `MemoryRow` + `rowToEntry` to surface the v28 fields on every read (symmetric with v25 DAG cache). Add 2 store helpers (`loadDirtySummaries`, `markSummaryDirty`) for the E2/E3 dirty-flag pipeline. Extend the `AuditOp` union with `'summary_marked_dirty'`. Land tests that lock the schema invariant.

## Scope

### S1 — Migration v28 + AuditOp extension in `src/db.ts` + `src/audit.ts`

**`src/audit.ts`** — extend the `AuditOp` union (line 130-141):

```typescript
export type AuditOp =
  | 'remember'
  | 'recall'
  | 'promote'
  | 'supersede'
  | 'forget'
  | 'archive_raw'
  | 'auth_revoke'
  | 'auth_create'
  | 'outcome'
  | 'consolidate'
  | 'audit_prune'
  | 'summary_marked_dirty'; // v0.30 / E1 of DAG live-coupling
```

**`src/cli.ts`** — extend the `VALID_AUDIT_OPS` Set (line 4726-4738). REQUIRED IN LOCKSTEP per v1.11.5 hardening pass (CRIT A institutional rule: union additions without Set additions create writable-but-unqueryable audit rows):

```typescript
const VALID_AUDIT_OPS: ReadonlySet<AuditOp> = new Set<AuditOp>([
  'remember', 'recall', 'promote', 'supersede', 'forget', 'archive_raw',
  'auth_revoke', 'auth_create', 'outcome', 'consolidate', 'audit_prune',
  'summary_marked_dirty', // v0.30 / E1 — lockstep with AuditOp union + server.ts
]);
```

**`src/server.ts`** — extend the `VALID_AUDIT_OPS` Set (line 71-83). Same lockstep pattern:

```typescript
const VALID_AUDIT_OPS: ReadonlySet<AuditOp> = new Set<AuditOp>([
  'remember', 'recall', 'promote', 'supersede', 'forget', 'archive_raw',
  'auth_revoke', 'auth_create', 'outcome', 'consolidate', 'audit_prune',
  'summary_marked_dirty', // v0.30 / E1 — lockstep with AuditOp union + cli.ts
]);
```

All three sites get the same one-line addition + cross-reference comment. The cli.ts comment (line 4747) already documents the regenerate-from-Set error message pattern that catches future drift; this extension keeps that contract intact.

**`src/db.ts`** — bump `CURRENT_SCHEMA_VERSION = 27;` to `28` (line 26). Append migration v28 to the `MIGRATIONS` array (after the v27 self-heal block, ~line 938):

```typescript
{
  version: 28,
  up: (db) => {
    // E1 of 5-episode DAG live-coupling arc. Adds dirty-flag persistence
    // for the existing DAG layer's level-2 summaries so E2 (child-write
    // propagation) and E3 (sleep-cycle rebuild) have somewhere to write
    // + read the staleness signal. Adds dag_level_3_built_at as a column
    // (per decomposition Q5: lets E5 entity-profile build path land
    // without a second migration).
    //
    // All columns are additive + DEFAULTed/nullable, so backfill is
    // automatic for existing rows. No min_compatible_binary bump: old
    // binaries (v1.12.x) ignore the columns on SELECTs that don't name
    // them; new binaries on old data hit this migration at openHippoDb
    // time before any DAG path touches summary_dirty.
    //
    // Precedent for column-only guards on memories: v25 (db.ts:827),
    // which added the DAG cache columns the same way. memories table
    // itself comes from v1 and is always present, so the tableExists
    // half of the v26/v27 guard pattern isn't load-bearing here.
    if (!tableHasColumn(db, 'memories', 'summary_dirty')) {
      db.exec(`ALTER TABLE memories ADD COLUMN summary_dirty INTEGER NOT NULL DEFAULT 0`);
    }
    if (!tableHasColumn(db, 'memories', 'last_rebuilt_at')) {
      db.exec(`ALTER TABLE memories ADD COLUMN last_rebuilt_at TEXT`);
    }
    if (!tableHasColumn(db, 'memories', 'rebuild_count')) {
      db.exec(`ALTER TABLE memories ADD COLUMN rebuild_count INTEGER NOT NULL DEFAULT 0`);
    }
    if (!tableHasColumn(db, 'memories', 'dag_level_3_built_at')) {
      // Reserved for E5: buildEntityProfiles will set this on level-3
      // rows when they're created. Always NULL for level 0/1/2 rows.
      db.exec(`ALTER TABLE memories ADD COLUMN dag_level_3_built_at TEXT`);
    }
  },
},
```

Idempotent re-open: `tableHasColumn` guards make v28 a no-op on already-migrated DBs (matches the migration runner's per-version skip + belt-and-braces).

### S2 — Plumb the v28 fields through the read path

**`src/memory.ts`** — extend `MemoryEntry` with 4 optional fields (alphabetical placement near the v25 DAG fields):

```typescript
export interface MemoryEntry {
  // ... existing fields ...
  // DAG layer (v25, level static structure):
  dag_level?: 0 | 1 | 2 | 3;
  dag_parent_id?: string | null;
  descendant_count?: number;
  earliest_at?: string | null;
  latest_at?: string | null;
  // DAG live-coupling (v28, E1 of 5):
  /** v28: 1 when this summary row has at least one child invalidated,
   *  superseded, forgotten, or archived since it was last rebuilt.
   *  Cleared by E3's rebuildDirtySummaries during sleep. Always 0 for
   *  non-summary rows (dag_level !== 2; E5 widens to include 3). */
  summary_dirty?: 0 | 1;
  /** v28: ISO 8601 timestamp of the last successful rebuild for this
   *  summary, or null if never rebuilt. */
  last_rebuilt_at?: string | null;
  /** v28: monotonically-increasing counter of successful rebuilds for
   *  this summary. 0 for initial buildDag write; bumped by E3. */
  rebuild_count?: number;
  /** v28 (reserved for E5): ISO 8601 timestamp the level-3 entity profile
   *  was built, or null. Only ever populated on dag_level=3 rows. */
  dag_level_3_built_at?: string | null;
}
```

**`src/store.ts`** — extend `MEMORY_SELECT_COLUMNS` (search the file) to include the 4 new columns; extend the `MemoryRow` interface declaration alongside; extend `rowToEntry` to copy them through. This is the single-source-of-truth pattern v25 already uses for `descendant_count` / `earliest_at` / `latest_at`.

After this, every read path (`readEntry`, `loadAllEntries`, `loadSearchRows`, `loadDirtySummaries`) returns `MemoryEntry` with the v28 fields populated symmetrically. No asymmetry; no JSDoc "populated only by X" carve-out.

### S3 — Store helpers in `src/store.ts`

Placed adjacent to the existing DAG-related helpers (search `dag_level` / `dag_parent_id` in the file):

```typescript
/**
 * v0.30 / E1 of DAG live-coupling — load summaries flagged dirty for the
 * given tenant. Used by E3's sleep-cycle rebuild phase to enumerate
 * candidates without scanning every memory row.
 *
 * Sorted by latest_at DESC (NULLS LAST) so E3's rebuild cap
 * (HIPPO_DAG_REBUILD_CAP, default 20) takes the most-recently-changed
 * summaries first.
 *
 * Returns full MemoryEntry shape via MEMORY_SELECT_COLUMNS + rowToEntry
 * (v28 fields are now part of the standard read path; no inline SELECT
 * augmentation needed).
 */
export function loadDirtySummaries(
  hippoRoot: string,
  tenantId: string,
): MemoryEntry[] {
  const db = openHippoDb(hippoRoot);
  try {
    const rows = db.prepare(`
      SELECT ${MEMORY_SELECT_COLUMNS}
        FROM memories
       WHERE summary_dirty = 1
         AND tenant_id = ?
         AND kind != 'archived'
       ORDER BY latest_at DESC NULLS LAST, id ASC
    `).all(tenantId) as MemoryRow[];
    return rows.map(rowToEntry);
  } finally {
    db.close();
  }
}

/**
 * v0.30 / E1 of DAG live-coupling — mark a summary as dirty. Idempotent
 * (re-marking dirty is a no-op + no second audit row). Tenant-scoped to
 * prevent cross-tenant writes via parent-lookup. Called by E2 from
 * invalidation.ts / writeEntry / forgetMemory / archiveRawMemory whenever
 * a child is invalidated, superseded, forgotten, or archived.
 *
 * Quietly no-ops if the target row doesn't exist or isn't a level-2
 * summary (E5 will widen the dag_level guard to IN (2, 3) when level-3
 * build path lands). Emits a 'summary_marked_dirty' audit row on actual
 * state transitions (0 -> 1) via the audit() helper, which try/catches
 * for missing audit_log (the v27 self-heal scenario).
 */
export function markSummaryDirty(
  hippoRoot: string,
  summaryId: string,
  tenantId: string,
  actor: string = 'cli',
): void {
  const db = openHippoDb(hippoRoot);
  try {
    const result = db.prepare(`
      UPDATE memories
         SET summary_dirty = 1
       WHERE id = ?
         AND tenant_id = ?
         AND dag_level = 2
         AND summary_dirty = 0
         AND kind != 'archived'
    `).run(summaryId, tenantId);
    if (result.changes > 0) {
      // audit() wraps appendAuditEvent in try/catch — defends against the
      // v16-partial-state where audit_log is missing (fixed by v27 heal).
      // metadata.source=E1 leaves a breadcrumb so E2-E5 debugging can
      // distinguish dirty-marks across the arc's wiring layers.
      audit(db, 'summary_marked_dirty', summaryId, { dag_level: 2, source: 'E1' }, actor, tenantId);
    }
  } finally {
    db.close();
  }
}
```

Both helpers use the standard openHippoDb + try/finally close pattern. The `kind != 'archived'` filter matches the recall path's archived exclusion (single source of truth via the `archivedClauseAlias` family in `loadSearchRows`). The `dag_level = 2 AND summary_dirty = 0` guard makes `markSummaryDirty` safe (only summaries) AND idempotent (only 0→1 transitions).

### S4 — Tests `tests/dag-dirty-flag-schema.test.ts`

NEW test file. Uses the canonical `initStore + createMemory + writeEntry` pattern from `tests/dag-schema.test.ts` (NOT raw INSERT — v1's mistake). 6 cases (idempotent migration, backfill, round-trip with tenant isolation, idempotent dirty-mark, no-op on non-summary, no-op on unknown id):

```typescript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
  initStore,
  writeEntry,
  loadDirtySummaries,
  markSummaryDirty,
  openHippoDb,
} from '../src/store.js';
import { createMemory, Layer } from '../src/memory.js';

describe('v28 schema migration + dirty-flag plumbing (E1)', () => {
  let hippoRoot: string;
  beforeEach(() => {
    hippoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hippo-dag-e1-'));
    initStore(hippoRoot);
  });
  afterEach(() => {
    fs.rmSync(hippoRoot, { recursive: true, force: true });
  });

  it('migration v28 is idempotent (re-open does not error or duplicate columns)', () => {
    // initStore already ran v28 in beforeEach. Re-open twice more and
    // confirm the columns are still there with no migration error.
    const db1 = openHippoDb(hippoRoot); db1.close();
    const db2 = openHippoDb(hippoRoot);
    expect(() => db2.prepare(`SELECT summary_dirty, last_rebuilt_at, rebuild_count, dag_level_3_built_at FROM memories`).all()).not.toThrow();
    db2.close();
  });

  it('backfill leaves rows clean (summary_dirty=0, rebuild_count=0, last_rebuilt_at NULL, dag_level_3_built_at NULL)', () => {
    const summary = createMemory('test summary', { layer: Layer.Semantic, dag_level: 2 });
    writeEntry(hippoRoot, summary);
    const db = openHippoDb(hippoRoot);
    const row = db.prepare(
      `SELECT summary_dirty, last_rebuilt_at, rebuild_count, dag_level_3_built_at FROM memories WHERE id = ?`
    ).get(summary.id) as {
      summary_dirty: number;
      last_rebuilt_at: string | null;
      rebuild_count: number;
      dag_level_3_built_at: string | null;
    };
    db.close();
    expect(row.summary_dirty).toBe(0);
    expect(row.last_rebuilt_at).toBeNull();
    expect(row.rebuild_count).toBe(0);
    expect(row.dag_level_3_built_at).toBeNull();
  });

  it('markSummaryDirty + loadDirtySummaries round-trip per tenant (cross-tenant isolation)', () => {
    // Seed 2 summaries in different tenants via writeEntry (single-process
    // tenant scoping per existing test pattern; tenant_id resolved from
    // HIPPO_TENANT or default).
    const summaryA = createMemory('summary A', { layer: Layer.Semantic, dag_level: 2 });
    const summaryB = createMemory('summary B', { layer: Layer.Semantic, dag_level: 2 });
    writeEntry(hippoRoot, summaryA);
    writeEntry(hippoRoot, summaryB);
    // Manually rewrite tenant_id on B since createMemory uses default — keeps
    // the test self-contained without env shenanigans.
    const db = openHippoDb(hippoRoot);
    db.prepare(`UPDATE memories SET tenant_id = ? WHERE id = ?`).run('tenant2', summaryB.id);
    db.close();
    markSummaryDirty(hippoRoot, summaryA.id, 'default', 'test-actor');
    expect(loadDirtySummaries(hippoRoot, 'default').map((m) => m.id)).toEqual([summaryA.id]);
    expect(loadDirtySummaries(hippoRoot, 'tenant2').map((m) => m.id)).toEqual([]);
  });

  it('markSummaryDirty is idempotent (second call writes no audit row + summary_dirty stays 1)', () => {
    const summary = createMemory('idempotent test', { layer: Layer.Semantic, dag_level: 2 });
    writeEntry(hippoRoot, summary);
    markSummaryDirty(hippoRoot, summary.id, 'default', 'actor');
    markSummaryDirty(hippoRoot, summary.id, 'default', 'actor');
    const db = openHippoDb(hippoRoot);
    const auditRows = db.prepare(
      `SELECT * FROM audit_log WHERE op = 'summary_marked_dirty' AND target_id = ?`
    ).all(summary.id);
    const row = db.prepare(`SELECT summary_dirty FROM memories WHERE id = ?`).get(summary.id) as { summary_dirty: number };
    db.close();
    expect(auditRows).toHaveLength(1);
    expect(row.summary_dirty).toBe(1);
  });

  it('markSummaryDirty no-ops on non-summary rows (dag_level !== 2)', () => {
    const leaf = createMemory('a leaf', { layer: Layer.Semantic, dag_level: 1 });
    writeEntry(hippoRoot, leaf);
    markSummaryDirty(hippoRoot, leaf.id, 'default', 'actor');
    expect(loadDirtySummaries(hippoRoot, 'default')).toEqual([]);
    // No audit row written either.
    const db = openHippoDb(hippoRoot);
    const auditRows = db.prepare(`SELECT * FROM audit_log WHERE op = 'summary_marked_dirty'`).all();
    db.close();
    expect(auditRows).toEqual([]);
  });

  it('markSummaryDirty no-ops on unknown id (R1 MED-3 test gap)', () => {
    markSummaryDirty(hippoRoot, 'sem_does_not_exist', 'default', 'actor');
    expect(loadDirtySummaries(hippoRoot, 'default')).toEqual([]);
    const db = openHippoDb(hippoRoot);
    const auditRows = db.prepare(`SELECT * FROM audit_log WHERE op = 'summary_marked_dirty'`).all();
    db.close();
    expect(auditRows).toEqual([]);
  });

  it('audit-query round-trip: markSummaryDirty + queryAuditEvents(op=summary_marked_dirty) returns the row (R2 institutional VALID_AUDIT_OPS lockstep test)', async () => {
    // Locks the v1.11.5 hardening pass institutional rule: a new AuditOp
    // is useless without VALID_AUDIT_OPS Set extensions in BOTH cli.ts +
    // server.ts. This test exercises the queryAuditEvents path that
    // both downstream sites consume.
    const { queryAuditEvents } = await import('../src/audit.js');
    const summary = createMemory('audit round-trip', { layer: Layer.Semantic, dag_level: 2 });
    writeEntry(hippoRoot, summary);
    markSummaryDirty(hippoRoot, summary.id, 'default', 'test-actor');
    const db = openHippoDb(hippoRoot);
    try {
      const events = queryAuditEvents(db, { tenantId: 'default', op: 'summary_marked_dirty' });
      expect(events).toHaveLength(1);
      expect(events[0]!.targetId).toBe(summary.id);
      expect(events[0]!.actor).toBe('test-actor');
      expect(events[0]!.metadata).toMatchObject({ dag_level: 2, source: 'E1' });
    } finally {
      db.close();
    }
  });
});
```

## Acceptance criteria

| # | Criterion | Verifies |
|---|---|---|
| AC1 | `CURRENT_SCHEMA_VERSION === 28` in `src/db.ts` | S1 |
| AC2 | Migration v28 entry exists in MIGRATIONS array | S1 |
| AC3 | Migration v28 is idempotent on re-open | S1 / S4 #1 |
| AC4 | `memories.summary_dirty` column exists with DEFAULT 0 | S1 |
| AC5 | `memories.last_rebuilt_at` column exists, nullable, TEXT | S1 |
| AC6 | `memories.rebuild_count` column exists with DEFAULT 0 | S1 |
| AC7 | `memories.dag_level_3_built_at` column exists, nullable, TEXT (reserved for E5) | S1 |
| AC8 | `AuditOp` union extended with `'summary_marked_dirty'` in `src/audit.ts` | S1 |
| AC8a | `VALID_AUDIT_OPS` Set in `src/cli.ts` extended with `'summary_marked_dirty'` (lockstep per v1.11.5 CRIT A rule) | S1 |
| AC8b | `VALID_AUDIT_OPS` Set in `src/server.ts` extended with `'summary_marked_dirty'` (lockstep) | S1 |
| AC9 | Existing rows backfill cleanly (all v28 fields default) | S1 / S4 #2 |
| AC10 | `MemoryEntry` interface includes 4 new optional fields | S2 |
| AC11 | `MEMORY_SELECT_COLUMNS` extended (single source of truth) | S2 |
| AC12 | `MemoryRow` interface declares 4 new columns | S2 |
| AC13 | `rowToEntry` maps 4 new columns into MemoryEntry | S2 |
| AC14 | `loadDirtySummaries(root, tenantId)` returns dirty summaries via standard read path | S3 / S4 #3 |
| AC15 | `loadDirtySummaries` sort: `latest_at DESC NULLS LAST, id ASC` | S3 |
| AC16 | `loadDirtySummaries` filters by tenant_id (cross-tenant isolation) | S3 / S4 #3 |
| AC17 | `loadDirtySummaries` excludes `kind = 'archived'` rows | S3 |
| AC18 | `markSummaryDirty(root, id, tenant, actor)` flips summary_dirty 0→1 | S3 / S4 #3 |
| AC19 | `markSummaryDirty` is idempotent (second call no-op + no second audit row) | S3 / S4 #4 |
| AC20 | `markSummaryDirty` no-ops on non-summary rows (dag_level !== 2) | S3 / S4 #5 |
| AC21 | `markSummaryDirty` no-ops on unknown id (no audit, no UPDATE) | S3 / S4 #6 |
| AC22 | `markSummaryDirty` emits `audit_log` row via `audit()` wrapper (try/catches missing audit_log) | S3 / S4 #4 |
| AC23 | `markSummaryDirty` audit metadata = `{dag_level: 2, source: 'E1'}` (breadcrumb) | S3 |
| AC24 | `npx tsc --noEmit` clean (AuditOp union extension type-checks; no signature drift) | regression |
| AC25 | Full test suite green (existing + 7 new) | regression |
| AC26 | `hippo audit list --op summary_marked_dirty` returns rows markSummaryDirty wrote (CLI VALID_AUDIT_OPS wiring) | S1+S4 #7 |
| AC27 | `queryAuditEvents(db, {op: 'summary_marked_dirty'})` returns rows markSummaryDirty wrote (HTTP/SDK VALID_AUDIT_OPS wiring) | S1+S4 #7 |
| AC28 | `markSummaryDirty` UPDATE includes `kind != 'archived'` guard (symmetric with loadDirtySummaries) | S3 |

## Risks

| # | Risk | Lik. | Mitigation |
|---|---|---|---|
| R1 | Migration v28 lands but rollback to v1.12.11 binary breaks | L | No `min_compatible_binary` bump — all 4 columns DEFAULTed/nullable. Old binaries SELECT existing columns only. Documented in migration comment. |
| R2 | `tableHasColumn` guards mistakenly think column exists when it doesn't | L | Same defensive pattern as v25 (`db.ts:827`); migration runner wraps each in BEGIN/COMMIT so atomic per migration. |
| R3 | `markSummaryDirty`'s `dag_level=2` guard excludes future level-3 summaries (when E5 lands) | M | E5 will widen to `dag_level IN (2, 3)` when level-3 build path lands. Documented in JSDoc. |
| R4 | `loadDirtySummaries` returns full MemoryEntry; large summaries × large rebuild cap = memory pressure | L | E3 caps rebuilds at `HIPPO_DAG_REBUILD_CAP=20`. Even at cap, 20 × ~2KB summary = 40KB heap — trivial. |
| R5 | `kind != 'archived'` clause drift if archived raw-row rule changes | L | Matches the existing recall path's archive exclusion (`archivedClauseAlias` from v1.12.6). Single source of truth. |
| R6 | Audit-log volume from frequent dirty-marks under busy summary | L | One audit row per 0→1 transition only (idempotency guard). Per summary per rebuild cycle. Bounded. |
| R7 | `MEMORY_SELECT_COLUMNS` extension breaks downstream tooling that snapshots the column list | L | Search the codebase for hardcoded column-list assumptions (none expected); the v25 precedent for adding columns to this constant didn't break anything. tsc + full suite catches anything. |
| R8 | `dag_level_3_built_at` column adds storage cost on every row though only level-3 rows ever populate | L | TEXT NULL columns are 1 byte per row in SQLite. With ~10k memories per dev, 10KB total cost — trivial. Per Keith's verbatim Q5 pick. |
| R9 | `audit()` helper signature drift if its arg order changes | L | `audit(db, op, targetId?, metadata?, actor?, tenantId?)` is the public-helper contract used by ~20 call sites in store.ts; signature stability is implicit. |

## Out of scope (named, deferred)

- E2 (child-write → dirty-flag propagation in `invalidation.ts` / `writeEntry` / `forgetMemory` / `archiveRawMemory`) — separate episode.
- E3 (sleep-cycle `rebuildDirtySummaries` phase + `HIPPO_DAG_REBUILD_CAP` config) — separate episode.
- E4 (DAG nodes first-class in `hybridSearch` + `physicsSearch` with `summaryDeboost`) — separate episode.
- E5 (level-3 entity profile build path + `drillDown --depth N` + `dag_level_3_built_at` write site) — separate episode.
- Summary-level conflict detection — deferred per decomposition out-of-scope.
- DAG-aware ambient/physics state coupling — deferred.
- Strength inheritance for summaries (`max(child.strength) × 0.95`) — Keith deferred to a 6th episode (decomposition Q4).
- HippoRAG-style PPR over the DAG — deferred (F4 research).
- UI / dashboard surfacing of DAG live-coupling — deferred (v0.2.5 fine).
- Performance hardening / soak harness coupling — out of scope.

## Rollback

Single PR, revertible:
- Revert → migration v28 entry removed from `MIGRATIONS`, `CURRENT_SCHEMA_VERSION` rolls back to 27.
- `'summary_marked_dirty'` removed from `AuditOp` union (no production callers in E1; only the new test references it).
- DBs that already applied v28 keep the 4 new columns (harmless; `v1.12.11` binary ignores on SELECTs that don't name them).
- `loadDirtySummaries` / `markSummaryDirty` helpers removed from `store.ts`.
- `MEMORY_SELECT_COLUMNS` + `MemoryRow` + `rowToEntry` narrow back (revert the v28 additions).
- `MemoryEntry` interface narrows back (4 optional fields removed; TypeScript-only).
- Tests deleted.

No DB downgrade required. The "min_compatible_binary stays at current" decision means old binaries opening a v28 DB silently ignore the new columns.

## Cost estimate

- Coding: ~3 hours (migration + AuditOp + interface + MEMORY_SELECT_COLUMNS + MemoryRow + rowToEntry + 2 helpers + 6 tests)
- Critic rounds: ~30 min (R2 likely PASS — v1 issues were all surface-level)
- Verify: ~10 min (run suite + tsc)
- Total: ~half-day

## Open questions for R2 critics

1. **R1 metadata breadcrumb format**: chose `{dag_level: 2, source: 'E1'}`. Alternative: include the trigger op (`{source: 'E1', trigger: 'invalidate' | 'supersede' | 'forget' | 'archive'}`) — but that requires E2 to pass the trigger string through, which couples E1's helper signature to E2. Plan picks the minimal breadcrumb; flag if you'd lock in the trigger param now to avoid an E2 signature break.

2. **`dag_level_3_built_at` column populated only on level-3 rows**: per Keith's Q5 pick. Alternative was meta-table key-value (v1 plan). The column approach is simpler for E5 (one ALTER, one SELECT), the storage cost is trivial. Flag if you'd argue for the meta-table approach despite Keith's pick.

3. **`createMemory(..., { layer: Layer.Semantic, dag_level: 2 })` pattern in tests**: matches `tests/dag-schema.test.ts:21-25` exactly. The summary's `tenant_id` defaults via `createMemory` to the env-resolved tenant; test #3's tenant-isolation case manually rewrites `tenant_id` to `'tenant2'` via SQL since `createMemory` has no `tenant_id` param. Flag if you'd prefer a different cross-tenant test approach (e.g. `HIPPO_TENANT` env swap between writes — flaky).

4. **`MEMORY_SELECT_COLUMNS` extension cost on the recall hot path**: 4 extra columns SELECTed on every recall (including the 1MB-row recall budget). Cost is 4 bytes each (3 INTEGER + 1 TEXT NULL = ~16B per row × maxRows). With max ~50 rows per recall, that's ~800B extra serialized per response. Trivial. Flag if you'd argue for the v1 inline-augmentation pattern despite the asymmetry cost.

5. **`audit()` helper actor default**: `markSummaryDirty(..., actor: string = 'cli')`. E2 wiring will pass `ctx.actor.subject` (real subject string). Default `'cli'` matches the existing `audit()` helper default in store.ts. Flag if you'd want the default to be `'system'` instead, since this is background-pipeline-ish.
