413 lines
15 KiB
TypeScript
413 lines
15 KiB
TypeScript
/**
|
|
* Agent enrollment schema witnesses — M4-4a, the schema-level half of the
|
|
* witness list in docs/plans/2026-08-29-agent-enrollment-command-design.md §5.
|
|
*
|
|
* Witnesses the guarantees migration 0021's tables themselves carry: the
|
|
* event-type CHECK, monotonic per-agent append order (`seq`), deletion-safe
|
|
* linkage (no foreign key from the events or fence tables into `agents` —
|
|
* rows survive a legacy CRUD DELETE of the agent), the causation self-FK,
|
|
* the outbox's FK/uniqueness/status shape, the fence's UNIQUE
|
|
* (operation, key) and replay-mode CHECK, and the nullable enrollment
|
|
* columns on `agents` (legacy rows insert without them). The command-level
|
|
* witnesses (never-echo, same-tx atomicity, replay semantics, correlation,
|
|
* CLI parity, fail-closed) belong to the M4-4b implementation slice.
|
|
*
|
|
* Two legs run the same witness body:
|
|
* - PGlite (WASM Postgres): always runs.
|
|
* - Real PostgreSQL: runs when DATABASE_URL is set — the binding witness;
|
|
* CI migrates ci-postgres before `pnpm test`.
|
|
*/
|
|
import { randomUUID } from 'node:crypto';
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { sql } from 'drizzle-orm';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { createDb } from './client.js';
|
|
import { createPgliteDb } from './client-pglite.js';
|
|
import { runPgliteMigrations } from './migrate.js';
|
|
import { agentAuditEvents, agentIdempotencyFence, agentOutbox, agents } from './schema.js';
|
|
|
|
type AnyDb = {
|
|
db: {
|
|
insert: (t: unknown) => { values: (v: unknown) => Promise<unknown> };
|
|
execute: (q: unknown) => Promise<{ rows?: unknown[] } | unknown[]>;
|
|
};
|
|
close: () => Promise<void>;
|
|
};
|
|
|
|
/** Match a constraint failure anywhere along drizzle's cause chain. */
|
|
async function expectViolation(p: Promise<unknown>, re: RegExp, label = ''): Promise<void> {
|
|
let err: unknown;
|
|
try {
|
|
await p;
|
|
} catch (e) {
|
|
err = e;
|
|
}
|
|
expect(err, label || 'expected the statement to be refused').toBeDefined();
|
|
const messages: string[] = [];
|
|
let cur: unknown = err;
|
|
while (cur instanceof Error) {
|
|
messages.push(cur.message);
|
|
cur = (cur as { cause?: unknown }).cause;
|
|
}
|
|
expect(messages.join(' | '), label).toMatch(re);
|
|
}
|
|
|
|
function rows(res: { rows?: unknown[] } | unknown[]): Record<string, unknown>[] {
|
|
return (Array.isArray(res) ? res : (res.rows ?? [])) as Record<string, unknown>[];
|
|
}
|
|
|
|
/** Unique per-run prefix so real-PG runs never collide and clean up safely. */
|
|
const T = `agent-e-${randomUUID().slice(0, 8)}`;
|
|
|
|
type EventInsert = typeof agentAuditEvents.$inferInsert;
|
|
|
|
function eventRow(overrides: Partial<EventInsert> = {}): EventInsert {
|
|
return {
|
|
eventType: 'agent.enrolled',
|
|
actorId: `${T}-actor`,
|
|
agentId: randomUUID(),
|
|
correlationId: `${T}-corr-${randomUUID()}`,
|
|
payload: {
|
|
harness: 'claude-code',
|
|
provider: 'anthropic',
|
|
name: 'x',
|
|
credentialMode: 'reference',
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
type FenceInsert = typeof agentIdempotencyFence.$inferInsert;
|
|
|
|
function fenceRow(overrides: Partial<FenceInsert> = {}): FenceInsert {
|
|
return {
|
|
operation: 'agent.enroll',
|
|
idempotencyKey: `${T}-${randomUUID()}`,
|
|
actorId: `${T}-actor`,
|
|
authorizationScope: 'platform-user',
|
|
payloadDigest: `${T}-digest`,
|
|
outcomeAgentId: randomUUID(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function witnessSuite(getHandle: () => AnyDb): void {
|
|
const db = () => getHandle().db as unknown as ReturnType<typeof createDb>['db'];
|
|
|
|
afterAll(async () => {
|
|
const d = db();
|
|
await d.execute(sql`DELETE FROM agent_outbox WHERE correlation_id LIKE ${T + '%'}`);
|
|
// Caused events first: the causation self-FK is RESTRICT.
|
|
await d.execute(
|
|
sql`DELETE FROM agent_audit_events WHERE correlation_id LIKE ${T + '%'} AND causation_id IS NOT NULL`,
|
|
);
|
|
await d.execute(sql`DELETE FROM agent_audit_events WHERE correlation_id LIKE ${T + '%'}`);
|
|
await d.execute(sql`DELETE FROM agent_idempotency_fence WHERE actor_id LIKE ${T + '%'}`);
|
|
await d.execute(sql`DELETE FROM agents WHERE name LIKE ${T + '%'}`);
|
|
});
|
|
|
|
// ── agents: nullable enrollment columns (no backfill semantics) ────────────
|
|
|
|
it('legacy agent rows insert without enrollment columns; enrolled rows carry both', async () => {
|
|
const legacyId = randomUUID();
|
|
await db()
|
|
.insert(agents)
|
|
.values({
|
|
id: legacyId,
|
|
name: `${T}-legacy`,
|
|
provider: 'anthropic',
|
|
model: 'claude-fable-5',
|
|
});
|
|
const legacy = rows(
|
|
await db().execute(sql`SELECT harness, enrolled_at FROM agents WHERE id = ${legacyId}`),
|
|
)[0]!;
|
|
expect(legacy['harness']).toBeNull();
|
|
expect(legacy['enrolled_at']).toBeNull();
|
|
|
|
const enrolledId = randomUUID();
|
|
await db()
|
|
.insert(agents)
|
|
.values({
|
|
id: enrolledId,
|
|
name: `${T}-enrolled`,
|
|
provider: 'anthropic',
|
|
model: 'claude-fable-5',
|
|
harness: 'claude-code',
|
|
enrolledAt: new Date(),
|
|
});
|
|
const enrolled = rows(
|
|
await db().execute(sql`SELECT harness, enrolled_at FROM agents WHERE id = ${enrolledId}`),
|
|
)[0]!;
|
|
expect(enrolled['harness']).toBe('claude-code');
|
|
expect(enrolled['enrolled_at']).not.toBeNull();
|
|
});
|
|
|
|
// ── agent_audit_events: CHECK, ordering, deletion-safe linkage ─────────────
|
|
|
|
it('accepts both declared event types and refuses an undeclared one', async () => {
|
|
await db()
|
|
.insert(agentAuditEvents)
|
|
.values(eventRow({ eventType: 'agent.enrolled' }));
|
|
await db()
|
|
.insert(agentAuditEvents)
|
|
.values(eventRow({ eventType: 'agent.enrollment.replayed' }));
|
|
await expectViolation(
|
|
db()
|
|
.insert(agentAuditEvents)
|
|
.values(eventRow({ eventType: 'agent.deleted' })),
|
|
/type_check|violates check/i,
|
|
'undeclared event type must be refused',
|
|
);
|
|
});
|
|
|
|
it('assigns strictly increasing seq in insert order for one agent', async () => {
|
|
const agentId = randomUUID();
|
|
const c1 = `${T}-seq-1-${randomUUID()}`;
|
|
const c2 = `${T}-seq-2-${randomUUID()}`;
|
|
await db()
|
|
.insert(agentAuditEvents)
|
|
.values(eventRow({ agentId, correlationId: c1 }));
|
|
await db()
|
|
.insert(agentAuditEvents)
|
|
.values(eventRow({ agentId, eventType: 'agent.enrollment.replayed', correlationId: c2 }));
|
|
const res = rows(
|
|
await db().execute(
|
|
sql`SELECT correlation_id, seq FROM agent_audit_events WHERE agent_id = ${agentId} ORDER BY seq ASC`,
|
|
),
|
|
);
|
|
expect(res.map((r) => r['correlation_id'])).toEqual([c1, c2]);
|
|
expect(Number(res[1]!['seq'])).toBeGreaterThan(Number(res[0]!['seq']));
|
|
});
|
|
|
|
it('has no foreign key into agents, and events survive agent deletion', async () => {
|
|
const fks = rows(
|
|
await db().execute(sql`
|
|
SELECT ccu.table_name AS referenced_table
|
|
FROM information_schema.table_constraints tc
|
|
JOIN information_schema.constraint_column_usage ccu
|
|
ON ccu.constraint_name = tc.constraint_name AND ccu.constraint_schema = tc.constraint_schema
|
|
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = 'agent_audit_events'
|
|
`),
|
|
);
|
|
// The causation self-FK is the ONLY foreign key on the events table.
|
|
expect([...new Set(fks.map((r) => r['referenced_table']))]).toEqual(['agent_audit_events']);
|
|
|
|
const agentId = randomUUID();
|
|
await db()
|
|
.insert(agents)
|
|
.values({ id: agentId, name: `${T}-doomed`, provider: 'anthropic', model: 'claude-fable-5' });
|
|
const corr = `${T}-survive-${randomUUID()}`;
|
|
await db()
|
|
.insert(agentAuditEvents)
|
|
.values(eventRow({ agentId, correlationId: corr }));
|
|
await db().execute(sql`DELETE FROM agents WHERE id = ${agentId}`);
|
|
const after = rows(
|
|
await db().execute(
|
|
sql`SELECT agent_id FROM agent_audit_events WHERE correlation_id = ${corr}`,
|
|
),
|
|
);
|
|
expect(after).toHaveLength(1);
|
|
expect(after[0]!['agent_id']).toBe(agentId);
|
|
});
|
|
|
|
it('enforces the causation self-FK and RESTRICTs deleting a cause', async () => {
|
|
await expectViolation(
|
|
db()
|
|
.insert(agentAuditEvents)
|
|
.values(eventRow({ causationId: randomUUID() })),
|
|
/foreign key/i,
|
|
'causation must reference an existing event',
|
|
);
|
|
const causeCorr = `${T}-cause-${randomUUID()}`;
|
|
await db()
|
|
.insert(agentAuditEvents)
|
|
.values(eventRow({ correlationId: causeCorr }));
|
|
const cause = rows(
|
|
await db().execute(
|
|
sql`SELECT id FROM agent_audit_events WHERE correlation_id = ${causeCorr}`,
|
|
),
|
|
)[0]!;
|
|
await db()
|
|
.insert(agentAuditEvents)
|
|
.values(
|
|
eventRow({
|
|
eventType: 'agent.enrollment.replayed',
|
|
causationId: cause['id'] as string,
|
|
}),
|
|
);
|
|
await expectViolation(
|
|
db().execute(sql`DELETE FROM agent_audit_events WHERE id = ${cause['id'] as string}`),
|
|
/foreign key/i,
|
|
'a cause with dependent events must not be deletable',
|
|
);
|
|
});
|
|
|
|
// ── agent_outbox shape ─────────────────────────────────────────────────────
|
|
|
|
it('outbox rows require an existing event, one outbox row per event, closed status enum', async () => {
|
|
await expectViolation(
|
|
db()
|
|
.insert(agentOutbox)
|
|
.values({ eventId: randomUUID(), correlationId: `${T}-corr` }),
|
|
/foreign key/i,
|
|
'outbox must reference an existing event',
|
|
);
|
|
const corr = `${T}-ob-${randomUUID()}`;
|
|
await db()
|
|
.insert(agentAuditEvents)
|
|
.values(eventRow({ correlationId: corr }));
|
|
const event = rows(
|
|
await db().execute(sql`SELECT id FROM agent_audit_events WHERE correlation_id = ${corr}`),
|
|
)[0]!;
|
|
const eventId = event['id'] as string;
|
|
await db().insert(agentOutbox).values({ eventId, correlationId: corr });
|
|
await expectViolation(
|
|
db()
|
|
.insert(agentOutbox)
|
|
.values({ eventId, correlationId: `${T}-ob2` }),
|
|
/duplicate key|unique/i,
|
|
'one outbox record per event',
|
|
);
|
|
await expectViolation(
|
|
db().execute(
|
|
sql`INSERT INTO agent_outbox (event_id, correlation_id, status)
|
|
VALUES (${eventId}, ${`${T}-ob3`}, 'failed')`,
|
|
),
|
|
/invalid input value for enum|22P02/i,
|
|
'status outside pending/processing/delivered must be refused',
|
|
);
|
|
});
|
|
|
|
it('outbox FK RESTRICTs event deletion while the outbox row exists', async () => {
|
|
const corr = `${T}-obr-${randomUUID()}`;
|
|
await db()
|
|
.insert(agentAuditEvents)
|
|
.values(eventRow({ correlationId: corr }));
|
|
const event = rows(
|
|
await db().execute(sql`SELECT id FROM agent_audit_events WHERE correlation_id = ${corr}`),
|
|
)[0]!;
|
|
await db()
|
|
.insert(agentOutbox)
|
|
.values({ eventId: event['id'] as string, correlationId: corr });
|
|
await expectViolation(
|
|
db().execute(sql`DELETE FROM agent_audit_events WHERE id = ${event['id'] as string}`),
|
|
/foreign key/i,
|
|
);
|
|
});
|
|
|
|
// ── agent_idempotency_fence: (operation, key) uniqueness, mode CHECK ───────
|
|
|
|
it('refuses a duplicate (operation, key) pair but allows the same key under another operation', async () => {
|
|
const key = `${T}-fence-${randomUUID()}`;
|
|
await db()
|
|
.insert(agentIdempotencyFence)
|
|
.values(fenceRow({ idempotencyKey: key }));
|
|
await expectViolation(
|
|
db()
|
|
.insert(agentIdempotencyFence)
|
|
.values(fenceRow({ idempotencyKey: key })),
|
|
/duplicate key|unique/i,
|
|
'fence uniqueness is (operation, key)',
|
|
);
|
|
// Same key, different operation identifier: a distinct fence.
|
|
await db()
|
|
.insert(agentIdempotencyFence)
|
|
.values(fenceRow({ idempotencyKey: key, operation: 'agent.other' }));
|
|
});
|
|
|
|
it('defaults replay mode to actor-bound and refuses an undeclared mode', async () => {
|
|
const key = `${T}-mode-${randomUUID()}`;
|
|
await db()
|
|
.insert(agentIdempotencyFence)
|
|
.values(fenceRow({ idempotencyKey: key }));
|
|
const row = rows(
|
|
await db().execute(
|
|
sql`SELECT replay_mode FROM agent_idempotency_fence WHERE idempotency_key = ${key}`,
|
|
),
|
|
)[0]!;
|
|
expect(row['replay_mode']).toBe('actor-bound');
|
|
await expectViolation(
|
|
db()
|
|
.insert(agentIdempotencyFence)
|
|
.values(fenceRow({ replayMode: 'unbound' as 'actor-bound' })),
|
|
/replay_mode_check|violates check/i,
|
|
'a mode outside actor-bound/shared must be refused',
|
|
);
|
|
});
|
|
|
|
it('fence has no foreign key at all, and rows survive agent deletion', async () => {
|
|
const fks = rows(
|
|
await db().execute(sql`
|
|
SELECT ccu.table_name AS referenced_table
|
|
FROM information_schema.table_constraints tc
|
|
JOIN information_schema.constraint_column_usage ccu
|
|
ON ccu.constraint_name = tc.constraint_name AND ccu.constraint_schema = tc.constraint_schema
|
|
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = 'agent_idempotency_fence'
|
|
`),
|
|
);
|
|
expect(fks).toHaveLength(0);
|
|
|
|
const agentId = randomUUID();
|
|
await db()
|
|
.insert(agents)
|
|
.values({
|
|
id: agentId,
|
|
name: `${T}-fdoomed`,
|
|
provider: 'anthropic',
|
|
model: 'claude-fable-5',
|
|
});
|
|
const key = `${T}-fsurvive-${randomUUID()}`;
|
|
await db()
|
|
.insert(agentIdempotencyFence)
|
|
.values(fenceRow({ idempotencyKey: key, outcomeAgentId: agentId }));
|
|
await db().execute(sql`DELETE FROM agents WHERE id = ${agentId}`);
|
|
const after = rows(
|
|
await db().execute(
|
|
sql`SELECT outcome_agent_id FROM agent_idempotency_fence WHERE idempotency_key = ${key}`,
|
|
),
|
|
);
|
|
expect(after).toHaveLength(1);
|
|
expect(after[0]!['outcome_agent_id']).toBe(agentId);
|
|
});
|
|
}
|
|
|
|
// ── Leg 1: PGlite (always runs — local witness signal) ───────────────────────
|
|
|
|
describe('agent enrollment schema witnesses — PGlite', () => {
|
|
let dir: string;
|
|
let handle: ReturnType<typeof createPgliteDb>;
|
|
|
|
beforeAll(async () => {
|
|
dir = mkdtempSync(join(tmpdir(), 'agent-enroll-witness-'));
|
|
handle = createPgliteDb(dir);
|
|
await runPgliteMigrations(handle);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await handle.close();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
witnessSuite(() => handle as unknown as AnyDb);
|
|
});
|
|
|
|
// ── Leg 2: real PostgreSQL (binding witness, ci-postgres in CI) ──────────────
|
|
|
|
const hasPostgres = Boolean(process.env['DATABASE_URL']);
|
|
|
|
describe.skipIf(!hasPostgres)('agent enrollment schema witnesses — real PostgreSQL', () => {
|
|
let handle: ReturnType<typeof createDb>;
|
|
|
|
beforeAll(() => {
|
|
handle = createDb(process.env['DATABASE_URL']!);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await handle.close();
|
|
});
|
|
|
|
witnessSuite(() => handle as unknown as AnyDb);
|
|
});
|