Skip to content

Commit 06efc55

Browse files
committed
feat: define runtime event taxonomy
1 parent 1cbdbb5 commit 06efc55

3 files changed

Lines changed: 172 additions & 0 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ The runtime state schema is separately versioned as `RuntimeState.schemaVersion:
158158

159159
The domain boundary is explicit: `Decomposer` creates the concrete graph, `Scheduler` computes ready tasks, `WorkerRuntime` dispatches and heartbeats work, `Verifier` validates evidence refs, and `GateEngine` decides transitions.
160160

161+
Runtime events are append-only `schemaVersion: 1` envelopes with monotonic `seq`, stable `eventId`, `idempotencyKey`, `type`, `category`, timestamp, run id, and a typed payload. The event taxonomy covers run lifecycle, objective/policy decisions, graph readiness, claim/lease transitions, worker readiness/heartbeat/retention/safe-close, evidence, evaluation, repair, integration, and reward records. Replay applies events by ascending sequence into derived state; duplicate event ids must be byte-equivalent, stale leases and missing workers recover only through explicit events, and `run.sealed` is terminal.
162+
161163
## Workflow Map
162164

163165
### Lightweight Or Artifact-Backed Workflows

src/domain/runtime-events.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
export type RuntimeEventType =
2+
| "run.created" | "run.objective_updated" | "policy.candidate_recorded" | "policy.simulated" | "policy.selected"
3+
| "task_graph.created" | "task.ready" | "task.claimed" | "task.lease_renewed" | "task.lease_expired" | "task.transitioned"
4+
| "worker.provisioned" | "worker.ready" | "worker.heartbeat" | "worker.retained" | "worker.cleanup_released"
5+
| "evidence.recorded" | "evaluation.recorded" | "repair.created" | "integration.attempted" | "reward.recorded" | "run.sealed";
6+
7+
export type RuntimeEventCategory = "run" | "objective" | "policy" | "task" | "worker" | "evidence" | "evaluation" | "repair" | "integration" | "reward";
8+
export type RuntimeEventReplayAction = "create-run" | "upsert-objective" | "record-policy" | "upsert-task" | "upsert-worker" | "record-evidence" | "record-evaluation" | "record-repair" | "record-integration" | "record-reward" | "seal-run";
9+
10+
export interface RuntimeEventEnvelope {
11+
schemaVersion: 1;
12+
eventId: string;
13+
runId: string;
14+
type: RuntimeEventType;
15+
category: RuntimeEventCategory;
16+
at: string;
17+
seq: number;
18+
idempotencyKey: string;
19+
payload: Record<string, unknown>;
20+
}
21+
22+
export interface RuntimeEventSpec {
23+
type: RuntimeEventType;
24+
category: RuntimeEventCategory;
25+
replay: RuntimeEventReplayAction;
26+
requiredPayload: readonly string[];
27+
terminal?: true;
28+
}
29+
30+
export const RUNTIME_EVENT_SCHEMA_VERSION = 1;
31+
32+
export const RUNTIME_EVENT_SPECS: readonly RuntimeEventSpec[] = [
33+
spec("run.created", "run", "create-run", ["goal"]),
34+
spec("run.objective_updated", "objective", "upsert-objective", ["objectiveRef"]),
35+
spec("policy.candidate_recorded", "policy", "record-policy", ["policyCandidateRef"]),
36+
spec("policy.simulated", "policy", "record-policy", ["policyCandidateRef", "simulationRef"]),
37+
spec("policy.selected", "policy", "record-policy", ["policySelectionRef"]),
38+
spec("task_graph.created", "task", "upsert-task", ["taskGraphRef"]),
39+
spec("task.ready", "task", "upsert-task", ["taskId", "reason"]),
40+
spec("task.claimed", "task", "upsert-task", ["taskId", "workerId", "claimToken", "leaseExpiresAt"]),
41+
spec("task.lease_renewed", "task", "upsert-task", ["taskId", "claimToken", "leaseExpiresAt"]),
42+
spec("task.lease_expired", "task", "upsert-task", ["taskId", "claimToken", "recoveryPolicy"]),
43+
spec("task.transitioned", "task", "upsert-task", ["taskId", "from", "to"]),
44+
spec("worker.provisioned", "worker", "upsert-worker", ["workerId", "adapterId"]),
45+
spec("worker.ready", "worker", "upsert-worker", ["workerId", "readinessNonce"]),
46+
spec("worker.heartbeat", "worker", "upsert-worker", ["workerId"]),
47+
spec("worker.retained", "worker", "upsert-worker", ["workerId", "reason"]),
48+
spec("worker.cleanup_released", "worker", "upsert-worker", ["workerId", "safeCloseRef"]),
49+
spec("evidence.recorded", "evidence", "record-evidence", ["evidenceRef"]),
50+
spec("evaluation.recorded", "evaluation", "record-evaluation", ["evaluationRef", "verdict"]),
51+
spec("repair.created", "repair", "record-repair", ["sourceTaskId", "repairTaskId", "reason"]),
52+
spec("integration.attempted", "integration", "record-integration", ["integrationCandidateRef", "dryRunRef"]),
53+
spec("reward.recorded", "reward", "record-reward", ["rewardRef"]),
54+
spec("run.sealed", "run", "seal-run", ["finalReportRef"], true),
55+
];
56+
57+
export const RUNTIME_EVENT_REPLAY_RULES = [
58+
"Apply events by ascending seq; equal seq/eventId replay is invalid unless idempotencyKey and payload match an already-applied event.",
59+
"Replay is additive: events upsert derived run/task/worker projections and append evidence, evaluation, reward, repair, and integration ledgers.",
60+
"task.lease_expired is explicit recovery evidence; stale leases are recoverable only after this event and never by destructive cleanup.",
61+
"worker.heartbeat updates liveness only; missing heartbeat becomes worker.retained or task.lease_expired through explicit recovery events.",
62+
"run.sealed is terminal: later non-run.sealed events for the same run are invalid and must not mutate projected state.",
63+
] as const;
64+
65+
export function validateRuntimeEvents(events: readonly unknown[]): { ok: boolean; issues: string[] } {
66+
const issues: string[] = [];
67+
const seen = new Map<string, string>();
68+
const seenSeq = new Map<number, { eventId: string; fingerprint: string }>();
69+
let lastSeq = 0;
70+
let sealedAtSeq: number | undefined;
71+
for (let index = 0; index < events.length; index += 1) {
72+
const event = events[index];
73+
const prefix = `events[${index}]`;
74+
if (!isRecord(event)) { issues.push(`${prefix} must be an object`); continue; }
75+
validateEvent(event, prefix, issues);
76+
const eventId = typeof event.eventId === "string" ? event.eventId : "";
77+
const fingerprint = JSON.stringify(event);
78+
if (eventId) {
79+
const prior = seen.get(eventId);
80+
if (prior && prior !== fingerprint) issues.push(`${prefix}.eventId duplicates with different payload`);
81+
seen.set(eventId, fingerprint);
82+
}
83+
const seq = typeof event.seq === "number" && Number.isInteger(event.seq) ? event.seq : undefined;
84+
if (seq !== undefined) {
85+
const priorSeq = seenSeq.get(seq);
86+
if (seq < lastSeq) issues.push(`${prefix}.seq must be monotonic`);
87+
if (priorSeq && (priorSeq.eventId !== eventId || priorSeq.fingerprint !== fingerprint)) issues.push(`${prefix}.seq duplicates with different event`);
88+
if (!priorSeq) seenSeq.set(seq, { eventId, fingerprint });
89+
if (seq > lastSeq) lastSeq = seq;
90+
}
91+
if (sealedAtSeq !== undefined && event.type !== "run.sealed") issues.push(`${prefix} occurs after terminal run.sealed`);
92+
if (event.type === "run.sealed" && typeof event.seq === "number") sealedAtSeq = event.seq;
93+
}
94+
return { ok: issues.length === 0, issues };
95+
}
96+
97+
export function runtimeEventCatalogMarkdown(): string {
98+
return RUNTIME_EVENT_SPECS.map((item) => `- \`${item.type}\` (${item.category}) payload: ${item.requiredPayload.join(", ")}; replay: ${item.replay}${item.terminal ? "; terminal" : ""}`).join("\n");
99+
}
100+
101+
const specsByType = new Map<RuntimeEventType, RuntimeEventSpec>(RUNTIME_EVENT_SPECS.map((item) => [item.type, item]));
102+
103+
function validateEvent(event: Record<string, unknown>, prefix: string, issues: string[]): void {
104+
if (event.schemaVersion !== RUNTIME_EVENT_SCHEMA_VERSION) issues.push(`${prefix}.schemaVersion must be 1`);
105+
needText(event.eventId, `${prefix}.eventId`, issues); needText(event.runId, `${prefix}.runId`, issues); needText(event.idempotencyKey, `${prefix}.idempotencyKey`, issues);
106+
if (typeof event.seq !== "number" || !Number.isInteger(event.seq) || event.seq < 1) issues.push(`${prefix}.seq must be a positive integer`);
107+
if (typeof event.at !== "string" || !Number.isFinite(Date.parse(event.at))) issues.push(`${prefix}.at must be a timestamp`);
108+
if (!isRecord(event.payload)) issues.push(`${prefix}.payload must be an object`);
109+
const specForType = typeof event.type === "string" ? specsByType.get(event.type as RuntimeEventType) : undefined;
110+
if (!specForType) { issues.push(`${prefix}.type is unsupported`); return; }
111+
if (event.category !== specForType.category) issues.push(`${prefix}.category must be ${specForType.category}`);
112+
if (!isRecord(event.payload)) return;
113+
for (const key of specForType.requiredPayload) needText(event.payload[key], `${prefix}.payload.${key}`, issues);
114+
if ((event.type === "task.claimed" || event.type === "task.lease_renewed") && typeof event.payload.leaseExpiresAt === "string" && !Number.isFinite(Date.parse(event.payload.leaseExpiresAt))) issues.push(`${prefix}.payload.leaseExpiresAt must be a timestamp`);
115+
}
116+
117+
function spec(type: RuntimeEventType, category: RuntimeEventCategory, replay: RuntimeEventReplayAction, requiredPayload: readonly string[], terminal?: true): RuntimeEventSpec {
118+
return { type, category, replay, requiredPayload, ...(terminal ? { terminal } : {}) };
119+
}
120+
function needText(value: unknown, label: string, issues: string[]): void {
121+
if (typeof value !== "string" || !value.trim()) issues.push(`${label} is required`);
122+
}
123+
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }

