# A5 Stub Auth Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Ship single-tenant API keys + audit log + `tenant_id` schema scaffolding so A1 server can authenticate requests and so future multi-tenant isolation is a config flip, not a re-migration.

**Architecture:** Schema migration v16 adds `tenant_id` (default `'default'`) to `memories`, `working_memory`, `consolidation_runs`, `task_snapshots`, `memory_conflicts`, plus composite `(tenant_id, created)` indexes on every recall path. New tables: `api_keys` (hashed key + revoked_at), `audit_log` (append-only mutation trail). New TS modules: `src/auth.ts` (key hash/validate/revoke), `src/audit.ts` (append + query primitives), `src/tenant.ts` (resolve current tenant from env or API key). Recall path filters by `tenant_id`. CLI gets `hippo auth` and `hippo audit` subcommands. SSO/SCIM exposed as stub functions that throw `NotImplementedError`. No server is written here — A1 (separate track) will consume these primitives.

**Tech Stack:** TypeScript, node:sqlite, vitest with real DB (no mocks), node:crypto (`scryptSync` for key hashing — never store plaintext API keys).

---

## Schema diagram

### Before (post-A3, v15)

```
memories
├─ id, created, ..., kind, scope, owner, artifact_ref, ...
└─ (no tenant_id)

working_memory   ← scope TEXT NOT NULL (per-scope, no tenant)
consolidation_runs, task_snapshots, memory_conflicts ← (no tenant_id)
raw_archive ← from A3
api_keys, audit_log ← (do not exist)
```

### After (v16)

```
memories
├─ ...same fields...
├─ tenant_id TEXT NOT NULL DEFAULT 'default'
└─ INDEX idx_memories_tenant_created (tenant_id, created)

working_memory       ← + tenant_id, INDEX (tenant_id, importance DESC, created_at DESC)
consolidation_runs   ← + tenant_id, INDEX (tenant_id, timestamp DESC)
task_snapshots       ← + tenant_id, INDEX (tenant_id, status, updated_at DESC)
memory_conflicts     ← + tenant_id

api_keys
├─ id INTEGER PRIMARY KEY
├─ key_id TEXT UNIQUE NOT NULL          ← public prefix shown in CLI listings (e.g. "hk_abc123")
├─ key_hash TEXT NOT NULL                ← scrypt(plaintext_key)
├─ tenant_id TEXT NOT NULL DEFAULT 'default'
├─ label TEXT                            ← optional human label
├─ created_at TEXT NOT NULL
├─ revoked_at TEXT                       ← NULL = active
└─ INDEX idx_api_keys_tenant_active (tenant_id) WHERE revoked_at IS NULL

audit_log
├─ id INTEGER PRIMARY KEY AUTOINCREMENT
├─ ts TEXT NOT NULL
├─ tenant_id TEXT NOT NULL DEFAULT 'default'
├─ actor TEXT NOT NULL                   ← 'cli', 'api_key:hk_abc123', or 'system'
├─ op TEXT NOT NULL                      ← 'remember' | 'recall' | 'promote' | 'supersede' | 'forget' | 'archive_raw'
├─ target_id TEXT                        ← memory id, working_memory id, etc.
├─ metadata_json TEXT NOT NULL DEFAULT '{}'
└─ INDEX idx_audit_log_tenant_ts (tenant_id, ts DESC)
```

**Iron rule honored:** every new table (`api_keys`, `audit_log`) carries `tenant_id` from day 1.

