feat(schema): migration v93 take_domain_assignments (v0.41 T1)

Adds the JOIN table backing per-pack calibration domain aggregation
in the v0.41 lens-packs wave. Replaces the originally-planned scalar
`takes.domain` column after codex outside-voice review caught that
one take can legitimately belong to multiple domains (a take about
"Sequoia's investment in Anthropic" lands in deal_success AND
market_call), and that scalar attribution bakes today's pack→domain
mapping into permanent fact.

Schema: composite PK (take_id, domain) for idempotent re-assignment,
FK CASCADE so deleting a take cascades assignments, confidence CHECK
in [0,1], idx_take_domain_assignments_domain for the aggregator JOIN
direction. RLS guard matches takes/synthesis_evidence pattern (enable
when running as BYPASSRLS role). PGLite parity via sqlFor.pglite.

Backward-compat: pre-existing takes carry no assignments; aggregator
LEFT JOIN skips them gracefully. No backfill required at migration
time — propose_takes (T10) populates new rows; greenfield assignment
of historical takes is a v0.42 follow-up.

R-MIG IRON-RULE regression at test/migrations-v93.test.ts pins 12
contracts: existence/name, LATEST_VERSION advance, table queryable
after initSchema, column shape, composite PK rejects duplicate
(take_id, domain), multi-domain assignment permitted, FK ON DELETE
CASCADE, CHECK rejects out-of-range confidence, index presence,
aggregator JOIN direction returns per-domain counts, sql/sqlFor.pglite
parity grep, backward-compat LEFT JOIN handles unassigned takes.

Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md
First of 13 sequencing tasks in v0.41 lens packs + epistemology
unification wave (decisions D9-B → T1-B per codex challenge).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-24 00:02:11 -07:00
co-authored by Claude Opus 4.7
parent 3c1cc8a4d6
commit 9e17d0076a
2 changed files with 400 additions and 0 deletions
+65
View File
@@ -4257,6 +4257,71 @@ export const MIGRATIONS: Migration[] = [
WHERE config ? 'github_repo'; WHERE config ? 'github_repo';
`, `,
}, },
{
version: 93,
name: 'take_domain_assignments',
// v0.41 lens packs (Section 1 D9/T1 — codex outside-voice challenge to
// scalar `takes.domain` column). One take can legitimately belong to
// multiple calibration domains (a take about "Sequoia's investment in
// Anthropic" lands in deal_success AND market_call). A scalar column
// forces single-bucket attribution AND bakes today's pack→domain mapping
// into permanent fact. The JOIN table separates assignment from the take
// itself: history preserved when packs/mappings change, multi-domain
// attribution honest, third-party packs add domains without schema migration.
//
// Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md
//
// Composite PK `(take_id, domain)` prevents duplicate assignment of the
// same take to the same domain (idempotent re-assignment from
// propose_takes). Domain index covers the aggregator JOIN direction
// (calibration_profile widens to "for each domain in active pack's
// calibration_domains, JOIN take_domain_assignments WHERE domain = $1
// JOIN takes ON id = take_id WHERE active AND resolved").
//
// FK ON DELETE CASCADE because assignments are derived data — if the
// underlying take is hard-deleted (rare; takes are usually soft-resolved),
// assignments go with it. NULL `source` permits manual operator
// assignments without a synthetic source string.
sql: `
CREATE TABLE IF NOT EXISTS take_domain_assignments (
take_id BIGINT NOT NULL REFERENCES takes(id) ON DELETE CASCADE,
domain TEXT NOT NULL,
pack TEXT NOT NULL,
source TEXT,
confidence REAL NOT NULL DEFAULT 1.0 CHECK (confidence >= 0 AND confidence <= 1),
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (take_id, domain)
);
CREATE INDEX IF NOT EXISTS idx_take_domain_assignments_domain
ON take_domain_assignments (domain, take_id);
DO $$
DECLARE
has_bypass BOOLEAN;
BEGIN
SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user;
IF has_bypass THEN
ALTER TABLE take_domain_assignments ENABLE ROW LEVEL SECURITY;
END IF;
END $$;
`,
sqlFor: {
// PGLite: same DDL minus the RLS DO-block (no rolbypassrls).
pglite: `
CREATE TABLE IF NOT EXISTS take_domain_assignments (
take_id BIGINT NOT NULL REFERENCES takes(id) ON DELETE CASCADE,
domain TEXT NOT NULL,
pack TEXT NOT NULL,
source TEXT,
confidence REAL NOT NULL DEFAULT 1.0 CHECK (confidence >= 0 AND confidence <= 1),
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (take_id, domain)
);
CREATE INDEX IF NOT EXISTS idx_take_domain_assignments_domain
ON take_domain_assignments (domain, take_id);
`,
},
},
]; ];
export const LATEST_VERSION = MIGRATIONS.length > 0 export const LATEST_VERSION = MIGRATIONS.length > 0
+335
View File
@@ -0,0 +1,335 @@
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { MIGRATIONS, LATEST_VERSION } from '../src/core/migrate.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
// v0.41 R-MIG IRON-RULE regression: v93 take_domain_assignments table
//
// Pinned contracts:
// 1. Migration v93 exists in the MIGRATIONS array with the canonical name.
// 2. Table created cleanly via initSchema() on a fresh PGLite.
// 3. Composite PK (take_id, domain) prevents duplicate (take, domain) pairs.
// 4. FK to takes(id) with ON DELETE CASCADE — deleting a take cascades assignments.
// 5. CHECK constraint on confidence in [0, 1].
// 6. Index idx_take_domain_assignments_domain present for aggregator JOIN direction.
// 7. Pre-existing takes can co-exist with NULL assignment state (backward-compat:
// aggregator skips takes lacking domain assignment without erroring).
// 8. PGLite + Postgres parity: schema-shape grep on migrate.ts ensures both
// sql: and sqlFor.pglite include the same CREATE TABLE + index DDL.
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
describe('v0.41 R-MIG: take_domain_assignments migration v93', () => {
test('v93 exists in MIGRATIONS with canonical name', () => {
const v93 = MIGRATIONS.find(m => m.version === 93);
expect(v93).toBeDefined();
expect(v93?.name).toBe('take_domain_assignments');
});
test('LATEST_VERSION >= 93', () => {
expect(LATEST_VERSION).toBeGreaterThanOrEqual(93);
});
test('table is created and queryable after initSchema()', async () => {
const rows = await engine.executeRaw<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM take_domain_assignments`
);
expect(rows[0].count).toBe(0);
});
test('table has expected columns with expected types', async () => {
const cols = await engine.executeRaw<{ column_name: string; data_type: string; is_nullable: string }>(
`SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'take_domain_assignments'
ORDER BY ordinal_position`
);
const byName = Object.fromEntries(cols.map(c => [c.column_name, c]));
expect(Object.keys(byName).sort()).toEqual([
'assigned_at',
'confidence',
'domain',
'pack',
'source',
'take_id',
]);
expect(byName.take_id.is_nullable).toBe('NO');
expect(byName.domain.is_nullable).toBe('NO');
expect(byName.pack.is_nullable).toBe('NO');
expect(byName.source.is_nullable).toBe('YES'); // optional manual-assignment source
expect(byName.confidence.is_nullable).toBe('NO');
expect(byName.assigned_at.is_nullable).toBe('NO');
});
test('composite PK (take_id, domain) rejects duplicate (take, domain) pair', async () => {
// Seed a page + take to satisfy FK
await engine.putPage('test/seed-1', {
title: 'seed',
type: 'person',
compiled_truth: '',
frontmatter: {},
timeline: '',
});
const pageRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM pages WHERE slug = 'test/seed-1' LIMIT 1`
);
const pageId = pageRow[0].id;
await engine.executeRaw(
`INSERT INTO takes (page_id, row_num, claim, kind, holder) VALUES ($1, 1, 'seed claim', 'take', 'garry')`,
[pageId]
);
const takeRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM takes WHERE page_id = $1 LIMIT 1`,
[pageId]
);
const takeId = takeRow[0].id;
await engine.executeRaw(
`INSERT INTO take_domain_assignments (take_id, domain, pack) VALUES ($1, 'deal_success', 'gbrain-investor')`,
[takeId]
);
// Second insert with same (take_id, domain) violates PK
let threw = false;
try {
await engine.executeRaw(
`INSERT INTO take_domain_assignments (take_id, domain, pack) VALUES ($1, 'deal_success', 'gbrain-investor')`,
[takeId]
);
} catch {
threw = true;
}
expect(threw).toBe(true);
});
test('multi-domain assignment for same take is permitted', async () => {
await engine.putPage('test/seed-multi', {
title: 'seed',
type: 'person',
compiled_truth: '',
frontmatter: {},
timeline: '',
});
const pageRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM pages WHERE slug = 'test/seed-multi' LIMIT 1`
);
const pageId = pageRow[0].id;
await engine.executeRaw(
`INSERT INTO takes (page_id, row_num, claim, kind, holder) VALUES ($1, 1, 'multi-domain claim', 'take', 'garry')`,
[pageId]
);
const takeRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM takes WHERE page_id = $1 LIMIT 1`,
[pageId]
);
const takeId = takeRow[0].id;
// Same take, two domains — should both insert cleanly
await engine.executeRaw(
`INSERT INTO take_domain_assignments (take_id, domain, pack) VALUES ($1, 'deal_success', 'gbrain-investor')`,
[takeId]
);
await engine.executeRaw(
`INSERT INTO take_domain_assignments (take_id, domain, pack) VALUES ($1, 'market_call', 'gbrain-investor')`,
[takeId]
);
const rows = await engine.executeRaw<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM take_domain_assignments WHERE take_id = $1`,
[takeId]
);
expect(rows[0].count).toBe(2);
});
test('FK ON DELETE CASCADE removes assignments when take is deleted', async () => {
await engine.putPage('test/seed-cascade', {
title: 'seed',
type: 'person',
compiled_truth: '',
frontmatter: {},
timeline: '',
});
const pageRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM pages WHERE slug = 'test/seed-cascade' LIMIT 1`
);
const pageId = pageRow[0].id;
await engine.executeRaw(
`INSERT INTO takes (page_id, row_num, claim, kind, holder) VALUES ($1, 1, 'cascade claim', 'take', 'garry')`,
[pageId]
);
const takeRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM takes WHERE page_id = $1 LIMIT 1`,
[pageId]
);
const takeId = takeRow[0].id;
await engine.executeRaw(
`INSERT INTO take_domain_assignments (take_id, domain, pack) VALUES ($1, 'deal_success', 'gbrain-investor')`,
[takeId]
);
expect(
(await engine.executeRaw<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM take_domain_assignments WHERE take_id = $1`,
[takeId]
))[0].count
).toBe(1);
await engine.executeRaw(`DELETE FROM takes WHERE id = $1`, [takeId]);
expect(
(await engine.executeRaw<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM take_domain_assignments WHERE take_id = $1`,
[takeId]
))[0].count
).toBe(0);
});
test('CHECK constraint rejects confidence outside [0, 1]', async () => {
await engine.putPage('test/seed-check', {
title: 'seed',
type: 'person',
compiled_truth: '',
frontmatter: {},
timeline: '',
});
const pageRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM pages WHERE slug = 'test/seed-check' LIMIT 1`
);
const pageId = pageRow[0].id;
await engine.executeRaw(
`INSERT INTO takes (page_id, row_num, claim, kind, holder) VALUES ($1, 1, 'check claim', 'take', 'garry')`,
[pageId]
);
const takeRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM takes WHERE page_id = $1 LIMIT 1`,
[pageId]
);
const takeId = takeRow[0].id;
let threw = false;
try {
await engine.executeRaw(
`INSERT INTO take_domain_assignments (take_id, domain, pack, confidence) VALUES ($1, 'deal_success', 'gbrain-investor', 1.5)`,
[takeId]
);
} catch {
threw = true;
}
expect(threw).toBe(true);
threw = false;
try {
await engine.executeRaw(
`INSERT INTO take_domain_assignments (take_id, domain, pack, confidence) VALUES ($1, 'deal_success', 'gbrain-investor', -0.1)`,
[takeId]
);
} catch {
threw = true;
}
expect(threw).toBe(true);
});
test('idx_take_domain_assignments_domain index is created', async () => {
const rows = await engine.executeRaw<{ indexname: string }>(
`SELECT indexname FROM pg_indexes
WHERE tablename = 'take_domain_assignments'
AND indexname = 'idx_take_domain_assignments_domain'`
);
expect(rows.length).toBe(1);
});
test('aggregator JOIN direction returns assignments per domain', async () => {
// Seed 3 takes, assign 2 to deal_success and 1 to market_call
for (let i = 1; i <= 3; i++) {
await engine.putPage(`test/agg-${i}`, {
title: `seed ${i}`,
type: 'person',
compiled_truth: '',
frontmatter: {},
timeline: '',
});
const pageRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM pages WHERE slug = 'test/agg-${i}' LIMIT 1`
);
const pageId = pageRow[0].id;
await engine.executeRaw(
`INSERT INTO takes (page_id, row_num, claim, kind, holder) VALUES ($1, 1, $2, 'take', 'garry')`,
[pageId, `agg claim ${i}`]
);
const takeRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM takes WHERE page_id = $1 LIMIT 1`,
[pageId]
);
const takeId = takeRow[0].id;
const domain = i <= 2 ? 'deal_success' : 'market_call';
await engine.executeRaw(
`INSERT INTO take_domain_assignments (take_id, domain, pack) VALUES ($1, $2, 'gbrain-investor')`,
[takeId, domain]
);
}
const per = await engine.executeRaw<{ domain: string; n: number }>(
`SELECT a.domain AS domain, COUNT(*)::int AS n
FROM take_domain_assignments a
JOIN takes t ON t.id = a.take_id
WHERE t.holder = 'garry'
GROUP BY a.domain
ORDER BY a.domain`
);
expect(per).toEqual([
{ domain: 'deal_success', n: 2 },
{ domain: 'market_call', n: 1 },
]);
});
test('PGLite + Postgres parity — source DDL matches between sql and sqlFor.pglite', () => {
const v93 = MIGRATIONS.find(m => m.version === 93);
expect(v93).toBeDefined();
expect(v93?.sql).toContain('CREATE TABLE IF NOT EXISTS take_domain_assignments');
expect(v93?.sql).toContain('REFERENCES takes(id) ON DELETE CASCADE');
expect(v93?.sql).toContain('PRIMARY KEY (take_id, domain)');
expect(v93?.sql).toContain('idx_take_domain_assignments_domain');
expect(v93?.sqlFor?.pglite).toContain('CREATE TABLE IF NOT EXISTS take_domain_assignments');
expect(v93?.sqlFor?.pglite).toContain('REFERENCES takes(id) ON DELETE CASCADE');
expect(v93?.sqlFor?.pglite).toContain('PRIMARY KEY (take_id, domain)');
expect(v93?.sqlFor?.pglite).toContain('idx_take_domain_assignments_domain');
});
test('pre-existing takes without assignment co-exist (backward compat)', async () => {
await engine.putPage('test/legacy-take', {
title: 'legacy',
type: 'person',
compiled_truth: '',
frontmatter: {},
timeline: '',
});
const pageRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM pages WHERE slug = 'test/legacy-take' LIMIT 1`
);
const pageId = pageRow[0].id;
await engine.executeRaw(
`INSERT INTO takes (page_id, row_num, claim, kind, holder) VALUES ($1, 1, 'unassigned claim', 'take', 'garry')`,
[pageId]
);
// Aggregator JOIN: takes with no assignment should produce zero rows
// (aggregator skips them; calibration_profile widening must handle this gracefully)
const rows = await engine.executeRaw<{ count: number }>(
`SELECT COUNT(*)::int AS count
FROM takes t
LEFT JOIN take_domain_assignments a ON a.take_id = t.id
WHERE t.page_id = $1 AND a.domain IS NULL`,
[pageId]
);
expect(rows[0].count).toBe(1);
});
});