test/runtime-events.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import * as assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
import { RUNTIME_EVENT_REPLAY_RULES, RUNTIME_EVENT_SPECS, runtimeEventCatalogMarkdown, validateRuntimeEvents, type RuntimeEventEnvelope } from "../src/domain/runtime-events.js";
4+
5+
const at = "2026-01-01T00:00:00.000Z";
6+
function event(overrides: Partial<RuntimeEventEnvelope> = {}): RuntimeEventEnvelope {
7+
return { schemaVersion: 1, eventId: "e1", runId: "run-1", type: "task.claimed", category: "task", at, seq: 1, idempotencyKey: "run-1/task-1/claim", payload: { taskId: "task-1", workerId: "worker-1", claimToken: "claim-1", leaseExpiresAt: "2026-01-01T00:05:00.000Z" }, ...overrides };
8+
}
9+
10+
test("runtime event catalog covers MVP categories and replay rules", () => {
11+
assert.ok(RUNTIME_EVENT_SPECS.some((item) => item.type === "run.sealed" && item.terminal));
12+
assert.ok(RUNTIME_EVENT_SPECS.some((item) => item.type === "task.lease_expired" && item.requiredPayload.includes("recoveryPolicy")));
13+
assert.ok(RUNTIME_EVENT_SPECS.some((item) => item.type === "worker.cleanup_released"));
14+
assert.ok(runtimeEventCatalogMarkdown().includes("`policy.simulated`"));
15+
assert.ok(RUNTIME_EVENT_REPLAY_RULES.some((rule) => rule.includes("run.sealed is terminal")));
16+
});
17+
18+
test("runtime events validate required envelope and payload surfaces", () => {
19+
assert.deepEqual(validateRuntimeEvents([event()]), { ok: true, issues: [] });
20+
const malformed = validateRuntimeEvents([{ ...event(), schemaVersion: 2, seq: 0, category: "worker", payload: { taskId: "task-1", workerId: "worker-1", claimToken: "claim-1", leaseExpiresAt: "not-time" } }]);
21+
assert.equal(malformed.ok, false);
22+
assert.ok(malformed.issues.includes("events[0].schemaVersion must be 1"));
23+
assert.ok(malformed.issues.includes("events[0].seq must be a positive integer"));
24+
assert.ok(malformed.issues.includes("events[0].category must be task"));
25+
assert.ok(malformed.issues.includes("events[0].payload.leaseExpiresAt must be a timestamp"));
26+
});
27+
28+
test("runtime events reject conflicting duplicate ids and mutation after seal", () => {
29+
const sealed = event({ eventId: "e2", type: "run.sealed", category: "run", seq: 2, idempotencyKey: "seal", payload: { finalReportRef: "final.md" } });
30+
const afterSeal = event({ eventId: "e3", seq: 3 });
31+
const duplicate = event({ payload: { taskId: "other", workerId: "worker-1", claimToken: "claim-1", leaseExpiresAt: "2026-01-01T00:05:00.000Z" } });
32+
const result = validateRuntimeEvents([event(), duplicate, sealed, afterSeal]);
33+
assert.equal(result.ok, false);
34+
assert.ok(result.issues.includes("events[1].eventId duplicates with different payload"));
35+
assert.ok(result.issues.includes("events[1].seq duplicates with different event"));
36+
assert.ok(result.issues.includes("events[3] occurs after terminal run.sealed"));
37+
});
38+
39+
test("runtime events enforce monotonic seq while allowing exact idempotent duplicates", () => {
40+
const first = event();
41+
assert.deepEqual(validateRuntimeEvents([first, first]), { ok: true, issues: [] });
42+
const sameSeqDifferentId = event({ eventId: "e2", idempotencyKey: "other" });
43+
assert.ok(validateRuntimeEvents([first, sameSeqDifferentId]).issues.includes("events[1].seq duplicates with different event"));
44+
const outOfOrder = event({ eventId: "e3", seq: 1, idempotencyKey: "late" });
45+
const result = validateRuntimeEvents([event({ seq: 2 }), outOfOrder]);
46+
assert.ok(result.issues.includes("events[1].seq must be monotonic"));
47+
});

0 commit comments

Comments
 (0)