**Auth model (stub):** one tenant per deployment. `HIPPO_TENANT` env var, default `'default'`. API keys are not tenant-scoped at validation time in the stub (any valid key authenticates as that deployment's tenant). Multi-tenant key→tenant mapping deferred to v2.

---

## Task list (sequence)

1. Migration v16 part 1: `tenant_id` on five tables + composite indexes
2. Migration v16 part 2: `api_keys` and `audit_log` tables
3. Backfill + invariant test for migration v16
4. `src/auth.ts` — `createApiKey`, `validateApiKey`, `revokeApiKey`, `listApiKeys`
5. `src/audit.ts` — `appendAuditEvent`, `queryAuditEvents`
6. `src/tenant.ts` — `resolveTenantId(opts)` from env/API key/default
7. Wire `tenant_id` into `writeEntry` / `readEntry` round trip
8. Tenant filter on recall path (`recallByQuery` and friends)
9. Audit hooks: emit events from `remember`, `recall`, `promote`, `supersede`, `forget`, `archive_raw`
10. CLI: `hippo auth create|list|revoke`
11. CLI: `hippo audit list [--op X] [--since Y] [--limit N]`
12. SSO/SCIM stubs in `src/sso.ts` that throw `NotImplementedError`
13. Cross-tenant negative test (recall isolation) and audit-completeness test
14. Eval re-run + bump to v0.35.0

Effort estimate: 2-3w solo. ~1 commit per task = 14 commits. The first 9 land the primitives; tasks 10-13 surface them; task 14 ships.

---

## Conventions

- **Tests use real DB.** Mock-free. Pattern: `mkdtempSync`, `openHippoDb(home)`, exercise primitive, `closeHippoDb`, `rmSync`.
- **Migrations are idempotent.** Use `tableHasColumn` guard + `CREATE INDEX IF NOT EXISTS` + `CREATE TABLE IF NOT EXISTS`. Copy the v14/v15 patterns in `src/db.ts:259-363`.
- **No `--no-verify`.** Pre-commit hook runs lint + types + tests on touched files. If it fails, fix the underlying issue.
- **Bite-sized commits.** Every task ends with a passing test and a single commit. If a task grows past 4-5 file edits, split it.
- **API keys are never stored plaintext.** Generate `hk_<24 random base32 chars>`, scrypt-hash it, return plaintext to caller exactly once.

---

### Task 1: Migration v16 part 1 — `tenant_id` columns + composite indexes

**Files:**
- Modify: `src/db.ts:24` — bump `CURRENT_SCHEMA_VERSION` to `16`
- Modify: `src/db.ts:323-363` — append migration v16 after v15
- Test: `tests/a5-tenant-migration.test.ts` (new)

**Step 1: Write the failing test**

```typescript
// tests/a5-tenant-migration.test.ts
import { describe, it, expect } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { openHippoDb, closeHippoDb, getSchemaVersion, getCurrentSchemaVersion } from '../src/db.js';

describe('A5 schema migration v16: tenant_id columns', () => {
  it('migrates to schema version 16', () => {
    const home = mkdtempSync(join(tmpdir(), 'hippo-a5-'));
    const db = openHippoDb(home);
    try {
      expect(getSchemaVersion(db)).toBe(16);
      expect(getCurrentSchemaVersion()).toBe(16);
    } finally {
      closeHippoDb(db);
      rmSync(home, { recursive: true, force: true });
    }
  });

  it('adds tenant_id to memories, working_memory, consolidation_runs, task_snapshots, memory_conflicts', () => {
    const home = mkdtempSync(join(tmpdir(), 'hippo-a5-'));
    const db = openHippoDb(home);
    try {
      for (const tbl of ['memories', 'working_memory', 'consolidation_runs', 'task_snapshots', 'memory_conflicts']) {
        const cols = db.prepare(`PRAGMA table_info(${tbl})`).all() as Array<{ name: string }>;
        expect(cols.some(c => c.name === 'tenant_id'), `${tbl}.tenant_id missing`).toBe(true);
      }
    } finally {
      closeHippoDb(db);
      rmSync(home, { recursive: true, force: true });
    }
  });

  it('creates composite (tenant_id, created/timestamp/...) indexes on each table', () => {
    const home = mkdtempSync(join(tmpdir(), 'hippo-a5-'));
    const db = openHippoDb(home);
    try {
      const indexes = db.prepare(`SELECT name FROM sqlite_master WHERE type='index'`).all() as Array<{ name: string }>;
      const names = new Set(indexes.map(i => i.name));
      expect(names.has('idx_memories_tenant_created')).toBe(true);
      expect(names.has('idx_working_memory_tenant')).toBe(true);
      expect(names.has('idx_consolidation_runs_tenant_ts')).toBe(true);
      expect(names.has('idx_task_snapshots_tenant_status')).toBe(true);
    } finally {
      closeHippoDb(db);
      rmSync(home, { recursive: true, force: true });
    }
  });
});
```

**Step 2: Run test to verify it fails**

```bash
npx vitest run tests/a5-tenant-migration.test.ts
```
Expected: FAIL — `getCurrentSchemaVersion()` returns 15, columns missing.

**Step 3: Write the migration**

Append to `src/db.ts` MIGRATIONS array (after v15):

```typescript
{
  version: 16,
  up: (db) => {
    // A5 stub auth: add tenant_id to all data tables. Single-tenant per deployment;
    // multi-tenant enforcement deferred to v2 (full A5). The columns are needed now
    // so future B-track tables don't have to backfill.
    const tables: Array<[string, string]> = [
      ['memories', 'created'],
      ['working_memory', 'created_at'],
      ['consolidation_runs', 'timestamp'],
      ['task_snapshots', 'updated_at'],
      ['memory_conflicts', 'detected_at'],
    ];
    for (const [tbl] of tables) {
      if (!tableHasColumn(db, tbl, 'tenant_id')) {
        db.exec(`ALTER TABLE ${tbl} ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default'`);
      }
    }
    // Composite indexes for recall hot paths.
    db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_tenant_created ON memories(tenant_id, created)`);
    db.exec(`CREATE INDEX IF NOT EXISTS idx_working_memory_tenant ON working_memory(tenant_id, importance DESC, created_at DESC)`);
    db.exec(`CREATE INDEX IF NOT EXISTS idx_consolidation_runs_tenant_ts ON consolidation_runs(tenant_id, timestamp DESC)`);
    db.exec(`CREATE INDEX IF NOT EXISTS idx_task_snapshots_tenant_status ON task_snapshots(tenant_id, status, updated_at DESC)`);
  },
},
```

Bump `CURRENT_SCHEMA_VERSION` to `16` at line 24.

**Step 4: Run tests**

```bash
npx vitest run tests/a5-tenant-migration.test.ts
```
Expected: PASS.

Also bump the schema version assertion in `tests/pr2-session-continuity.test.ts:39` from `15` to `16`. Run full suite:

```bash
npx vitest run
```
Expected: all green (730+ existing + 3 new = 733+).

**Step 5: Commit**

```bash
git add src/db.ts tests/a5-tenant-migration.test.ts tests/pr2-session-continuity.test.ts
git commit -m "feat(a5): schema v16 — tenant_id on 5 data tables + composite indexes"
```

---

### Task 2: Migration v16 part 2 — `api_keys` and `audit_log` tables

**Files:**
- Modify: `src/db.ts` (extend the v16 migration body)
- Test: `tests/a5-tenant-migration.test.ts` (add cases)

**Step 1: Add failing tests**

```typescript
describe('A5 schema migration v16: api_keys and audit_log tables', () => {
  it('creates api_keys table with required columns', () => {
    const home = mkdtempSync(join(tmpdir(), 'hippo-a5-'));
    const db = openHippoDb(home);
    try {
      const cols = db.prepare(`PRAGMA table_info(api_keys)`).all() as Array<{ name: string; notnull: number }>;
      const names = new Set(cols.map(c => c.name));
      for (const required of ['id', 'key_id', 'key_hash', 'tenant_id', 'created_at', 'revoked_at', 'label']) {
        expect(names.has(required), `api_keys.${required} missing`).toBe(true);
      }
    } finally {
      closeHippoDb(db);
      rmSync(home, { recursive: true, force: true });
    }
  });

  it('enforces UNIQUE on api_keys.key_id', () => {
    const home = mkdtempSync(join(tmpdir(), 'hippo-a5-'));
    const db = openHippoDb(home);
    try {
      db.prepare(`INSERT INTO api_keys(key_id, key_hash, tenant_id, created_at) VALUES (?, ?, ?, ?)`)
        .run('hk_abc', 'hash1', 'default', new Date().toISOString());
      expect(() =>
        db.prepare(`INSERT INTO api_keys(key_id, key_hash, tenant_id, created_at) VALUES (?, ?, ?, ?)`)
          .run('hk_abc', 'hash2', 'default', new Date().toISOString())
      ).toThrow(/UNIQUE/i);
    } finally {
      closeHippoDb(db);
      rmSync(home, { recursive: true, force: true });
    }
  });

  it('creates audit_log table with required columns', () => {
    const home = mkdtempSync(join(tmpdir(), 'hippo-a5-'));
    const db = openHippoDb(home);
    try {
      const cols = db.prepare(`PRAGMA table_info(audit_log)`).all() as Array<{ name: string }>;
      const names = new Set(cols.map(c => c.name));
      for (const required of ['id', 'ts', 'tenant_id', 'actor', 'op', 'target_id', 'metadata_json']) {
        expect(names.has(required), `audit_log.${required} missing`).toBe(true);
      }
    } finally {
      closeHippoDb(db);
      rmSync(home, { recursive: true, force: true });
    }
  });
});
```

