Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d18ce04959 | ||
|
|
7bc69286d1 | ||
|
|
4753e285eb |
+17
-34
@@ -197,41 +197,24 @@ conflict must amend one of them explicitly, never fork a third document
|
||||
- A second writable task store beside PostgreSQL (native-kanban-sot invariants).
|
||||
- Fully-designed federation in v1 (D3 — roadmap placeholder only).
|
||||
|
||||
### D15 — Tiered containerized deployment (2026-08-30, containerization lane)
|
||||
### 12. Decision registry
|
||||
|
||||
The stack ships a tiered deployment target, additive to the architecture
|
||||
gate (D8): (1) Standalone tier — docker compose is the canonical
|
||||
single-host deployment: postgres, valkey, openbao, gateway, appservice
|
||||
and the served webUI in one composition, with migrations, health checks,
|
||||
and a documented install/upgrade path; the registry (CI-published
|
||||
images) is the only deployment source. (2) Enterprise tier — Kubernetes
|
||||
manifests for the same service set, phase-gated on the standalone tier
|
||||
holding its acceptance bar. The v1 acceptance bar for the standalone
|
||||
tier: compose-up healthy; webUI hosts agent chat; an in-stack agent can
|
||||
open a PR to this repo; CI validates it; the running deployment adopts
|
||||
the merged change (pull + restart). Federation (D3 clause) remains
|
||||
deferred and unforeclosed. Implementation plan:
|
||||
docs/plans/2026-08-30_containerization.md.
|
||||
|
||||
## 12. Decision registry
|
||||
|
||||
| ID | Decision (short form) |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
|
||||
| D1 | Open-source, AI-first, self-hosted platform for agentic management + life OS |
|
||||
| D2 | Hierarchy company→estate→project→workspace→kanban; bubble-up; granular RBAC |
|
||||
| D3 | Standalone vs Enterprise; one-way conversion; per-user brains + Vault required in Enterprise; federation deferred |
|
||||
| D4 | Re-runnable, extensible, per-mode onboarding wizards |
|
||||
| D5 | North star = this rewrite of docs/PRD.md; stack docs/ = product SSOT |
|
||||
| D6 | Only product-relevant material migrates from brains; operational records stay and link |
|
||||
| D7 | Spec-inventory sweep launched immediately (executed; INPUTS baseline frozen by operator ruling T2, 2026-08-25) |
|
||||
| D8 | webUI sits over official framework tooling; CLI primary |
|
||||
| D9 | Not a hosted business; company = organizational separation for one operator |
|
||||
| D10 | better-auth is the account system of record; external IdPs via OIDC |
|
||||
| D11 | Small v1 slice; ALL phases on the documented roadmap from day one |
|
||||
| D12 | HARD RULE: webUI never bypasses tooling; missing tool ⇒ build the tool first |
|
||||
| D13 | workspace_id stays the hard isolation unit; hierarchy is parent structure above; kanban SOT amended, not rewritten |
|
||||
| D14 | Sensitive profile data in the user's own brain only; postgres holds structure/consent/pointers |
|
||||
| D15 | Tiered containerized deployment: compose standalone tier (five-point v1 bar) + phase-gated k8s enterprise tier; registry-only image source | 2026-08-30 containerization lane; plan docs/plans/2026-08-30_containerization.md |
|
||||
| ID | Decision (short form) |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| D1 | Open-source, AI-first, self-hosted platform for agentic management + life OS |
|
||||
| D2 | Hierarchy company→estate→project→workspace→kanban; bubble-up; granular RBAC |
|
||||
| D3 | Standalone vs Enterprise; one-way conversion; per-user brains + Vault required in Enterprise; federation deferred |
|
||||
| D4 | Re-runnable, extensible, per-mode onboarding wizards |
|
||||
| D5 | North star = this rewrite of docs/PRD.md; stack docs/ = product SSOT |
|
||||
| D6 | Only product-relevant material migrates from brains; operational records stay and link |
|
||||
| D7 | Spec-inventory sweep launched immediately (executed; INPUTS baseline frozen by operator ruling T2, 2026-08-25) |
|
||||
| D8 | webUI sits over official framework tooling; CLI primary |
|
||||
| D9 | Not a hosted business; company = organizational separation for one operator |
|
||||
| D10 | better-auth is the account system of record; external IdPs via OIDC |
|
||||
| D11 | Small v1 slice; ALL phases on the documented roadmap from day one |
|
||||
| D12 | HARD RULE: webUI never bypasses tooling; missing tool ⇒ build the tool first |
|
||||
| D13 | workspace_id stays the hard isolation unit; hierarchy is parent structure above; kanban SOT amended, not rewritten |
|
||||
| D14 | Sensitive profile data in the user's own brain only; postgres holds structure/consent/pointers |
|
||||
|
||||
The full decision texts are recorded in the operator decision log (USC estate
|
||||
brain, webui-audit lane, `GRILL.md`).
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# Plan — Stack Containerization (tiered deployment)
|
||||
|
||||
Status: DRAFT for review. Charter: fleet/lanes/stack-containerization
|
||||
(brain) NORTH-STAR.md; PRD amendment in the same PR adds D15.
|
||||
Supersedes nothing; sequences the absorbed M4 remainder per its lane.
|
||||
|
||||
## Measured baseline (origin/next @ 143ba0f5, 2026-08-30)
|
||||
|
||||
- `docker-compose.yml`: dev infrastructure only — postgres (pgvector),
|
||||
valkey, otel-collector, jaeger. No application services.
|
||||
- `docker-compose.federated.yml`: standalone overlay for the FEDERATED
|
||||
storage tier (own postgres/valkey; port-conflicts the base stack by
|
||||
design). Not an app deployment.
|
||||
- `docker/gateway.Dockerfile`, `docker/appservice.Dockerfile`:
|
||||
multi-stage production builds (node:22-alpine) EXIST; the gateway image
|
||||
includes the web SPA bundle (#1444).
|
||||
- CI (`publish.yml`) builds and publishes these images (next-channel
|
||||
prereleases + main stable), and runs `verify:release` fail-closed.
|
||||
- Gap: no stack-level composition wires gateway+appservice+data plane
|
||||
into one deployable unit; no blessed install/upgrade path; no
|
||||
in-container agent-runtime story for the dogfood loop.
|
||||
|
||||
## Target (PRD D15 amendment)
|
||||
|
||||
Tiered deployment, additive to the existing architecture:
|
||||
|
||||
1. **Standalone tier (v1 bar)**: `docker compose up` on one host brings
|
||||
postgres, valkey, openbao, gateway, appservice (and the webUI the
|
||||
gateway serves) to healthy; migrations apply; the webUI hosts agent
|
||||
chat; an in-stack agent can read this repo and open a PR; CI
|
||||
validates; the deployment adopts merged images (pull + restart).
|
||||
2. **Enterprise tier (post-v1)**: Kubernetes manifests (or Helm) for the
|
||||
same service set, phase-gated on the standalone bar holding.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase A — blessed standalone compose
|
||||
|
||||
- A1 Compose service definitions for gateway + appservice joining the
|
||||
existing infra compose (profiles: `dev` keeps today's behavior;
|
||||
`stack` adds the app tier), with health checks and dependency order.
|
||||
- A2 Migrations on boot (or an explicit migrate step) with idempotency
|
||||
and version pinning; init-db.sql folded into pg-init.
|
||||
- A3 Openbao in the compose set (secret plumbing for the app tier).
|
||||
- A4 `.env.example` + `mosaic.config.json` defaults documented for the
|
||||
standalone mode; mode recorded per the mode-conversion contract.
|
||||
- A5 Smoke: `docker compose --profile stack up` green on a scratch host;
|
||||
webUI served; agent chat reachable; failures catalogued and fixed.
|
||||
- Acceptance: the five-point NORTH-STAR bar measured live.
|
||||
|
||||
### Phase B — component completion
|
||||
|
||||
- Interface assumption (velma verdict A1, P5-RM-005/006): in-stack
|
||||
dogfood agents inherit SEAT-GRADE identity — credential-slot
|
||||
isolation, wrapper-first enforcement, no privileged coordination
|
||||
identity, evidence by references that resolve outside the container
|
||||
lifetime.
|
||||
- Decompose JIT from A5's catalogue. Known candidates: agent runtime
|
||||
bits (brain/tool access paths in-container), repo credentials for the
|
||||
dogfood agent, watch/comms surfaces inside the deployment.
|
||||
|
||||
### Phase C — CI/CD parity
|
||||
|
||||
- Publish pipeline is the only image source (already true); add the
|
||||
deployment-side pull/upgrade path (compose pull + migrate + restart =
|
||||
next iteration); document the promotion flow next -> registry ->
|
||||
deployment.
|
||||
|
||||
### Phase D — coordinator integration (GATED)
|
||||
|
||||
- Gate (velma verdict C2): blocked until the checkpoint-and-lease child
|
||||
of the guides-proposed control-plane refactor — core + WU-P1-CHECKPOINT
|
||||
(schema, freshness, incarnation, clean-replacement resume; D57-D60
|
||||
lineage) — carries an independent target-bound PASS. Wiring restarts
|
||||
against the core alone re-creates the stale-incarnation failure class
|
||||
D57-D60 closed. Transitive: inherits the T108 gates (P0 exit + Jason
|
||||
P1 authorization).
|
||||
- Scope (velma verdict C1): lifecycle actions (start/stop/restart/
|
||||
health/recovery) executed by the SHIPPED coord client over the one
|
||||
typed coordination contract (request id, actor identity, epoch,
|
||||
revision, lease, correlation; typed stale rejection; worker role
|
||||
boundary). No second coordination interface gets designed here —
|
||||
containerization consumes the coordination contract, never defines it.
|
||||
|
||||
### Phase E — enterprise tier
|
||||
|
||||
- k8s manifests/Helm for the same set; phase-gated on Phase A holding.
|
||||
|
||||
### Absorbed M4 remainder
|
||||
|
||||
- M4-3 pivot: KBN-101 foundation first (per ruling R6), then expand DDL.
|
||||
- M4-5: lands inside Phase B/C where natural.
|
||||
- M4-6 (composes M4-1+M4-4): last, as designed.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- No Kubernetes in v1; no multi-host federation; no replacement of the
|
||||
fleet's brain-based seats (the stack is an additional operator
|
||||
surface); no on-host image builds for deployment (registry only).
|
||||
@@ -1,47 +0,0 @@
|
||||
CREATE TYPE "public"."agent_outbox_status" AS ENUM('pending', 'processing', 'delivered');--> statement-breakpoint
|
||||
CREATE TABLE "agent_audit_events" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"seq" bigint GENERATED ALWAYS AS IDENTITY (sequence name "agent_audit_events_seq_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||
"event_type" text NOT NULL,
|
||||
"actor_id" text NOT NULL,
|
||||
"agent_id" uuid NOT NULL,
|
||||
"correlation_id" text NOT NULL,
|
||||
"causation_id" uuid,
|
||||
"payload" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "agent_audit_events_type_check" CHECK (event_type IN ('agent.enrolled', 'agent.enrollment.replayed'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "agent_idempotency_fence" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"operation" text NOT NULL,
|
||||
"idempotency_key" text NOT NULL,
|
||||
"actor_id" text NOT NULL,
|
||||
"authorization_scope" text NOT NULL,
|
||||
"payload_digest" text NOT NULL,
|
||||
"replay_mode" text DEFAULT 'actor-bound' NOT NULL,
|
||||
"outcome_agent_id" uuid NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "agent_idempotency_fence_replay_mode_check" CHECK (replay_mode IN ('actor-bound', 'shared'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "agent_outbox" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"event_id" uuid NOT NULL,
|
||||
"correlation_id" text NOT NULL,
|
||||
"status" "agent_outbox_status" DEFAULT 'pending' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"delivered_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "agents" ADD COLUMN "harness" text;--> statement-breakpoint
|
||||
ALTER TABLE "agents" ADD COLUMN "enrolled_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "agent_audit_events" ADD CONSTRAINT "agent_audit_events_causation_id_agent_audit_events_id_fk" FOREIGN KEY ("causation_id") REFERENCES "public"."agent_audit_events"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "agent_outbox" ADD CONSTRAINT "agent_outbox_event_id_agent_audit_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."agent_audit_events"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "agent_audit_events_seq_idx" ON "agent_audit_events" USING btree ("seq");--> statement-breakpoint
|
||||
CREATE INDEX "agent_audit_events_agent_seq_idx" ON "agent_audit_events" USING btree ("agent_id","seq");--> statement-breakpoint
|
||||
CREATE INDEX "agent_audit_events_correlation_idx" ON "agent_audit_events" USING btree ("correlation_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "agent_idempotency_fence_operation_key_idx" ON "agent_idempotency_fence" USING btree ("operation","idempotency_key");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "agent_outbox_event_idx" ON "agent_outbox" USING btree ("event_id");--> statement-breakpoint
|
||||
CREATE INDEX "agent_outbox_status_created_idx" ON "agent_outbox" USING btree ("status","created_at");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -148,13 +148,6 @@
|
||||
"when": 1787963521142,
|
||||
"tag": "0020_special_betty_brant",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1788053011351,
|
||||
"tag": "0021_agent_enrollment",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
@@ -302,11 +302,6 @@ export const agents = pgTable(
|
||||
skills: jsonb('skills').$type<string[]>(),
|
||||
isSystem: boolean('is_system').notNull().default(false),
|
||||
config: jsonb('config'),
|
||||
// Enrollment (M4-4, docs/plans/2026-08-29-agent-enrollment-command-design.md §4).
|
||||
// NULL on both marks a legacy (non-enrolled) row; no backfill — enrollment
|
||||
// is a fact the rank-4 command creates, not one to invent for existing rows.
|
||||
harness: text('harness'),
|
||||
enrolledAt: timestamp('enrolled_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
@@ -1284,109 +1279,3 @@ export const hierarchyOutbox = pgTable(
|
||||
index('hierarchy_outbox_status_created_idx').on(t.status, t.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent enrollment (M4-4) — rank-4 command family audit/outbox/fence stores.
|
||||
// Design: docs/plans/2026-08-29-agent-enrollment-command-design.md §4.
|
||||
// Pattern reuse from the hierarchy audit/outbox pair, separate store. Audit
|
||||
// rows reference the agent by snapshot id, deliberately with NO FK, so audit
|
||||
// history survives agent deletion through the legacy CRUD DELETE path.
|
||||
// Idempotency for this family lives in agent_idempotency_fence (contract 3
|
||||
// §4.3 envelope, ratified into contract 5 §4 via contract 3 §7 item 4) — the
|
||||
// audit and outbox tables carry no idempotency key of their own.
|
||||
|
||||
export const AGENT_AUDIT_EVENT_TYPES = [
|
||||
// Semantic mutation event of agent.enroll.
|
||||
'agent.enrolled',
|
||||
// Non-mutation access class: a passing idempotent replay appends this and
|
||||
// nothing else (accessing principal, current correlation id, fence-row
|
||||
// reference in the payload).
|
||||
'agent.enrollment.replayed',
|
||||
] as const;
|
||||
|
||||
export const agentAuditEvents = pgTable(
|
||||
'agent_audit_events',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
// Global append order; per-agent ordering is a filter on agent_id ordered
|
||||
// by seq.
|
||||
seq: bigint('seq', { mode: 'number' }).notNull().generatedAlwaysAsIdentity(),
|
||||
eventType: text('event_type').notNull(),
|
||||
// No FK: audit events outlive every principal and every target.
|
||||
actorId: text('actor_id').notNull(),
|
||||
agentId: uuid('agent_id').notNull(),
|
||||
correlationId: text('correlation_id').notNull(),
|
||||
causationId: uuid('causation_id').references((): AnyPgColumn => agentAuditEvents.id, {
|
||||
onDelete: 'restrict',
|
||||
}),
|
||||
// Immutable snapshot at event time; never carries credential material
|
||||
// (§3.1 rule 1: actor, agent id, harness, provider, name, credentialMode).
|
||||
payload: jsonb('payload').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('agent_audit_events_seq_idx').on(t.seq),
|
||||
index('agent_audit_events_agent_seq_idx').on(t.agentId, t.seq),
|
||||
index('agent_audit_events_correlation_idx').on(t.correlationId),
|
||||
check(
|
||||
'agent_audit_events_type_check',
|
||||
sql`event_type IN ('agent.enrolled', 'agent.enrollment.replayed')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const agentOutboxStatusEnum = pgEnum('agent_outbox_status', [
|
||||
'pending',
|
||||
'processing',
|
||||
'delivered',
|
||||
]);
|
||||
|
||||
export const agentOutbox = pgTable(
|
||||
'agent_outbox',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
// FK into the append-only events table: never dangles, RESTRICT is safe.
|
||||
eventId: uuid('event_id')
|
||||
.notNull()
|
||||
.references(() => agentAuditEvents.id, { onDelete: 'restrict' }),
|
||||
correlationId: text('correlation_id').notNull(),
|
||||
status: agentOutboxStatusEnum('status').notNull().default('pending'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
deliveredAt: timestamp('delivered_at', { withTimezone: true }),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('agent_outbox_event_idx').on(t.eventId),
|
||||
index('agent_outbox_status_created_idx').on(t.status, t.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
// Contract 3 §4.3 fence shape. Uniqueness is (operation, key); the recorded
|
||||
// replay mode is always 'actor-bound' for this family (`shared` is seed-only
|
||||
// and refused at validation — design §3.1), but the column keeps the ratified
|
||||
// envelope shape and serves the mode-mismatch collision check. The payload
|
||||
// digest input EXCLUDES the credential value (design §3.1 rule 5).
|
||||
export const agentIdempotencyFence = pgTable(
|
||||
'agent_idempotency_fence',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
operation: text('operation').notNull(),
|
||||
idempotencyKey: text('idempotency_key').notNull(),
|
||||
// No FK: fence rows outlive principals, mirroring the audit tables.
|
||||
actorId: text('actor_id').notNull(),
|
||||
authorizationScope: text('authorization_scope').notNull(),
|
||||
payloadDigest: text('payload_digest').notNull(),
|
||||
replayMode: text('replay_mode').notNull().default('actor-bound'),
|
||||
// Committed-outcome reference (the enrolled agent's id). Snapshot value,
|
||||
// no FK: the fence must keep answering replays after a legacy DELETE.
|
||||
outcomeAgentId: uuid('outcome_agent_id').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('agent_idempotency_fence_operation_key_idx').on(t.operation, t.idempotencyKey),
|
||||
check(
|
||||
'agent_idempotency_fence_replay_mode_check',
|
||||
sql`replay_mode IN ('actor-bound', 'shared')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user