mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
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:
co-authored by
Claude Opus 4.7
parent
3c1cc8a4d6
commit
9e17d0076a
@@ -4257,6 +4257,71 @@ export const MIGRATIONS: Migration[] = [
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user