**Step 2: Run, see fail.**

**Step 3: Extend the v16 migration body**

```typescript
db.exec(`
  CREATE TABLE IF NOT EXISTS api_keys (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    key_id TEXT UNIQUE NOT NULL,
    key_hash TEXT NOT NULL,
    tenant_id TEXT NOT NULL DEFAULT 'default',
    label TEXT,
    created_at TEXT NOT NULL,
    revoked_at TEXT
  );
  CREATE INDEX IF NOT EXISTS idx_api_keys_tenant_active
    ON api_keys(tenant_id) WHERE revoked_at IS NULL;
`);
db.exec(`
  CREATE TABLE IF NOT EXISTS audit_log (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    ts TEXT NOT NULL,
    tenant_id TEXT NOT NULL DEFAULT 'default',
    actor TEXT NOT NULL,
    op TEXT NOT NULL,
    target_id TEXT,
    metadata_json TEXT NOT NULL DEFAULT '{}'
  );
  CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_ts ON audit_log(tenant_id, ts DESC);
`);
```

**Step 4: Run, see pass.**

**Step 5: Commit**

```bash
git add src/db.ts tests/a5-tenant-migration.test.ts
git commit -m "feat(a5): schema v16 — api_keys and audit_log tables"
```

---

### Task 3: Backfill + invariant test

**Files:**
- Test: `tests/a5-tenant-migration.test.ts` (add backfill case)

The migration uses `DEFAULT 'default'` so existing rows are auto-backfilled. Add a regression test that proves it.

**Step 1: Add failing test**

```typescript
describe('A5 v16 backfill', () => {
  it('existing memories get tenant_id="default" after migration', () => {
    const home = mkdtempSync(join(tmpdir(), 'hippo-a5-backfill-'));
    // Seed at v15 by running migrations only up to 15. Easiest: open the DB once
    // (which runs all migrations), then manually insert a row WITHOUT tenant_id and
    // verify the DEFAULT applies. Pre-existing rows from the live system would have
    // gone through this path on first open after upgrade.
    const db = openHippoDb(home);
    try {
      db.prepare(
        `INSERT INTO memories (id, created, last_retrieved, retrieval_count, strength, half_life_days, layer, tags_json, emotional_valence, schema_fit, source, conflicts_with_json, pinned, confidence, content, kind) VALUES ('m1','2026-04-01','2026-04-01',0,1.0,7,'episodic','[]','neutral',0.5,'test','[]',0,'observed','c','distilled')`,
      ).run();
      const row = db.prepare(`SELECT tenant_id FROM memories WHERE id='m1'`).get() as { tenant_id: string };
      expect(row.tenant_id).toBe('default');
    } finally {
      closeHippoDb(db);
      rmSync(home, { recursive: true, force: true });
    }
  });
});
```

**Step 2-4: standard TDD loop. The test should already pass given Task 1's `DEFAULT 'default'`.** If it fails, fix the migration.

**Step 5: Commit**

```bash
git add tests/a5-tenant-migration.test.ts
git commit -m "test(a5): regression — pre-A5 rows get tenant_id default on insert"
```

---

### Task 4: `src/auth.ts` — API key primitives

**Files:**
- Create: `src/auth.ts`
- Test: `tests/auth.test.ts`

**Step 1: Failing tests**

```typescript
// tests/auth.test.ts
import { describe, it, expect } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { openHippoDb, closeHippoDb } from '../src/db.js';
import { createApiKey, validateApiKey, revokeApiKey, listApiKeys } from '../src/auth.js';

describe('auth', () => {
  it('createApiKey returns plaintext exactly once and stores hash', () => {
    const home = mkdtempSync(join(tmpdir(), 'hippo-auth-'));
    const db = openHippoDb(home);
    try {
      const { keyId, plaintext } = createApiKey(db, { tenantId: 'default', label: 'cli' });
      expect(keyId).toMatch(/^hk_[a-z2-7]{24}$/);
      expect(plaintext).toMatch(/^hk_[a-z2-7]{24}\.[a-z2-7]+$/);
      // Stored row exists, hash != plaintext
      const row = db.prepare(`SELECT key_hash FROM api_keys WHERE key_id=?`).get(keyId) as { key_hash: string };
      expect(row.key_hash).not.toBe(plaintext);
      expect(row.key_hash.length).toBeGreaterThan(20);
    } finally {
      closeHippoDb(db);
      rmSync(home, { recursive: true, force: true });
    }
  });

  it('validateApiKey: positive returns tenant context', () => {
    const home = mkdtempSync(join(tmpdir(), 'hippo-auth-'));
    const db = openHippoDb(home);
    try {
      const { plaintext } = createApiKey(db, { tenantId: 'default', label: 'test' });
      const ctx = validateApiKey(db, plaintext);
      expect(ctx).toEqual({ valid: true, tenantId: 'default', keyId: expect.stringMatching(/^hk_/) });
    } finally {
      closeHippoDb(db);
      rmSync(home, { recursive: true, force: true });
    }
  });

  it('validateApiKey: rejects unknown plaintext', () => {
    const home = mkdtempSync(join(tmpdir(), 'hippo-auth-'));
    const db = openHippoDb(home);
    try {
      const ctx = validateApiKey(db, 'hk_aaaaaaaaaaaaaaaaaaaaaaaa.deadbeef');
      expect(ctx.valid).toBe(false);
    } finally {
      closeHippoDb(db);
      rmSync(home, { recursive: true, force: true });
    }
  });

  it('revokeApiKey: revoked keys fail validation', () => {
    const home = mkdtempSync(join(tmpdir(), 'hippo-auth-'));
    const db = openHippoDb(home);
    try {
      const { keyId, plaintext } = createApiKey(db, { tenantId: 'default' });
      revokeApiKey(db, keyId);
      const ctx = validateApiKey(db, plaintext);
      expect(ctx.valid).toBe(false);
    } finally {
      closeHippoDb(db);
      rmSync(home, { recursive: true, force: true });
    }
  });

  it('listApiKeys excludes revoked when active=true', () => {
    const home = mkdtempSync(join(tmpdir(), 'hippo-auth-'));
    const db = openHippoDb(home);
    try {
      const a = createApiKey(db, { tenantId: 'default', label: 'a' });
      const b = createApiKey(db, { tenantId: 'default', label: 'b' });
      revokeApiKey(db, b.keyId);
      const active = listApiKeys(db, { active: true });
      expect(active.map(k => k.keyId)).toEqual([a.keyId]);
      const all = listApiKeys(db, { active: false });
      expect(all.length).toBe(2);
    } finally {
      closeHippoDb(db);
      rmSync(home, { recursive: true, force: true });
    }
  });
});
```

**Step 2: Run, see fail (`src/auth.ts` does not exist).**

**Step 3: Implement `src/auth.ts`**

```typescript
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
import type { DatabaseSyncLike } from './db.js';

const KEY_PREFIX = 'hk_';
const ID_LEN = 24;       // base32 chars after prefix
const SECRET_LEN = 32;   // base32 chars after dot
const SCRYPT_KEYLEN = 32;

const BASE32 = 'abcdefghijklmnopqrstuvwxyz234567';

function randBase32(n: number): string {
  const bytes = randomBytes(n);
  let out = '';
  for (let i = 0; i < n; i++) out += BASE32[bytes[i]! % 32];
  return out;
}

function hashKey(plaintext: string): string {
  // Format: scrypt$<saltHex>$<hashHex>
  const salt = randomBytes(16);
  const hash = scryptSync(plaintext, salt, SCRYPT_KEYLEN);
  return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`;
}

function verifyKey(plaintext: string, stored: string): boolean {
  const parts = stored.split('$');
  if (parts.length !== 3 || parts[0] !== 'scrypt') return false;
  const salt = Buffer.from(parts[1]!, 'hex');
  const expected = Buffer.from(parts[2]!, 'hex');
  const actual = scryptSync(plaintext, salt, expected.length);
  return expected.length === actual.length && timingSafeEqual(expected, actual);
}

export interface CreateApiKeyOpts {
  tenantId: string;
  label?: string;
}

export interface CreateApiKeyResult {
  keyId: string;
  plaintext: string;
}

export function createApiKey(db: DatabaseSyncLike, opts: CreateApiKeyOpts): CreateApiKeyResult {
  const keyId = `${KEY_PREFIX}${randBase32(ID_LEN)}`;
  const secret = randBase32(SECRET_LEN);
  const plaintext = `${keyId}.${secret}`;
  const hash = hashKey(plaintext);
  db.prepare(
    `INSERT INTO api_keys (key_id, key_hash, tenant_id, label, created_at) VALUES (?, ?, ?, ?, ?)`,
  ).run(keyId, hash, opts.tenantId, opts.label ?? null, new Date().toISOString());
  return { keyId, plaintext };
}

export interface ValidateResult {
  valid: boolean;
  tenantId?: string;
  keyId?: string;
}

export function validateApiKey(db: DatabaseSyncLike, plaintext: string): ValidateResult {
  const dot = plaintext.indexOf('.');
  if (dot < 0) return { valid: false };
  const keyId = plaintext.slice(0, dot);
  const row = db
    .prepare(`SELECT key_hash, tenant_id, revoked_at FROM api_keys WHERE key_id = ?`)
    .get(keyId) as { key_hash: string; tenant_id: string; revoked_at: string | null } | undefined;
  if (!row || row.revoked_at) return { valid: false };
  if (!verifyKey(plaintext, row.key_hash)) return { valid: false };
  return { valid: true, tenantId: row.tenant_id, keyId };
}

export function revokeApiKey(db: DatabaseSyncLike, keyId: string): void {
  db.prepare(`UPDATE api_keys SET revoked_at = ? WHERE key_id = ? AND revoked_at IS NULL`)
    .run(new Date().toISOString(), keyId);
}

export interface ApiKeyListItem {
  keyId: string;
  tenantId: string;
  label: string | null;
  createdAt: string;
  revokedAt: string | null;
}

export function listApiKeys(db: DatabaseSyncLike, opts: { active: boolean }): ApiKeyListItem[] {
  const sql = opts.active
    ? `SELECT key_id, tenant_id, label, created_at, revoked_at FROM api_keys WHERE revoked_at IS NULL ORDER BY id DESC`
    : `SELECT key_id, tenant_id, label, created_at, revoked_at FROM api_keys ORDER BY id DESC`;
  const rows = db.prepare(sql).all() as Array<{
    key_id: string; tenant_id: string; label: string | null; created_at: string; revoked_at: string | null;
  }>;
  return rows.map(r => ({
    keyId: r.key_id, tenantId: r.tenant_id, label: r.label,
    createdAt: r.created_at, revokedAt: r.revoked_at,
  }));
}
```

**Step 4: Run, see pass.**

**Step 5: Commit**

```bash
git add src/auth.ts tests/auth.test.ts
git commit -m "feat(a5): API key primitives — scrypt-hashed, single-use plaintext"
```

---

### Task 5: `src/audit.ts` — audit log primitives

**Files:**
- Create: `src/audit.ts`
- Test: `tests/audit.test.ts`

**Step 1: Failing tests**

```typescript
// tests/audit.test.ts
import { describe, it, expect } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { openHippoDb, closeHippoDb } from '../src/db.js';
import { appendAuditEvent, queryAuditEvents } from '../src/audit.js';

describe('audit log', () => {
  it('appendAuditEvent persists row with required fields', () => {
    const home = mkdtempSync(join(tmpdir(), 'hippo-audit-'));
    const db = openHippoDb(home);
    try {
      appendAuditEvent(db, {
        tenantId: 'default',
        actor: 'cli',
        op: 'remember',
        targetId: 'm1',
        metadata: { content_len: 42 },
      });
      const rows = queryAuditEvents(db, { tenantId: 'default' });
      expect(rows.length).toBe(1);
      expect(rows[0]!.op).toBe('remember');
      expect(rows[0]!.targetId).toBe('m1');
      expect(rows[0]!.metadata.content_len).toBe(42);
      expect(rows[0]!.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
    } finally {
      closeHippoDb(db);
      rmSync(home, { recursive: true, force: true });
    }
  });

  it('queryAuditEvents filters by op and limit', () => {
    const home = mkdtempSync(join(tmpdir(), 'hippo-audit-'));
    const db = openHippoDb(home);
    try {
      for (const op of ['remember', 'remember', 'recall', 'forget']) {
        appendAuditEvent(db, { tenantId: 'default', actor: 'cli', op });
      }
      const recalls = queryAuditEvents(db, { tenantId: 'default', op: 'recall' });
      expect(recalls.length).toBe(1);
      const limited = queryAuditEvents(db, { tenantId: 'default', limit: 2 });
      expect(limited.length).toBe(2);
    } finally {
      closeHippoDb(db);
      rmSync(home, { recursive: true, force: true });
    }
  });

  it('queryAuditEvents isolates by tenant', () => {
    const home = mkdtempSync(join(tmpdir(), 'hippo-audit-'));
    const db = openHippoDb(home);
    try {
      appendAuditEvent(db, { tenantId: 'alpha', actor: 'cli', op: 'remember' });
      appendAuditEvent(db, { tenantId: 'beta', actor: 'cli', op: 'remember' });
      expect(queryAuditEvents(db, { tenantId: 'alpha' }).length).toBe(1);
      expect(queryAuditEvents(db, { tenantId: 'beta' }).length).toBe(1);
    } finally {
      closeHippoDb(db);
      rmSync(home, { recursive: true, force: true });
    }
  });
});
```

**Step 2: Run, fail.**

**Step 3: Implement `src/audit.ts`**

```typescript
import type { DatabaseSyncLike } from './db.js';

export type AuditOp =
  | 'remember' | 'recall' | 'promote' | 'supersede' | 'forget' | 'archive_raw';

export interface AppendAuditOpts {
  tenantId: string;
  actor: string;        // 'cli' | 'api_key:hk_...' | 'system'
  op: AuditOp;
  targetId?: string;
  metadata?: Record<string, unknown>;
}

export function appendAuditEvent(db: DatabaseSyncLike, opts: AppendAuditOpts): void {
  db.prepare(
    `INSERT INTO audit_log (ts, tenant_id, actor, op, target_id, metadata_json) VALUES (?, ?, ?, ?, ?, ?)`,
  ).run(
    new Date().toISOString(),
    opts.tenantId,
    opts.actor,
    opts.op,
    opts.targetId ?? null,
    JSON.stringify(opts.metadata ?? {}),
  );
}

export interface QueryAuditOpts {
  tenantId: string;
  op?: AuditOp;
  since?: string; // ISO timestamp
  limit?: number;
}

export interface AuditEvent {
  id: number;
  ts: string;
  tenantId: string;
  actor: string;
  op: AuditOp;
  targetId: string | null;
  metadata: Record<string, unknown>;
}

export function queryAuditEvents(db: DatabaseSyncLike, opts: QueryAuditOpts): AuditEvent[] {
  const where: string[] = ['tenant_id = ?'];
  const params: unknown[] = [opts.tenantId];
  if (opts.op) { where.push('op = ?'); params.push(opts.op); }
  if (opts.since) { where.push('ts >= ?'); params.push(opts.since); }
  const limit = Math.max(1, Math.min(opts.limit ?? 100, 10000));
  const rows = db.prepare(
    `SELECT id, ts, tenant_id, actor, op, target_id, metadata_json
     FROM audit_log WHERE ${where.join(' AND ')} ORDER BY ts DESC, id DESC LIMIT ?`,
  ).all(...params, limit) as Array<{
    id: number; ts: string; tenant_id: string; actor: string;
    op: string; target_id: string | null; metadata_json: string;
  }>;
  return rows.map(r => ({
    id: r.id, ts: r.ts, tenantId: r.tenant_id, actor: r.actor,
    op: r.op as AuditOp, targetId: r.target_id,
    metadata: safeJsonParse(r.metadata_json),
  }));
}

function safeJsonParse(raw: string): Record<string, unknown> {
  try { const v = JSON.parse(raw); return typeof v === 'object' && v !== null ? v : {}; }
  catch { return {}; }
}
```

**Step 4: Pass.**

**Step 5: Commit**

```bash
git add src/audit.ts tests/audit.test.ts
git commit -m "feat(a5): audit_log append + query primitives"
```

---

### Task 6: `src/tenant.ts` — resolveTenantId

**Files:**
- Create: `src/tenant.ts`
- Test: `tests/tenant.test.ts`

The resolution order: explicit `apiKey` > `HIPPO_TENANT` env > `'default'`.

**Step 1: Failing test**

```typescript
// tests/tenant.test.ts
import { describe, it, expect } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { openHippoDb, closeHippoDb } from '../src/db.js';
import { createApiKey } from '../src/auth.js';
import { resolveTenantId } from '../src/tenant.js';

describe('resolveTenantId', () => {
  it('returns "default" with no env, no api key', () => {
    delete process.env.HIPPO_TENANT;
    expect(resolveTenantId({})).toBe('default');
  });

  it('returns env value when set', () => {
    process.env.HIPPO_TENANT = 'acme';
    try {
      expect(resolveTenantId({})).toBe('acme');
    } finally {
      delete process.env.HIPPO_TENANT;
    }
  });

  it('api key tenant beats env', () => {
    const home = mkdtempSync(join(tmpdir(), 'hippo-tenant-'));
    const db = openHippoDb(home);
    process.env.HIPPO_TENANT = 'env_tenant';
    try {
      const { plaintext } = createApiKey(db, { tenantId: 'key_tenant' });
      expect(resolveTenantId({ db, apiKey: plaintext })).toBe('key_tenant');
    } finally {
      delete process.env.HIPPO_TENANT;
      closeHippoDb(db);
      rmSync(home, { recursive: true, force: true });
    }
  });

  it('invalid api key throws', () => {
    const home = mkdtempSync(join(tmpdir(), 'hippo-tenant-'));
    const db = openHippoDb(home);
    try {
      expect(() => resolveTenantId({ db, apiKey: 'hk_bogus.x' })).toThrow(/invalid api key/i);
    } finally {
      closeHippoDb(db);
      rmSync(home, { recursive: true, force: true });
    }
  });
});
```

**Step 3: Implement**

```typescript
// src/tenant.ts
import type { DatabaseSyncLike } from './db.js';
import { validateApiKey } from './auth.js';

export interface ResolveOpts {
  db?: DatabaseSyncLike;
  apiKey?: string;
}

export function resolveTenantId(opts: ResolveOpts): string {
  if (opts.apiKey) {
    if (!opts.db) throw new Error('resolveTenantId: db required when apiKey is set');
    const ctx = validateApiKey(opts.db, opts.apiKey);
    if (!ctx.valid || !ctx.tenantId) throw new Error('invalid api key');
    return ctx.tenantId;
  }
  return process.env.HIPPO_TENANT ?? 'default';
}
```

**Step 5: Commit**

```bash
git add src/tenant.ts tests/tenant.test.ts
git commit -m "feat(a5): resolveTenantId — env / api-key resolution"
```

---

### Task 7: tenant_id round-trip in writeEntry/readEntry

**Files:**
- Modify: `src/store.ts` (functions: `writeEntry`, `readEntry`, `serializeEntry`, `deserializeEntry` — same surface that A3 task 6 touched)
- Modify: `src/memory.ts` — extend `MemoryEntry` interface with `tenantId?: string`
- Test: `tests/store-tenant-roundtrip.test.ts` (new)

**Step 1: Failing test**

```typescript
// tests/store-tenant-roundtrip.test.ts
import { describe, it, expect } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initStore, writeEntry, readEntry } from '../src/store.js';
import { createMemory } from '../src/memory.js';

describe('store roundtrip with tenant_id', () => {
  it('writeEntry persists tenant_id, readEntry returns it', () => {
    const home = mkdtempSync(join(tmpdir(), 'hippo-store-tenant-'));
    initStore(home);
    const entry = createMemory({
      content: 'tenant test',
      tenantId: 'acme',
    });
    writeEntry(home, entry);
    const loaded = readEntry(home, entry.id);
    expect(loaded?.tenantId).toBe('acme');
    rmSync(home, { recursive: true, force: true });
  });

  it('omitting tenantId defaults to "default" on read', () => {
    const home = mkdtempSync(join(tmpdir(), 'hippo-store-tenant-'));
    initStore(home);
    const entry = createMemory({ content: 'no tenant' });
    writeEntry(home, entry);
    const loaded = readEntry(home, entry.id);
    expect(loaded?.tenantId).toBe('default');
    rmSync(home, { recursive: true, force: true });
  });
});
```

**Step 3: Implementation**

In `src/memory.ts`:
- Add `tenantId?: string` to `MemoryEntry`.
- In `createMemory`, default `tenantId = opts.tenantId ?? 'default'`.

In `src/store.ts`:
- `writeEntry` SQL: include `tenant_id` in INSERT column list, parameter `entry.tenantId ?? 'default'`.
- `readEntry` row mapping: pull `tenant_id` field, set on returned entry.
- `serializeEntry` markdown frontmatter: emit `tenant_id:` line when not 'default'.
- `deserializeEntry` markdown parser: read `tenant_id:` field if present.

(Mirror the A3 envelope round-trip pattern in the same file.)

**Step 5: Commit**

```bash
git add src/store.ts src/memory.ts tests/store-tenant-roundtrip.test.ts
git commit -m "feat(a5): tenant_id round-trip through writeEntry/readEntry + markdown mirror"
```

---

### Task 8: Tenant filter on recall path

**Files:**
- Modify: `src/store.ts` — recall functions (`recallByQuery`, `loadAllMemories`, anywhere that emits memories)
- Test: `tests/recall-tenant-isolation.test.ts` (new)

**Step 1: Failing test (THE cross-tenant negative test from ROADMAP commitments)**

```typescript
// tests/recall-tenant-isolation.test.ts
import { describe, it, expect } from 'vitest';
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { execSync } from 'node:child_process';

const repoRoot = resolve(__dirname, '..');
const cli = resolve(repoRoot, 'dist', 'cli.js');

describe('cross-tenant recall isolation', () => {
  it('tenant A recall does not return tenant B memories', () => {
    if (!existsSync(cli)) throw new Error('build first');
    const home = mkdtempSync(join(tmpdir(), 'hippo-iso-'));
    const envA = { ...process.env, HIPPO_HOME: home, HIPPO_TENANT: 'tenant_a' };
    const envB = { ...process.env, HIPPO_HOME: home, HIPPO_TENANT: 'tenant_b' };
    try {
      execSync(`node "${cli}" init --global`, { env: envA, cwd: home });
      execSync(`node "${cli}" remember "alpha-secret-xyz" --global`, { env: envA, cwd: home });
      execSync(`node "${cli}" remember "beta-secret-xyz" --global`, { env: envB, cwd: home });

      const aOut = execSync(`node "${cli}" recall "secret-xyz" --global`, { env: envA, cwd: home }).toString();
      const bOut = execSync(`node "${cli}" recall "secret-xyz" --global`, { env: envB, cwd: home }).toString();

      expect(aOut).toContain('alpha-secret-xyz');
      expect(aOut).not.toContain('beta-secret-xyz');
      expect(bOut).toContain('beta-secret-xyz');
      expect(bOut).not.toContain('alpha-secret-xyz');
    } finally {
      rmSync(home, { recursive: true, force: true });
    }
  });
});
```

**Step 3: Implementation**

In `src/store.ts`, every SELECT that hydrates memories for recall must include `WHERE tenant_id = ?`. Pass `tenantId` from the caller (CLI resolves once at boot via `resolveTenantId`). The composite index `idx_memories_tenant_created` from Task 1 makes this free.

Wire CLI: in `src/cli.ts` recall handler (the same place `--why` was wired), call `resolveTenantId({})` once, pass into recall functions.

**Step 5: Commit**

```bash
git add src/store.ts src/cli.ts tests/recall-tenant-isolation.test.ts
git commit -m "feat(a5): recall path filters by tenant_id (cross-tenant isolation)"
```

---

### Task 9: Audit hooks on every mutation

**Files:**
- Modify: `src/store.ts` — `writeEntry`, `deleteEntry`, `markSuperseded`, `promoteToSemantic`
- Modify: `src/raw-archive.ts` — emit on archive
- Modify: `src/cli.ts` — emit `recall` event from recall handler
- Test: `tests/audit-completeness.test.ts` (new)

**Step 1: Failing test**

```typescript
// tests/audit-completeness.test.ts
import { describe, it, expect } from 'vitest';
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { execSync } from 'node:child_process';
import { openHippoDb, closeHippoDb } from '../src/db.js';
import { queryAuditEvents } from '../src/audit.js';

const cli = resolve(__dirname, '..', 'dist', 'cli.js');

describe('audit log captures every mutation', () => {
  it('remember -> recall -> forget all logged', () => {
    if (!existsSync(cli)) throw new Error('build first');
    const home = mkdtempSync(join(tmpdir(), 'hippo-audit-cli-'));
    const env = { ...process.env, HIPPO_HOME: home };
    try {
      execSync(`node "${cli}" init --global`, { env, cwd: home });
      execSync(`node "${cli}" remember "audit-canary-99" --global`, { env, cwd: home });
      execSync(`node "${cli}" recall "audit-canary-99" --global`, { env, cwd: home });

      const db = openHippoDb(join(home, 'hippo-global'));
      try {
        const events = queryAuditEvents(db, { tenantId: 'default' });
        const ops = events.map(e => e.op);
        expect(ops).toContain('remember');
        expect(ops).toContain('recall');
      } finally {
        closeHippoDb(db);
      }
    } finally {
      rmSync(home, { recursive: true, force: true });
    }
  });
});
```

(Path `hippo-global` may differ — adjust to whatever the CLI's `--global` resolves to in tests. Match the pattern in `tests/recall-why-envelope.test.ts`.)

**Step 3: Implementation**

Add a small helper at the top of `src/store.ts`:

```typescript
import { appendAuditEvent } from './audit.js';
import { resolveTenantId } from './tenant.js';

function audit(db: DatabaseSyncLike, op: AuditOp, targetId?: string, metadata?: Record<string, unknown>): void {
  appendAuditEvent(db, {
    tenantId: resolveTenantId({}),  // env-based for CLI; later A1 server passes apiKey
    actor: 'cli',
    op, targetId, metadata,
  });
}
```

Hook calls:
- `writeEntry(...)`: after INSERT, `audit(db, 'remember', entry.id, { kind, scope })`
- `deleteEntry(...)`: before DELETE, `audit(db, 'forget', id)`
- `markSuperseded(...)`: `audit(db, 'supersede', oldId, { newId })`
- `promoteToSemantic(...)`: `audit(db, 'promote', id)`
- `archiveRawMemory` in `src/raw-archive.ts`: `audit(db, 'archive_raw', id, { reason: opts.reason })`
- `cli.ts` recall handler: emit one `recall` event per query

**Step 5: Commit**

```bash
git add src/store.ts src/raw-archive.ts src/cli.ts tests/audit-completeness.test.ts
git commit -m "feat(a5): audit hooks on remember/recall/promote/supersede/forget/archive"
```

---

### Task 10: CLI — `hippo auth` subcommands

**Files:**
- Modify: `src/cli.ts` — add `auth` command parser (mirror existing subcommand wiring)
- Test: `tests/cli-auth.test.ts` (new)

Subcommands:
- `hippo auth create [--label <s>] [--tenant <id>]` → prints plaintext exactly once + key id
- `hippo auth list [--all]` → table of key id, tenant, label, created, revoked
- `hippo auth revoke <key_id>` → marks revoked

**Step 1: Failing test**

```typescript
// tests/cli-auth.test.ts
import { describe, it, expect } from 'vitest';
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { execSync } from 'node:child_process';

const cli = resolve(__dirname, '..', 'dist', 'cli.js');

describe('hippo auth CLI', () => {
  it('create -> list -> revoke flow', () => {
    if (!existsSync(cli)) throw new Error('build first');
    const home = mkdtempSync(join(tmpdir(), 'hippo-auth-cli-'));
    const env = { ...process.env, HIPPO_HOME: home };
    try {
      execSync(`node "${cli}" init --global`, { env, cwd: home });

      const createOut = execSync(`node "${cli}" auth create --label test --global`, { env, cwd: home }).toString();
      const keyMatch = createOut.match(/(hk_[a-z2-7]{24})/);
      const plainMatch = createOut.match(/(hk_[a-z2-7]{24}\.[a-z2-7]+)/);
      expect(keyMatch, 'key_id missing in output').toBeTruthy();
      expect(plainMatch, 'plaintext missing in output').toBeTruthy();
      const keyId = keyMatch![1]!;

      const listOut = execSync(`node "${cli}" auth list --global`, { env, cwd: home }).toString();
      expect(listOut).toContain(keyId);
      expect(listOut).toContain('test');

      execSync(`node "${cli}" auth revoke ${keyId} --global`, { env, cwd: home });
      const listAfter = execSync(`node "${cli}" auth list --global`, { env, cwd: home }).toString();
      expect(listAfter).not.toContain(keyId);

      const listAll = execSync(`node "${cli}" auth list --all --global`, { env, cwd: home }).toString();
      expect(listAll).toContain(keyId);
    } finally {
      rmSync(home, { recursive: true, force: true });
    }
  });
});
```

**Step 3: Implementation**

Add a top-level subcommand `auth` in `src/cli.ts`. Sub-routes to `create | list | revoke`. Lean on existing flag parser. Keys printed: emit plaintext on its own line with a one-line warning about saving it now.

**Step 5: Commit**

```bash
git add src/cli.ts tests/cli-auth.test.ts
git commit -m "feat(a5): hippo auth create/list/revoke CLI"
```

---

### Task 11: CLI — `hippo audit list`

**Files:**
- Modify: `src/cli.ts`
- Test: `tests/cli-audit.test.ts` (new)

Surface: `hippo audit list [--op <op>] [--since <iso>] [--limit <n>] [--json]`. Default tabular output, `--json` for machine consumers.

**Step 1: Test**

```typescript
// tests/cli-audit.test.ts
import { describe, it, expect } from 'vitest';
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { execSync } from 'node:child_process';

const cli = resolve(__dirname, '..', 'dist', 'cli.js');

describe('hippo audit list', () => {
  it('lists events after remember + recall', () => {
    if (!existsSync(cli)) throw new Error('build first');
    const home = mkdtempSync(join(tmpdir(), 'hippo-audit-cli-'));
    const env = { ...process.env, HIPPO_HOME: home };
    try {
      execSync(`node "${cli}" init --global`, { env, cwd: home });
      execSync(`node "${cli}" remember "audit-list-canary" --global`, { env, cwd: home });
      execSync(`node "${cli}" recall "audit-list-canary" --global`, { env, cwd: home });

      const out = execSync(`node "${cli}" audit list --json --global`, { env, cwd: home }).toString();
      const events = JSON.parse(out);
      const ops = events.map((e: { op: string }) => e.op);
      expect(ops).toContain('remember');
      expect(ops).toContain('recall');
    } finally {
      rmSync(home, { recursive: true, force: true });
    }
  });
});
```

**Step 5: Commit**

```bash
git add src/cli.ts tests/cli-audit.test.ts
git commit -m "feat(a5): hippo audit list CLI"
```

---

### Task 12: SSO/SCIM stubs

**Files:**
- Create: `src/sso.ts`
- Test: `tests/sso-stubs.test.ts`

Per ROADMAP: "SSO/SCIM hook points exist as stubs with explicit not-implemented errors."

```typescript
// src/sso.ts
export class NotImplementedError extends Error {
  constructor(feature: string) {
    super(`${feature} is not implemented in stub auth — tracked for v2 multi-tenant`);
    this.name = 'NotImplementedError';
  }
}

export function ssoLogin(_opts: { provider: 'oidc' | 'saml'; token: string }): never {
  throw new NotImplementedError('SSO login');
}

export function scimProvisionUser(_opts: { externalId: string; email: string }): never {
  throw new NotImplementedError('SCIM user provisioning');
}

export function scimDeprovisionUser(_externalId: string): never {
  throw new NotImplementedError('SCIM user deprovisioning');
}
```

Test: each function throws `NotImplementedError` with a string mentioning v2.

**Step 5: Commit**

```bash
git add src/sso.ts tests/sso-stubs.test.ts
git commit -m "feat(a5): SSO/SCIM hook stubs (NotImplementedError, tracked for v2)"
```

---

### Task 13: Eval re-run + acceptance check

**Files:** none (validation only).

**Step 1: Build**

```bash
npm run build
```

**Step 2: Run vitest**

```bash
npx vitest run
```
Expected: all green. Should now be ~745+ tests (730 baseline + ~15 new from A5).

**Step 3: Run micro eval**

```bash
HIPPO_BIN="node C:/Users/skf_s/hippo/dist/cli.js" python benchmarks/micro/run.py
```
Expected: 9/9 at 100% (no regression from A3 baseline).

**Step 4: Run LongMemEval R@5 (optional smoke)**

If time permits and the harness is wired: confirm R@5 didn't regress vs A3 baseline.

**Step 5: Acceptance checklist**

- [ ] Schema v16 lands clean on a v15 db (open existing test fixture, confirm migrations run)
- [ ] `tenant_id` columns exist on memories / working_memory / consolidation_runs / task_snapshots / memory_conflicts
- [ ] Composite indexes present
- [ ] `api_keys` table with UNIQUE(key_id), `audit_log` table
- [ ] `hippo auth create` prints plaintext exactly once
- [ ] `hippo auth list` excludes revoked by default; `--all` shows revoked
- [ ] `hippo auth revoke <id>` blocks subsequent `validateApiKey`
- [ ] `hippo audit list --json` returns array of events
- [ ] Cross-tenant recall test passes (alpha cannot see beta)
- [ ] Audit completeness test passes (remember + recall both logged)
- [ ] SSO/SCIM stubs throw `NotImplementedError`
- [ ] All existing tests still green (no regressions)
- [ ] Build clean, no TS errors

---

### Task 14: Ship

After acceptance, hand off to `/publish-repo` for the full release flow:
- Bump version `0.34.0 → 0.35.0` (minor — feature add) in 4 manifests
- CHANGELOG entry under `## 0.35.0`
- README "What's new in v0.35.0" section
- `/review` + fix findings before merge
- PR + npm publish

Anything beyond `tenant_id` enforcement at the data layer (full RLS, per-key tenant scoping at validate time, OAuth, SCIM provisioning) is **explicitly deferred to v2** and tracked in TODOS.md as "A5 v2 follow-ups".

---

## Footguns to watch

1. **Plaintext leak.** `createApiKey` returns plaintext exactly once. If you log it, it's leaked. Test should grep that no console.log in `src/auth.ts` echoes plaintext.
2. **Composite index ordering.** SQLite uses leading-column-first. Indexes must lead with `tenant_id` so cross-tenant queries are O(log n). Verify with `EXPLAIN QUERY PLAN SELECT ... WHERE tenant_id = ?`.
3. **Audit log unbounded growth.** No retention policy in stub. Note in TODOS.md as a follow-up — a daily cron `DELETE FROM audit_log WHERE ts < datetime('now', '-90 days')` is the obvious move, but design with care if regulatory retention applies.
4. **`tenant_id` on `memories_fts`.** FTS5 virtual table doesn't get the column from ALTER TABLE. Either add `tenant_id` to the FTS schema (requires drop + rebuild) or filter post-FTS by joining back to `memories`. The post-join approach is simpler and the index makes it cheap. Stick with post-join in this stub. Document in `MEMORY_ENVELOPE.md`.
5. **Existing default behaviour.** Single-user CLI usage stays at `tenant_id='default'`. Setting `HIPPO_TENANT=foo` must not silently strand old `default` rows. Document clearly in CLI help: "switching tenants does not migrate data".
6. **Test the migration path on real upgraded DB.** After local impl, run a manual upgrade test: open a v15-era DB (use the v0.34.0 release fixture if you can, otherwise seed one), run migrations, confirm v16 lands clean and existing data is intact with `tenant_id='default'`.

---

## Out of scope (v2 / full A5)

- Multi-tenant per-key isolation (one key → one tenant — already encoded but not enforced beyond the data layer)
- OAuth / OIDC provider integration
- SCIM provisioning
- RBAC (org > team > project > scope hierarchy)
- Approval boundaries for write-backs
- Audit log retention + export
- Rate limiting per tenant
- Postgres backend (A6)
