@kontextmind/kxm
Version:
KXM local-first multi-agent orchestration and operator dashboard
1,952 lines • 79.5 kB
text/typescript
import { createHash, createHmac } from "node:crypto";
import {
IMPROVEMENT_AREAS,
MAX_MESSAGE_TTL_MS,
MIN_MESSAGE_TTL_MS,
ProtocolError,
TERMINAL_RECEIPT_SCHEMA,
newId,
requireString,
validateTerminalReceipt,
type ImprovementArea,
type JournalCategory,
type MessageRecord,
type TerminalReceipt,
type TerminalReceiptEvidence,
type TerminalReceiptMetrics,
type TerminalReceiptStatus,
type WorkflowCheckpointStatus,
type WorkflowEvidenceInput,
type WorkflowEvidenceReference,
type WorkflowEvidenceReferenceInput,
type WorkflowMessageContext,
} from "./protocol.ts";
import { redactSecrets } from "./redact.ts";
import { compareCodeUnitIds } from "./relevance.ts";
export type {
ImprovementArea,
JournalCategory,
TerminalReceipt,
TerminalReceiptEvidence,
TerminalReceiptMetrics,
TerminalReceiptStatus,
WorkflowCheckpointStatus,
WorkflowEvidenceInput,
WorkflowEvidenceReference,
WorkflowEvidenceReferenceInput,
} from "./protocol.ts";
export { TERMINAL_RECEIPT_SCHEMA, validateTerminalReceipt } from "./protocol.ts";
export type WorkflowRunStatus = "running" | "waiting" | "completed" | "failed";
export type WorkflowStageStatus = "pending" | "in_progress" | "waiting" | WorkflowCheckpointStatus;
/** Every journal category this runtime accepts. v0.4 records only use the
* first five; the v0.5 additions turn the journal into the canonical episode
* and learning substrate. */
export const JOURNAL_CATEGORIES: readonly JournalCategory[] = [
"plan",
"decision",
"contradiction",
"error",
"lesson",
"observation",
"hypothesis",
"experiment",
"state-change",
"skill-candidate",
];
/** Categories that must cite durable evidence references. A lesson without
* evidence is an opinion; a skill-candidate without verified receipts is a
* wish. Both fail closed at the hub. */
export const EVIDENCE_REQUIRED_JOURNAL_CATEGORIES: readonly JournalCategory[] = ["lesson", "skill-candidate"];
/** Categories that participate in the governed promotion lifecycle. */
export const PROMOTABLE_JOURNAL_CATEGORIES: readonly JournalCategory[] = ["skill-candidate", "hypothesis", "experiment"];
export function parseJournalCategory(value: unknown): JournalCategory {
if (typeof value !== "string" || !JOURNAL_CATEGORIES.includes(value as JournalCategory)) {
throw new ProtocolError(
400,
`invalid journal category: must be one of ${JOURNAL_CATEGORIES.join(", ")}`,
"invalid_journal_category",
);
}
return value as JournalCategory;
}
export function journalEvidenceRequired(category: JournalCategory): boolean {
return EVIDENCE_REQUIRED_JOURNAL_CATEGORIES.includes(category);
}
export type JournalPromotionState = "proposed" | "approved" | "rejected" | "quarantined";
export const JOURNAL_PROMOTION_STATES: readonly JournalPromotionState[] = ["proposed", "approved", "rejected", "quarantined"];
const JOURNAL_PROMOTION_TERMINAL: readonly JournalPromotionState[] = ["approved", "rejected", "quarantined"];
/** Durable, append-only promotion decision for a journal entry. Journals are
* evidence, never executable policy: a promotion record changes the learning
* lifecycle of an entry, and nothing else. */
export interface JournalPromotionRecord {
schema: "kxm.journal-promotion.v1";
from: JournalPromotionState;
to: JournalPromotionState;
evidenceRefs: string[];
decidedBy: string;
reason: string;
decidedAt: string;
}
/** Durable evidence accumulated across local work, retries, and an external
* signal. A legacy string array can still be read from pre-0.4 databases, but
* it never satisfies a keyed requirement. */
export type WorkflowEvidence = Record<string, string[]>;
export interface PeerReplyDegradationPolicy {
minProducers: number;
}
export interface PeerReplyEvidencePolicy {
kind: "peer-reply";
minProducers: number;
eligibleAgents: string[];
acceptedStatuses?: ["replied"];
degradation?: PeerReplyDegradationPolicy;
}
export type WorkflowEvidencePolicy = PeerReplyEvidencePolicy;
export type WorkflowEvidencePolicies = Record<string, WorkflowEvidencePolicy>;
export interface EligiblePeerProducer {
id: string;
name: string;
}
export interface ResolvedPeerReplyEvidencePolicy {
kind: "peer-reply";
minProducers: number;
eligibleProducers: EligiblePeerProducer[];
acceptedStatuses: ["replied"];
degradation?: PeerReplyDegradationPolicy;
}
export type ResolvedWorkflowEvidencePolicies = Record<string, ResolvedPeerReplyEvidencePolicy>;
export interface VerifiedPeerEvidenceSnapshot {
schema: "pi-mesh.verified-peer-evidence.v1";
messageId: string;
producerId: string;
producerName: string;
context: WorkflowMessageContext;
status: "replied";
requestSha256: string;
replySha256: string;
createdAt: string;
replyCreatedAt: string;
repliedAt: string;
verifiedAt: string;
}
export type WorkflowVerifiedEvidence = Record<string, VerifiedPeerEvidenceSnapshot[]>;
export interface WorkflowDegradationApproval {
schema: "pi-mesh.workflow-degradation-approval.v1";
id: string;
requirementKey: string;
attempt: number;
policyMinProducers: number;
approvedMinProducers: number;
approvedBy: "kxm-admin";
reason: string;
approvedAt: string;
}
export interface WorkflowStageDefinition {
id: string;
label: string;
instructions: string;
requiredEvidence: string[];
maxAttempts: number;
/** Bounded in-place retry limit before audit escalation (default: 2). */
autoResumeLimit?: number;
area?: ImprovementArea;
evidencePolicies?: WorkflowEvidencePolicies;
/** Typed outcome map (v0.5). Keys are outcome identities ("passed",
* "failed", or declared custom outcomes); values are stage IDs or
* "$terminal". Only declared keys create transitions; undeclared outcomes
* keep the v0.4 default edges (forward-next, attempt-bounded retry). */
on?: WorkflowOutcomeMap;
/** Per-stage budget on transitions taken from this stage. */
maxTransitions?: number;
}
/** A declared transition: a target stage ID, "$terminal", or a rule with a
* per-edge budget. */
export interface WorkflowTransitionRule {
target: string;
maxTransitions?: number;
}
export type WorkflowOutcomeValue = string | WorkflowTransitionRule;
export type WorkflowOutcomeMap = Record<string, WorkflowOutcomeValue>;
export const WORKFLOW_TERMINAL_TARGET = "$terminal";
/** One durably journaled typed transition. */
export interface WorkflowTransitionRecord {
id: string;
fromStage: string;
toStage: string;
outcome: string;
attempt: number;
evidenceKeys: string[];
at: string;
}
export function normalizeOutcomeValue(value: WorkflowOutcomeValue, field: string): WorkflowTransitionRule {
if (typeof value === "string") return { target: value };
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${field} must be a stage ID, "$terminal", or a { target, maxTransitions } rule`);
}
const rule = value as Partial<WorkflowTransitionRule>;
if (typeof rule.target !== "string" || !rule.target.trim()) {
throw new Error(`${field}.target must be a non-empty stage ID or "$terminal"`);
}
if (rule.maxTransitions !== undefined
&& (!Number.isInteger(rule.maxTransitions) || (rule.maxTransitions as number) < 1 || (rule.maxTransitions as number) > 100)) {
throw new Error(`${field}.maxTransitions must be an integer between 1 and 100`);
}
return { target: rule.target, ...(rule.maxTransitions !== undefined ? { maxTransitions: rule.maxTransitions } : {}) };
}
export interface WebhookWorkflowDefinition {
id: string;
source: "jira" | "github" | "generic";
project: string;
target: string;
secret: string;
signalSecret?: string;
event?: string;
filter?: { path: string; equals: string };
delivery: "steer" | "followUp";
ttlMs?: number;
promptTemplate: string;
stages: WorkflowStageDefinition[];
/** Global budget on typed transitions per run. Required whenever any stage
* declares a back-edge (a cycle without a budget is rejected at load). */
maxTransitions?: number;
/** Immutable reproduction oracle (v0.5 /fix): when `stageId` passes, the
* sha256 of `evidenceKey`'s value is captured on the run. Any later
* checkpoint citing the same evidence key with different values is
* rejected — a confirmed reproduction may not be weakened to make the
* fix pass. */
reproOracle?: WorkflowOracleConfig;
/** Approved-plan hash: when `stageId` passes, the sha256 of `evidenceKey`'s
* value is captured on the run. */
planHash?: WorkflowOracleConfig;
/** Stages that cannot checkpoint until the plan hash is captured. */
requirePlanHash?: string[];
}
export interface WorkflowOracleConfig {
stageId: string;
evidenceKey: string;
}
export interface WorkflowStageState extends WorkflowStageDefinition {
status: WorkflowStageStatus;
attempts: number;
summary?: string;
evidence: WorkflowEvidence | string[];
resolvedEvidencePolicies?: ResolvedWorkflowEvidencePolicies;
verifiedEvidence?: WorkflowVerifiedEvidence;
degradationApprovals?: WorkflowDegradationApproval[];
degraded?: boolean;
degradedRequirements?: string[];
startedAt?: string;
completedAt?: string;
updatedAt?: string;
receipt?: TerminalReceipt;
auditEscalation?: {
reason: string;
timestamp: string;
ruling?: string;
receipt?: TerminalReceipt;
};
}
export interface CapturedOracle {
evidenceKey: string;
sha256: string;
capturedAt: string;
}
export interface WorkflowWaitState {
stageId: string;
signalKey: string;
summary: string;
createdAt: string;
expiresAt: string;
}
export interface WorkflowSignalReceipt {
deliveryId: string;
payloadHash: string;
signalKey: string;
stageId: string;
status: WorkflowCheckpointStatus;
degraded?: boolean;
degradedRequirements?: string[];
messageId?: string;
receivedAt: string;
}
export interface WorkflowRun {
id: string;
definitionId: string;
source: WebhookWorkflowDefinition["source"];
deliveryId: string;
payloadHash: string;
/** sha256 of the canonical workflow definition JSON, stamped at run
* creation. Optional on read: runs persisted before this field existed
* still load, they simply have no hash to compare. */
definitionHash?: string;
event?: string;
project: string;
targetAgentId: string;
targetAgentName: string;
messageId: string;
status: WorkflowRunStatus;
currentStage?: string;
waiting?: WorkflowWaitState;
signalReceipts?: WorkflowSignalReceipt[];
stages: WorkflowStageState[];
/** Durable typed-transition journal (v0.5), oldest first, bounded by the
* definition's maxTransitions. Absent on v0.4 runs. */
transitions?: WorkflowTransitionRecord[];
/** Global transition budget copied from the definition at run creation. */
maxTransitions?: number;
/** Captured immutable reproduction oracle (sha256 of the repro evidence). */
oracle?: CapturedOracle;
/** Captured approved-plan hash. */
planHash?: CapturedOracle;
/** Stages that require the plan hash before checkpointing. */
requirePlanHash?: string[];
/** Oracle config copied from the definition at run creation. */
reproOracle?: WorkflowOracleConfig;
planHashConfig?: WorkflowOracleConfig;
createdAt: string;
updatedAt: string;
completedAt?: string;
}
export interface WorkflowJournalEntry {
id: string;
runId: string;
agentId: string;
category: JournalCategory;
area: ImprovementArea;
severity: "info" | "warning" | "error";
summary: string;
details?: string;
evidence: string[];
relatedEntryIds: string[];
createdAt: string;
/** Stage the entry was recorded against. Optional: v0.4 entries and
* run-level entries have no stage binding. */
stageId?: string;
/** Attempt the entry was recorded against, when stage-bound. */
attempt?: number;
/** Governed promotion history. Absent on v0.4 records and on entries that
* never entered the promotion lifecycle. */
promotion?: JournalPromotionRecord[];
}
/** Current promotion lifecycle state of an entry. Entries without promotion
* records are implicitly `proposed` when they belong to a promotable
* category. */
export function journalPromotionState(entry: WorkflowJournalEntry): JournalPromotionState | undefined {
if (!PROMOTABLE_JOURNAL_CATEGORIES.includes(entry.category)) return undefined;
const records = entry.promotion ?? [];
return records.length === 0 ? "proposed" : records[records.length - 1]?.to;
}
export interface JournalPromotionDecision {
to: Exclude<JournalPromotionState, "proposed">;
evidenceRefs: string[];
decidedBy: string;
reason: string;
}
/** Apply a governed promotion transition. Returns a new entry; never mutates
* in place. Rules: only promotable categories; `proposed` is the only
* non-terminal state; the author of an entry can never decide its promotion;
* evidence references are required. */
export function applyJournalPromotion(
entry: WorkflowJournalEntry,
decision: JournalPromotionDecision,
decidedAt: string,
): WorkflowJournalEntry {
if (!PROMOTABLE_JOURNAL_CATEGORIES.includes(entry.category)) {
throw new ProtocolError(
400,
`journal entries of category ${entry.category} do not participate in promotion`,
"journal_promotion_invalid",
);
}
if (decision.decidedBy === entry.agentId) {
throw new ProtocolError(
400,
"the author of a journal entry cannot decide its promotion",
"journal_promotion_invalid",
);
}
if (!Array.isArray(decision.evidenceRefs) || decision.evidenceRefs.length < 1
|| decision.evidenceRefs.some((ref) => typeof ref !== "string" || !ref.trim())) {
throw new ProtocolError(
400,
"journal promotion requires at least one durable evidence reference",
"journal_promotion_invalid",
);
}
const current = journalPromotionState(entry);
if (current !== "proposed") {
throw new ProtocolError(
400,
`journal entry promotion already reached terminal state ${current}`,
"journal_promotion_invalid",
);
}
const record: JournalPromotionRecord = {
schema: "kxm.journal-promotion.v1",
from: "proposed",
to: decision.to,
evidenceRefs: decision.evidenceRefs.map((ref) => ref.trim()),
decidedBy: decision.decidedBy,
reason: decision.reason,
decidedAt,
};
return { ...entry, promotion: [...(entry.promotion ?? []), record] };
}
export interface ImprovementAreaReport {
area: ImprovementArea;
total: number;
errors: number;
contradictions: number;
lessons: number;
priorities: WorkflowJournalEntry[];
}
export function improvementReport(entries: WorkflowJournalEntry[]): ImprovementAreaReport[] {
const severityWeight = { error: 3, warning: 2, info: 1 } as const;
return IMPROVEMENT_AREAS.map((area) => {
const matching = entries.filter((entry) => entry.area === area);
const priorities = [...matching]
.filter((entry) => entry.category === "error" || entry.category === "contradiction" || entry.category === "lesson" || entry.category === "skill-candidate")
.sort((left, right) => severityWeight[right.severity] - severityWeight[left.severity])
.slice(0, 10);
return {
area,
total: matching.length,
errors: matching.filter((entry) => entry.category === "error").length,
contradictions: matching.filter((entry) => entry.category === "contradiction").length,
lessons: matching.filter((entry) => entry.category === "lesson").length,
priorities,
};
}).filter((report) => report.total > 0);
}
/** Diagnostic classes that put a signal in the security tier regardless of
* its priority (diagnostics.ts). */
export const SECURITY_SIGNAL_CLASSES: readonly string[] = ["invalid_auth", "invalid_identity", "signal_mismatch"];
const SIGNAL_CATEGORIES: readonly JournalCategory[] = ["error", "contradiction", "lesson", "skill-candidate"];
const SIGNAL_SEVERITY_WEIGHT = { error: 3, warning: 2, info: 1 } as const;
const SIGNAL_CLASS = /^[a-z0-9_]{1,64}$/;
const MAX_SIGNAL_IDS = 16;
const MAX_SIGNAL_SUMMARY_KEY_CHARS = 160;
/** Collapse volatile tokens so the same failure in two runs keys the same.
* Callers redact first: lowercasing and digit folding would otherwise defeat
* the secret patterns. */
export function normalizeSignalSummary(summary: string): string {
return summary
.toLowerCase()
.replace(/\b[a-z]+_[0-9a-f]{8,}\b/g, "<id>")
.replace(/\b\d{4}-\d{2}-\d{2}t\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:z|[+-]\d{2}:?\d{2})?/g, "<ts>")
.replace(/\b[0-9a-f]{7,}\b/g, "<hex>")
.replace(/\d+/g, "#")
.replace(/\s+/g, " ")
.trim()
.slice(0, MAX_SIGNAL_SUMMARY_KEY_CHARS);
}
/** One improvement signal: journal entries from one or more runs that share
* a key, scored by frequency x severity x run-attempt cost x evidence
* confidence. Text is redacted; the key never carries raw summary text. */
export interface ImprovementSignal {
key: string;
/** Which rule produced the key: an evidence class, an error's stage, or the
* normalized summary. */
basis: "class" | "stage" | "summary";
category: JournalCategory;
/** Modal area of the grouped entries; ties follow IMPROVEMENT_AREAS order. */
area: ImprovementArea;
/** Security signals rank ahead of every priority. */
overrideTier?: "security";
/** Distinct runs that recorded this signal. */
frequency: number;
runIds: string[];
entryIds: string[];
severity: WorkflowJournalEntry["severity"];
severityWeight: number;
/** Mean run attempts (stage attempts plus transitions) over the known runs;
* null when none of the runs is known. */
workflowCost: number | null;
costBasis: "run-attempts" | "unknown";
/** 0.5 plus half the fraction of entries that cite evidence. */
confidence: number;
priority: number;
/** Redacted summary of the latest entry. */
summary: string;
}
function round3(value: number): number {
return Math.round(value * 1000) / 1000;
}
function signalKeyOf(
entry: WorkflowJournalEntry,
runs: ReadonlyMap<string, WorkflowRun>,
): { key: string; basis: ImprovementSignal["basis"]; signalClass?: string } {
const classRef = entry.evidence.find((ref) => ref.startsWith("class:"));
const signalClass = classRef?.slice("class:".length);
if (signalClass !== undefined && SIGNAL_CLASS.test(signalClass) && signalClass !== "unknown") {
return { key: `${entry.category}|class:${signalClass}`, basis: "class", signalClass };
}
const run = runs.get(entry.runId);
if (entry.category === "error" && entry.stageId !== undefined && run) {
return { key: `error|stage:${run.definitionId}/${entry.stageId}`, basis: "stage" };
}
return {
key: `${entry.category}|summary:${normalizeSignalSummary(redactSecrets(entry.summary))}`,
basis: "summary",
};
}
function runAttemptCost(run: WorkflowRun): number {
let attempts = 0;
for (const stage of run.stages) attempts += stage.attempts;
return Math.max(1, attempts + (run.transitions?.length ?? 0));
}
/** Merge journal learning across runs into ranked, redacted signals. Only
* errors, open contradictions, lessons and still-proposed skill candidates
* count. Security signals come first, then priority, frequency and key; no
* id or insertion order decides a tie. */
export function rankImprovementSignals(
entries: readonly WorkflowJournalEntry[],
runs: ReadonlyMap<string, WorkflowRun>,
limit = 20,
): ImprovementSignal[] {
const resolvedContradictions = new Set<string>();
for (const entry of entries) {
if (entry.category !== "decision" && entry.category !== "lesson") continue;
for (const related of entry.relatedEntryIds) resolvedContradictions.add(`${entry.runId}\u0000${related}`);
}
const groups = new Map<string, {
basis: ImprovementSignal["basis"];
category: JournalCategory;
signalClass?: string;
entries: WorkflowJournalEntry[];
}>();
for (const entry of entries) {
if (!SIGNAL_CATEGORIES.includes(entry.category)) continue;
if (entry.category === "contradiction" && resolvedContradictions.has(`${entry.runId}\u0000${entry.id}`)) continue;
const promotionState = journalPromotionState(entry);
if (promotionState !== undefined && promotionState !== "proposed") continue;
const { key, basis, signalClass } = signalKeyOf(entry, runs);
const group = groups.get(key);
if (group) group.entries.push(entry);
else groups.set(key, { basis, category: entry.category, ...(signalClass !== undefined ? { signalClass } : {}), entries: [entry] });
}
const areaRank = (area: string): number => {
const index = IMPROVEMENT_AREAS.indexOf(area as ImprovementArea);
return index === -1 ? IMPROVEMENT_AREAS.length : index;
};
const signals: ImprovementSignal[] = [];
for (const [key, group] of groups) {
const runIds = [...new Set(group.entries.map((entry) => entry.runId))].sort(compareCodeUnitIds);
const entryIds = group.entries.map((entry) => entry.id).sort(compareCodeUnitIds);
let severity: WorkflowJournalEntry["severity"] = "info";
for (const entry of group.entries) {
if ((SIGNAL_SEVERITY_WEIGHT[entry.severity] ?? 0) > SIGNAL_SEVERITY_WEIGHT[severity]) severity = entry.severity;
}
const severityWeight = SIGNAL_SEVERITY_WEIGHT[severity];
const knownRuns = runIds.map((runId) => runs.get(runId)).filter((run): run is WorkflowRun => run !== undefined);
const workflowCost = knownRuns.length > 0
? knownRuns.reduce((total, run) => total + runAttemptCost(run), 0) / knownRuns.length
: null;
const withEvidence = group.entries.filter((entry) => entry.evidence.length > 0).length;
const confidence = 0.5 + 0.5 * (withEvidence / group.entries.length);
const areaCounts = new Map<ImprovementArea, number>();
for (const entry of group.entries) areaCounts.set(entry.area, (areaCounts.get(entry.area) ?? 0) + 1);
const area = [...areaCounts].sort((left, right) => right[1] - left[1] || areaRank(left[0]) - areaRank(right[0]))[0]![0];
const latest = [...group.entries].sort((left, right) =>
compareCodeUnitIds(left.createdAt, right.createdAt) || compareCodeUnitIds(left.id, right.id)).at(-1)!;
const security = area === "security"
|| (group.signalClass !== undefined && SECURITY_SIGNAL_CLASSES.includes(group.signalClass));
signals.push({
key,
basis: group.basis,
category: group.category,
area,
...(security ? { overrideTier: "security" as const } : {}),
frequency: runIds.length,
runIds: runIds.slice(0, MAX_SIGNAL_IDS),
entryIds: entryIds.slice(0, MAX_SIGNAL_IDS),
severity,
severityWeight,
workflowCost,
costBasis: workflowCost === null ? "unknown" : "run-attempts",
confidence,
priority: round3(runIds.length * severityWeight * (workflowCost ?? 1) * confidence),
summary: redactSecrets(latest.summary),
});
}
return signals
.sort((left, right) =>
(right.overrideTier === "security" ? 1 : 0) - (left.overrideTier === "security" ? 1 : 0)
|| right.priority - left.priority
|| right.frequency - left.frequency
|| compareCodeUnitIds(left.key, right.key))
.slice(0, limit);
}
function object(value: unknown, name: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${name} must be an object`);
return value as Record<string, unknown>;
}
function stringArray(value: unknown, name: string): string[] {
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item.trim())) {
throw new Error(`${name} must be an array of non-empty strings`);
}
return value.map((item) => (item as string).trim());
}
/** Canonical requirement identity used for matching and durable storage. */
export function canonicalWorkflowEvidenceKey(value: string): string {
return value.trim().replace(/\s+/gu, " ").toLowerCase();
}
export function normalizeWorkflowEvidence(value: unknown): WorkflowEvidence {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const normalized = new Map<string, string[]>();
for (const [requirement, candidate] of Object.entries(value as Record<string, unknown>)) {
const key = canonicalWorkflowEvidenceKey(requirement);
if (!key) continue;
const values = Array.isArray(candidate) ? candidate : [candidate];
const safeValues = values
.filter((item): item is string => typeof item === "string" && Boolean(item.trim()))
.map((item) => item.trim());
if (safeValues.length > 0) {
normalized.set(key, [...new Set([...(normalized.get(key) ?? []), ...safeValues])]);
}
}
return Object.fromEntries(normalized);
}
export function normalizeVerifiedWorkflowEvidence(value: unknown): WorkflowVerifiedEvidence {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const result = new Map<string, VerifiedPeerEvidenceSnapshot[]>();
for (const [rawRequirement, rawSnapshots] of Object.entries(value as Record<string, unknown>)) {
const requirement = canonicalWorkflowEvidenceKey(rawRequirement);
if (!requirement || !Array.isArray(rawSnapshots)) continue;
const snapshots = rawSnapshots.filter((candidate): candidate is VerifiedPeerEvidenceSnapshot => {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return false;
const snapshot = candidate as Partial<VerifiedPeerEvidenceSnapshot>;
return snapshot.schema === "pi-mesh.verified-peer-evidence.v1"
&& typeof snapshot.messageId === "string"
&& typeof snapshot.producerId === "string"
&& typeof snapshot.producerName === "string"
&& snapshot.status === "replied"
&& typeof snapshot.requestSha256 === "string"
&& typeof snapshot.replySha256 === "string"
&& typeof snapshot.createdAt === "string"
&& typeof snapshot.replyCreatedAt === "string"
&& typeof snapshot.repliedAt === "string"
&& typeof snapshot.verifiedAt === "string"
&& snapshot.context?.schema === "pi-mesh.workflow-message-context.v1";
});
if (snapshots.length) result.set(requirement, snapshots);
}
return Object.fromEntries(result);
}
export function mergeVerifiedWorkflowEvidence(
current: WorkflowVerifiedEvidence | undefined,
incoming: WorkflowVerifiedEvidence = {},
): WorkflowVerifiedEvidence {
const merged = new Map(Object.entries(normalizeVerifiedWorkflowEvidence(current)));
for (const [rawRequirement, snapshots] of Object.entries(incoming)) {
const requirement = canonicalWorkflowEvidenceKey(rawRequirement);
if (!requirement) continue;
const values = [...(merged.get(requirement) ?? [])];
for (const snapshot of snapshots) {
if (!values.some((candidate) => candidate.messageId === snapshot.messageId)) values.push(snapshot);
}
if (values.length) merged.set(requirement, values);
}
return Object.fromEntries(merged);
}
export function mergeWorkflowEvidence(
current: WorkflowEvidence | string[] | undefined,
incoming: WorkflowEvidenceInput = {},
): WorkflowEvidence {
const merged = new Map(Object.entries(normalizeWorkflowEvidence(current)));
const seen = new Set<string>();
for (const [rawRequirement, rawValue] of Object.entries(incoming)) {
const requirement = canonicalWorkflowEvidenceKey(rawRequirement);
if (!requirement || typeof rawValue !== "string" || !rawValue.trim()) {
throw new ProtocolError(400, "evidence must contain non-empty keyed string values", "invalid_workflow_evidence");
}
if (seen.has(requirement)) {
throw new ProtocolError(
400,
`evidence contains duplicate normalized requirement identity: ${requirement}`,
"invalid_workflow_evidence",
);
}
seen.add(requirement);
const value = rawValue.trim();
const values = merged.get(requirement) ?? [];
if (!values.includes(value)) values.push(value);
merged.set(requirement, values);
}
return Object.fromEntries(merged);
}
export function missingWorkflowEvidence(required: string[], evidence: WorkflowEvidence): string[] {
return required
.map(canonicalWorkflowEvidenceKey)
.filter((requirement) => !evidence[requirement]?.length);
}
export function workflowEvidenceStrings(evidence: WorkflowEvidenceInput | WorkflowEvidence): string[] {
return Object.entries(evidence).flatMap(([requirement, candidate]) => {
const values = Array.isArray(candidate) ? candidate : [candidate];
return values.map((value) => `${requirement}: ${value}`);
});
}
export function activeWorkflowAttempt(stage: Pick<WorkflowStageState, "attempts">): number {
return stage.attempts + 1;
}
/** The attempt a stage-bound journal entry belongs to. An active or waiting
* stage is on its next attempt; a finished stage is on the last attempt it
* consumed; a pending stage that never ran has no attempt yet. The caller
* never supplies it. */
export function journalAttemptFor(stage: Pick<WorkflowStageState, "status" | "attempts">): number | undefined {
switch (stage.status) {
case "in_progress":
case "waiting":
return stage.attempts + 1;
case "pending":
return stage.attempts > 0 ? stage.attempts : undefined;
case "passed":
case "warning":
case "failed":
return Math.max(1, stage.attempts);
}
}
export interface EvidenceLookup {
getMessage(id: string): MessageRecord | undefined;
}
function validIsoTimestamp(value: string | undefined): number | undefined {
if (!value) return undefined;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
/** Verify a complete reference batch without mutating the run. */
export function verifyWorkflowEvidenceReferences(
run: WorkflowRun,
stage: WorkflowStageState,
references: WorkflowEvidenceReferenceInput,
lookup: EvidenceLookup,
verifiedAt: string,
): WorkflowVerifiedEvidence {
const expectedAttempt = activeWorkflowAttempt(stage);
const result = new Map<string, VerifiedPeerEvidenceSnapshot[]>();
const seenRequirements = new Set<string>();
const seenMessageIds = new Set<string>();
for (const [rawRequirement, reference] of Object.entries(references)) {
const requirementKey = canonicalWorkflowEvidenceKey(rawRequirement);
if (!requirementKey || seenRequirements.has(requirementKey)) {
throw new ProtocolError(
400,
`evidenceRefs contains duplicate or empty requirement identity: ${requirementKey || "(empty)"}`,
"invalid_workflow_evidence_refs",
);
}
seenRequirements.add(requirementKey);
const policy = stage.resolvedEvidencePolicies?.[requirementKey];
if (!policy) {
throw new ProtocolError(
400,
`requirement ${requirementKey} does not declare a resolved peer evidence policy`,
"workflow_evidence_policy_missing",
);
}
if (!reference || !Array.isArray(reference.messageIds) || reference.messageIds.length < 1 || reference.messageIds.length > 16) {
throw new ProtocolError(
400,
`evidenceRefs.${requirementKey}.messageIds must contain between 1 and 16 message IDs`,
"invalid_workflow_evidence_refs",
);
}
const eligibleProducerIds = new Set(policy.eligibleProducers.map((producer) => producer.id));
const snapshots: VerifiedPeerEvidenceSnapshot[] = [];
for (const rawMessageId of reference.messageIds) {
const messageId = typeof rawMessageId === "string" ? rawMessageId.trim() : "";
if (!messageId || seenMessageIds.has(messageId)) {
throw new ProtocolError(
400,
`evidenceRefs contains an empty or duplicate message ID: ${messageId || "(empty)"}`,
"invalid_workflow_evidence_refs",
);
}
seenMessageIds.add(messageId);
const message = lookup.getMessage(messageId);
if (!message) {
throw new ProtocolError(400, `peer evidence message not found: ${messageId}`, "workflow_provenance_invalid");
}
const context = message.workflowContext;
if (
context?.schema !== "pi-mesh.workflow-message-context.v1"
|| context.runId !== run.id
|| context.stageId !== stage.id
|| context.requirementKey !== requirementKey
|| context.attempt !== expectedAttempt
) {
throw new ProtocolError(
400,
`peer evidence message ${messageId} is not bound to ${run.id}/${stage.id}/${requirementKey}/attempt-${expectedAttempt}`,
"workflow_provenance_invalid",
);
}
if (message.project !== run.project || message.from !== run.targetAgentId || message.to === run.targetAgentId) {
throw new ProtocolError(
400,
`peer evidence message ${messageId} has an invalid project or direction`,
"workflow_provenance_invalid",
);
}
if (!eligibleProducerIds.has(message.to)) {
throw new ProtocolError(
400,
`peer evidence producer ${message.toName} is not eligible for ${requirementKey}`,
"workflow_provenance_invalid",
);
}
if (message.correlationId !== run.id || message.status !== "replied" || !message.reply?.content.trim()) {
throw new ProtocolError(
400,
`peer evidence message ${messageId} is not a replied message for run ${run.id}`,
"workflow_provenance_invalid",
);
}
const createdAt = validIsoTimestamp(message.createdAt);
const deliveredAt = message.deliveredAt === undefined ? undefined : validIsoTimestamp(message.deliveredAt);
const replyCreatedAt = validIsoTimestamp(message.reply.createdAt);
const repliedAt = validIsoTimestamp(message.repliedAt);
if (
createdAt === undefined
|| replyCreatedAt === undefined
|| repliedAt === undefined
|| (message.deliveredAt !== undefined && deliveredAt === undefined)
|| (deliveredAt !== undefined && (deliveredAt < createdAt || deliveredAt > repliedAt))
|| replyCreatedAt < createdAt
|| repliedAt < replyCreatedAt
) {
throw new ProtocolError(
400,
`peer evidence message ${messageId} has incoherent reply timestamps`,
"workflow_provenance_invalid",
);
}
snapshots.push({
schema: "pi-mesh.verified-peer-evidence.v1",
messageId: message.id,
producerId: message.to,
producerName: message.toName,
context: { ...context },
status: "replied",
requestSha256: createHash("sha256").update(message.content, "utf8").digest("hex"),
replySha256: createHash("sha256").update(message.reply.content, "utf8").digest("hex"),
createdAt: message.createdAt,
replyCreatedAt: message.reply.createdAt,
repliedAt: message.repliedAt!,
verifiedAt,
});
}
result.set(requirementKey, snapshots);
}
return Object.fromEntries(result);
}
export interface PeerEvidenceRequirementStatus {
requirementKey: string;
policyMinProducers: number;
effectiveMinProducers: number;
producers: string[];
met: boolean;
degraded: boolean;
approval?: WorkflowDegradationApproval;
}
export function peerEvidenceRequirementStatus(
stage: WorkflowStageState,
requirementKey: string,
runId: string,
verifiedEvidence: WorkflowVerifiedEvidence = normalizeVerifiedWorkflowEvidence(stage.verifiedEvidence),
): PeerEvidenceRequirementStatus | undefined {
const canonicalKey = canonicalWorkflowEvidenceKey(requirementKey);
const policy = stage.resolvedEvidencePolicies?.[canonicalKey];
if (!policy) return undefined;
const attempt = activeWorkflowAttempt(stage);
const eligibleIds = new Set(policy.eligibleProducers.map((producer) => producer.id));
const producers = new Set<string>();
for (const snapshot of verifiedEvidence[canonicalKey] ?? []) {
if (
snapshot.schema === "pi-mesh.verified-peer-evidence.v1"
&& snapshot.status === "replied"
&& snapshot.context?.schema === "pi-mesh.workflow-message-context.v1"
&& snapshot.context.runId === runId
&& snapshot.context.stageId === stage.id
&& snapshot.context.requirementKey === canonicalKey
&& snapshot.context.attempt === attempt
&& eligibleIds.has(snapshot.producerId)
&& /^[a-f0-9]{64}$/.test(snapshot.requestSha256)
&& /^[a-f0-9]{64}$/.test(snapshot.replySha256)
&& validIsoTimestamp(snapshot.createdAt) !== undefined
&& validIsoTimestamp(snapshot.replyCreatedAt) !== undefined
&& validIsoTimestamp(snapshot.repliedAt) !== undefined
&& validIsoTimestamp(snapshot.verifiedAt) !== undefined
) producers.add(snapshot.producerId);
}
const approval = stage.degradationApprovals?.find(
(candidate) => candidate.requirementKey === canonicalKey && candidate.attempt === attempt,
);
const effectiveMinProducers = approval?.approvedMinProducers ?? policy.minProducers;
return {
requirementKey: canonicalKey,
policyMinProducers: policy.minProducers,
effectiveMinProducers,
producers: [...producers],
met: producers.size >= effectiveMinProducers,
degraded: Boolean(approval && producers.size < policy.minProducers && producers.size >= effectiveMinProducers),
...(approval ? { approval } : {}),
};
}
function requireCompleteEvidence(
stage: WorkflowStageState,
evidence: WorkflowEvidence,
verifiedEvidence: WorkflowVerifiedEvidence,
runId: string,
): PeerEvidenceRequirementStatus[] {
const missing: string[] = [];
const peerStatuses: PeerEvidenceRequirementStatus[] = [];
for (const rawRequirement of stage.requiredEvidence) {
const requirement = canonicalWorkflowEvidenceKey(rawRequirement);
const peerStatus = peerEvidenceRequirementStatus(stage, requirement, runId, verifiedEvidence);
if (peerStatus) {
peerStatuses.push(peerStatus);
if (!peerStatus.met) missing.push(requirement);
} else if (stage.evidencePolicies?.[requirement]) {
throw new ProtocolError(
409,
`stage ${stage.id} evidence policy ${requirement} was not resolved when the run started`,
"workflow_evidence_policy_unresolved",
);
} else if (!evidence[requirement]?.length) {
missing.push(requirement);
}
}
if (missing.length === 0) return peerStatuses;
throw new ProtocolError(
400,
`stage ${stage.id} is missing required evidence: ${missing.join(", ")}`,
"workflow_evidence_incomplete",
{
missingRequirements: missing,
providedRequirements: Object.keys(evidence),
peerRequirements: peerStatuses,
},
);
}
function parseWorkflowEvidencePolicies(
value: unknown,
stageId: string,
requiredEvidence: string[],
workflowId: string,
targetName: string,
warn: (message: string) => void,
): WorkflowEvidencePolicies | undefined {
if (value === undefined) return undefined;
const rawPolicies = object(value, `stage ${stageId} evidencePolicies`);
const policies = new Map<string, WorkflowEvidencePolicy>();
for (const [rawRequirement, rawPolicy] of Object.entries(rawPolicies)) {
const requirementKey = canonicalWorkflowEvidenceKey(
requireString(rawRequirement, `stage ${stageId} evidencePolicies requirement`, { max: 128 }),
);
if (!requiredEvidence.includes(requirementKey)) {
throw new Error(`stage ${stageId} evidence policy ${requirementKey} must match requiredEvidence`);
}
if (policies.has(requirementKey)) {
throw new Error(`stage ${stageId} evidencePolicies keys must be unique after normalization`);
}
const policy = object(rawPolicy, `stage ${stageId} evidencePolicies.${requirementKey}`);
const supportedPolicyFields = new Set([
"kind",
"minProducers",
"eligibleAgents",
"acceptedStatuses",
"degradation",
]);
const unsupportedPolicyFields = Object.keys(policy).filter((field) => !supportedPolicyFields.has(field));
if (unsupportedPolicyFields.length) {
throw new Error(
`stage ${stageId} evidencePolicies.${requirementKey} contains unsupported fields: ${unsupportedPolicyFields.join(", ")}`,
);
}
if (policy.kind !== "peer-reply") {
throw new Error(`stage ${stageId} evidencePolicies.${requirementKey}.kind must be peer-reply`);
}
const minProducers = policy.minProducers;
if (!Number.isInteger(minProducers) || (minProducers as number) < 1 || (minProducers as number) > 8) {
throw new Error(`stage ${stageId} evidencePolicies.${requirementKey}.minProducers must be an integer between 1 and 8`);
}
const eligibleAgents = stringArray(
policy.eligibleAgents,
`stage ${stageId} evidencePolicies.${requirementKey}.eligibleAgents`,
);
if (eligibleAgents.length < 1 || eligibleAgents.length > 16) {
throw new Error(`stage ${stageId} evidencePolicies.${requirementKey}.eligibleAgents must contain between 1 and 16 selectors`);
}
const normalizedSelectors = eligibleAgents.map((selector) => selector.toLowerCase());
if (new Set(normalizedSelectors).size !== normalizedSelectors.length) {
throw new Error(`stage ${stageId} evidencePolicies.${requirementKey}.eligibleAgents must be unique`);
}
// The workflow target can never produce peer evidence for its own run:
// the hub requires evidence messages from the target to a peer that is
// not the target, so a coordinator in its own quorum is structurally
// unable to produce. Reject the configuration instead of shipping a
// stage that can only pass via degradation.
if (normalizedSelectors.includes(targetName)) {
throw new Error(
`workflow ${workflowId} stage ${stageId} evidencePolicies.${requirementKey}.eligibleAgents must not include the workflow target ${targetName}: the target cannot produce peer evidence for its own run`,
);
}
if ((minProducers as number) > normalizedSelectors.length) {
throw new Error(`stage ${stageId} evidencePolicies.${requirementKey}.minProducers exceeds eligibleAgents`);
}
if (policy.acceptedStatuses !== undefined) {
const statuses = stringArray(
policy.acceptedStatuses,
`stage ${stageId} evidencePolicies.${requirementKey}.acceptedStatuses`,
);
if (statuses.length !== 1 || statuses[0] !== "replied") {
throw new Error(`stage ${stageId} evidencePolicies.${requirementKey}.acceptedStatuses must be ["replied"]`);
}
}
let degradation: PeerReplyDegradationPolicy | undefined;
if (policy.degradation !== undefined) {
const rawDegradation = object(
policy.degradation,
`stage ${stageId} evidencePolicies.${requirementKey}.degradation`,
);
const unsupportedDegradationFields = Object.keys(rawDegradation)
.filter((field) => field !== "minProducers");
if (unsupportedDegradationFields.length) {
throw new Error(
`stage ${stageId} evidencePolicies.${requirementKey}.degradation contains unsupported fields: ${unsupportedDegradationFields.join(", ")}`,
);
}
const degradedMin = rawDegradation.minProducers;
if (
!Number.isInteger(degradedMin)
|| (degradedMin as number) < 1
|| (degradedMin as number) >= (minProducers as number)
) {
throw new Error(
`stage ${stageId} evidencePolicies.${requirementKey}.degradation.minProducers must be at least 1 and lower than minProducers`,
);
}
degradation = { minProducers: degradedMin as number };
if (degradation.minProducers < 2) {
// Warning, not a hard failure: a floor of 1 is legal for
// non-independent requirements, but operators should see that one
// producer can satisfy this degraded peer-reply quorum.
warn(
`workflow ${workflowId} stage ${stageId} evidence policy ${requirementKey}: degradation.minProducers is ${degradation.minProducers} (< 2); a single producer can satisfy the degraded peer-reply quorum`,
);
}
}
policies.set(requirementKey, {
kind: "peer-reply",
minProducers: minProducers as number,
eligibleAgents,
acceptedStatuses: ["replied"],
...(degradation ? { degradation } : {}),
});
}
return policies.size ? Object.fromEntries(policies) : undefined;
}
export function parseWorkflowDefinitions(
raw: string | undefined,
environment: Record<string, string | undefined> = process.env,
onWarning?: (warning: string) => void,
): WebhookWorkflowDefinition[] {
if (!raw?.trim()) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) throw new Error("KXM_WEBHOOK_WORKFLOWS must be a JSON array");
const warn = (message: string): void => { onWarning?.(message); };
const ids = new Set<string>();
return parsed.map((entry, definitionIndex) => {
const value = object(entry, `workflow ${definitionIndex}`);
const id = requireString(value.id, "workflow.id", { max: 64 });
if (ids.has(id)) throw new Error(`duplicate workflow id: ${id}`);
ids.add(id);
const source = value.source ?? "generic";
if (source !== "jira" && source !== "github" && source !== "generic") {
throw new Error(`workflow ${id} source must be jira, github, or generic`);
}
const delivery = value.delivery ?? "followUp";
if (delivery !== "steer" && delivery !== "followUp") {
throw new Error(`workflow ${id} delivery must be steer or followUp`);
}
const secretEnv = value.secretEnv === undefined
? undefined
: requireString(value.secretEnv, "workflow.secretEnv", { max: 128 });
if (value.secret !== undefined && secretEnv) {
throw new Error(`workflow ${id} must configure only one of secret or secretEnv`);
}
const secret = requireString(secretEnv ? environment[secretEnv] : value.secret, "workflow.secret", { max: 512 });
if (secret.length < 16) throw new Error(`workflow ${id} secret must contain at least 16 characters`);
const signalSecretEnv = value.signalSecretEnv === undefined
? undefined
: requireString(value.signalSecretEnv, "workflow.signalSecretEnv", { max: 128 });
if (value.signalSecret !== undefined && signalSecretEnv) {
throw new Error(`workflow ${id} must configure only one of signalSecret or signalSecretEnv`);
}
const signalSecret = signalSecretEnv
? requireString(environment[signalSecretEnv], "workflow.signalSecret", { max: 512 })
: value.signalSecret === undefined
? undefined
: requireString(value.signalSecret, "workflow.signalSecret", { max: 512 });
if (signalSecret && signalSecret.length < 16) {
throw new Error(`workflow ${id} signalSecret must contain at least 16 characters`);
}
const target = requireString(value.target, "workflow.target", { max: 80 });
const targetName = target.toLowerCase();
if (!Array.isArray(value.stages) || value.stages.length === 0 || value.stages.length > 32) {
throw new Error(`workflow ${id} must define between 1 and 32 stages`);
}
const stageIds = new Set<string>();
const stages = value.stages.map((stageEntry, stageIndex) => {
const stage = object(stageEntry, `workflow ${id} stage ${stageIndex}`);
const stageId = requireString(stage.id, "stage.id", { max: 64 });
if (stageIds.has(stageId)) throw new Error(`duplicate stage id ${stageId} in workflow ${id}`);
stageIds.add(stageId);
const maxAttempts = stage.maxAttempts ?? 3;
if (!Number.isInteger(maxAttempts) || (maxAttempts as number) < 1 || (maxAttempts as number) > 20) {
throw new Error(`stage ${stageId} maxAttempts must be an integer between 1 and 20`);
}
const area = stage.area
? requireString(stage.area, "stage.area", { max: 24 }) as ImprovementArea
: undefined;
if (area && !IMPROVEMENT_AREAS.includes(area)) {
throw new Error(`stage ${stageId} area is invalid`);
}
const requiredEvidence = stringArray(stage.requiredEvidence ?? [], "stage.requiredEvidence")
.map((requirement, requirementIndex) => canonicalWorkflowEvidenceKey(
requireString(requirement, `stage.requiredEvidence[${requirementIndex}]`, { max: 128 }),
));
if (requiredEvidence.length > 32) throw new Error(`stage ${stageId} may require at most 32 evidence keys`);
if (new Set(requiredEvidence).size !== requiredEvidence.length) {
throw new Error(`stage ${stageId} requiredEvidence keys must be unique`);
}
const evidencePolicies = parseWorkflowEvidencePolicies(
stage.evidencePolicies,
stageId,
requiredEvidence,
id,
targetName,
warn,
);
const on = parseOutcomeMap(stageId, stage.on);
const stageMaxTransitions = stage.maxTransitions;
if (stageMaxTransitions !== undefined
&& (!Number.isInteger(stageMaxTransitions) || (stageMaxTransitions as number) < 1 || (stageMaxTransitions as number) > 100)) {
throw new Error(`stage ${stageId} maxTransitions must be an integer between 1 and 100`);
}
const autoResumeLimit = stage.autoResumeLimit;
if (autoResumeLimit !== undefined
&& (!Number.isInteger(autoResumeLimit) || (autoResumeLimit as number) < 1 || (autoResumeLimit as number) > 20)) {
throw new Error(`stage ${stageId} autoResumeLimit must be an integer between 1 and 20`);
}
return {
id: stageId,
label: requireString(stage.label ?? stageId, "stage.label", { max: 128 }),
instructions: requireString(stage.instructions, "stage.instructions", { max: 4_000 }),
requiredEvidence,
maxAttempts: maxAttempts as number,
...(autoResumeLimit !== undefined ? { autoResumeLimit: autoResumeLimit as number } : {}),
...(area ? { area } : {}),
...(evidencePolicies ? { evidencePolicies } : {}),
...(on ? { on } : {}),
...(stageMaxTransitions !== undefined ? { maxTransitions: stageMaxTransitions as number } : {}),
};
});
const definitionMaxTransitions = value.maxTransitions as number | undefined;
if (definitionMaxTransitions !== undefined) validateWorkflowTransitions({ id, stages: stages as WorkflowStageDefinition[], maxTransitions: definitionMaxTransitions });
else validateWorkflowTransitions({ id, stages: stages as WorkflowStageDefinition[] });
const stageIdSet = new Set(stages.map((stage) => stage.id));
const parseOracleConfig = (raw: unknown, field: string): WorkflowOracleConfig | undefined => {
if (raw === undefined) return undefined;
const candidate = object(raw, `workflow ${id} ${field}`);
const stageId = requireString(candidate.stageId, `workflow ${id} ${field}.stageId`, { max: 64 });
if (!stageIdSet.has(stageId)) {
throw new Error(`workflow ${id} ${field}.stageId references unknown stage ${stageId}`);
}
const evidenceKey = canonicalWorkflowEvidenceKey(requireString(candidate.evidenceKey, `workflow ${id} ${field}.evidenceKey`, { max: 128 }));
return { stageId, evidenceKey };
};
const reproOracle = parseOracleConfig(value.reproOracle, "reproOracle");
const planHash = parseOracleConfig(value.planHash, "planHash");
let requirePlanHash: string[] | undefined;
if (value.requirePlanHash !== undefined) {
const required = stringArray(value.requirePlanHash, `workflow ${id} requirePlanHash`);
for (const stageId of required) {
if (!stageIdSet.has(stageId)) {
throw new Error(`workflow ${id} requirePlanHash references unknown stage ${stageId}`);
}
}
requirePlanHash = [...new Set(required)].sort();
}
let filter: WebhookWorkflowDefinition["filter"];
if (value.filter !== undefined) {
const candidate = object(value.filter, `workflow ${id} filter`);
filter = {
path: requireString(candidate.path, "filter.path", { max: 256 }),
equals: requireString(candidate.equals, "filter.equals", { max: 512 }),
};
}
if (
value.ttlMs !== undefined
&& (!Number.isInteger(value.ttlMs)
|| (value.ttlMs as number) < MIN_MESSAGE_TTL_MS
|| (value.ttlMs as number) > MAX_MESSAGE_TTL_MS)
) {
throw new Error(`workflow ${id} ttlMs must be an integer between ${MIN_MESSAGE_TTL_MS} and ${MAX_MESSAGE_TTL_MS}`);
}
return {
id,
source,
project: requireString(value.project, "workflow.project", { max: 128 }),
target,
secret,
...(signalSecret ? { signalSecret } : {}),
...(value.event ? { event: requireString(value.event, "workflow.event", { max: 128 }) } : {}),
...(filter ? { filter } : {}),
delivery,
...(value.ttlMs !== undefined ? { ttlMs: value.ttlMs as number } : {}),
...(value.maxTransitions !== undefined ? { maxTransitions: value.maxTransitions as number } : {}),
...(reproOracle ? { reproOracle } : {}),
...(planHash ? { planHash } : {}),
...(requirePlanHash ? { requirePlanHash } : {}),
promptTemplate: requireString(value.promptTemplate, "workflow.promptTemplate", { max: 20_000 }),
stages,
};
});
}
/** Validate typed transitions at definition load: targets must exist,
* forward transitions may never skip an intermediate stage (approvals and
* gates cannot be bypassed), and back-edges require global and bounded
* budgets. */
export function validateWorkflowTransitions(definition: Pick<WebhookWorkflowDefinition, "id" | "stages"> & { maxTransitions?: number }): void {
const stageIndex = new Map(definition.stages.map((stage, index) => [stage.id, index]));
let hasBackEdge = false;
for (const stage of definition.stages) {
if (!stage.on) continue;
for (const [outcome, rawValue] of Object.entries(stage.on)) {
if (!outcome.trim()) throw new Error(`stage ${stage.id} declares an empty outcome key`);
const rule = normalizeOutcomeValue(rawValue, `stage ${stage.id} on.${outcome}`);
if (rule.target === WORKFLOW_TERMINAL_TARGET) continue;
const targetIndex = stageIndex.get(rule.target);
if (targetIndex === undefined) {
throw new Error(`stage ${stage.id} on.${outcome} targets unknown stage ${rule.target}`);
}
const sourceIndex = stageIndex.get(stage.id)!;
if (targetIndex > sourceIndex + 1) {
throw new Error(
`stage ${stage.id} on.${outcome} skips intermediate stages by targeting ${rule.target}; forward transitions must target the next stage so approvals and gates cannot be bypassed`,
);
}
if (targetIndex <= sourceIndex) hasBackEdge = true;
}
}
if (definition.maxTransitions !== undefined
&& (!Number.isInteger(definition.maxTransitions) || (definition.maxTransitions as number) < 1 || (definition.maxTransitions as number) > 200)) {
throw new Error(`workflow ${definition.id} maxTransitions must be an integer between 1 and 200`);
}
if (hasBackEdge && (definition.maxTransitions === undefined || definition.maxTransitions < 1)) {
throw new Error(`workflow ${definition.id} declares a back-edge but no maxTransitions budget; cycles without budgets are rejected`);
}
}
function parseOutcomeMap(stageId: string, raw: unknown): WorkflowOutcomeMap | undefined {
if (raw === undefined) return undefined;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new Error(`stage ${stageId} on must be an object`);
}
const map: WorkflowOutcomeMap = {};
for (const [outcome, value] of Object.entries(raw as Record<string, unknown>)) {
map[outcome] = normalizeOutcomeValue(value as WorkflowOutcomeValue, `stage ${stageId} on.${outcome}`);
}
return map;
}
export function valueAtPath(payload: unknown, path: string): unknown {
let current = payload;
for (const part of path.split(".")) {
if (!current || typeof current !== "object" || Array.isArray(current)) return undefined;
current = (current as Record<string, unknown>)[part];
}
return current;
}
export function renderWorkflowPrompt(template: string, payload: unknown): string {
return template.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_match, path: string) => {
const value = valueAtPath(payload, path);
if (value === undefined || value === null) return "";
return typeof value === "object" ? JSON.stringify(value) : String(value);
});
}
function canonicalizeForHash(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonicalizeForHash);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.filter(([, candidate]) => candidate !== undefined)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([key, candidate]) => [key, canonicalizeForHash(candidate)]),
);
}
return value;
}
/** Stable, secret-free JSON serialization of a workflow definition. Object
* keys are sorted recursively and undefined fields are dropped, so hash
* equality is semantic and credential rotation does not create false drift. */
export function canonicalWorkflowDefinitionJson(definition: WebhookWorkflowDefinition): string {
const { secret: _secret, signalSecret: _signalSecret, ...publicDefinition } = definition;
return JSON.stringify(canonicalizeForHash(publicDefinition));
}
/** sha256 of the canonical workflow definition JSON. Stamped on runs at
* creation so a later resume or audit can detect that the definition changed
* underneath the run. */
export function workflowDefinitionHash(definition: WebhookWorkflowDefinition): string {
return createHash("sha256").update(canonicalWorkflowDefinitionJson(definition), "utf8").digest("hex");
}
/** The KXM-owned webhook sender contract: every signal callback, and any
* workflow start that is not a Jira or GitHub provider delivery. The HMAC
* covers a fixed-arity header block and the exact body bytes, so a captured
* request cannot be replayed under another delivery ID, against another run
* or signal key, or outside the timestamp window. */
export const WORKFLOW_WEBHOOK_SIGNATURE_VERSION = "kxm-webhook-v1";
export const WORKFLOW_WEBHOOK_MAX_SKEW_SECONDS = 300;
/** What a signature is bound to. A start names only the definition; a signal
* callback also names the run and signal key from its route. */
export type WorkflowWebhookScope =
| { definitionId: string; runId?: undefined; signalKey?: undefined }
| { definitionId: string; runId: string; signalKey: string };
/** The exact bytes a KXM webhook signature covers: seven newline-terminated
* fields (version, kind, timestamp, delivery ID, definition ID, run ID, signal
* key; the last two empty for a start) followed by the raw body. No field may
* contain a line break, so the encoding is unambiguous. */
export function workflowWebhookSignedMaterial(
scope: WorkflowWebhookScope,
timestamp: string,
deliveryId: string,
body: string | Uint8Array,
): Buffer {
const fields = [
WORKFLOW_WEBHOOK_SIGNATURE_VERSION,
scope.runId === undefined ? "start" : "signal",
timestamp,
deliveryId,
scope.definitionId,
scope.runId ?? "",
scope.signalKey ?? "",
];
if (fields.some((field) => /[\r\n]/.test(field))) {
throw new ProtocolError(400, "webhook signature fields must not contain line breaks", "webhook_signature_field_invalid");
}
return Buffer.concat([
Buffer.from(`${fields.join("\n")}\n`, "utf8"),
typeof body === "string" ? Buffer.from(body, "utf8") : Buffer.from(body),
]);
}
export function workflowWebhookSignature(
secret: string,
scope: WorkflowWebhookScope,
timestamp: string,
deliveryId: string,
body: string | Uint8Array,
): string {
return `sha256=${createHmac("sha256", secret).update(workflowWebhookSignedMaterial(scope, timestamp, deliveryId, body)).digest("hex")}`;
}
/** Headers for one KXM webhook send. Sign at send time: a transport retry
* re-signs with a fresh timestamp and keeps the same delivery ID and body. */
export function workflowWebhookHeaders(input: {
secret: string;
scope: WorkflowWebhookScope;
deliveryId: string;
body: string;
nowMs?: number;
}): Record<string, string> {
const timestamp = String(Math.floor((input.nowMs ?? Date.now()) / 1_000));
return {
"x-kxm-delivery-id": input.deliveryId,
"x-kxm-timestamp": timestamp,
"x-kxm-signature": workflowWebhookSignature(input.secret, input.scope, timestamp, input.deliveryId, input.body),
};
}
/** Resolve a declared transition rule for a stage outcome, or undefined when
* the outcome keeps the v0.4 default edges. */
function resolveOutcomeRule(stage: WorkflowStageState, outcomeKey: string): WorkflowTransitionRule | undefined {
const raw = stage.on?.[outcomeKey];
return raw === undefined ? undefined : normalizeOutcomeValue(raw, `stage ${stage.id} on.${outcomeKey}`);
}
/** Transition budgets are derived from the durable journal: global count is
* the record count, per-stage counts filter by source stage, per-edge counts
* filter by source+outcome+target. Restart-safe with no extra state. */
function transitionCounts(
run: WorkflowRun,
fromStage: string,
outcome: string,
target: string,
): { total: number; fromStage: number; forEdge: number } {
const records = run.transitions ?? [];
return {
total: records.length,
fromStage: records.filter((record) => record.fromStage === fromStage).length,
forEdge: records.filter((record) => record.fromStage === fromStage && record.outcome === outcome && record.toStage === target).length,
};
}
function recordTransition(
run: WorkflowRun,
fromStage: string,
rule: WorkflowTransitionRule,
outcome: string,
attempt: number,
evidenceKeys: string[],
timestamp: string,
): WorkflowTransitionRecord {
const record: WorkflowTransitionRecord = {
id: newId("trans"),
fromStage,
toStage: rule.target,
outcome,
attempt,
evidenceKeys,
at: timestamp,
};
run.transitions = [...(run.transitions ?? []), record];
return record;
}
/** Enter (or re-enter) a stage with attempt-bound state: previous evidence
* never satisfies a later attempt unless policy explicitly allows it. */
function enterStage(run: WorkflowRun, stage: WorkflowStageState, timestamp: string): void {
stage.status = "in_progress";
stage.attempts = 0;
stage.evidence = {};
stage.verifiedEvidence = {};
stage.startedAt = timestamp;
stage.updatedAt = timestamp;
delete stage.summary;
run.currentStage = stage.id;
run.updatedAt = timestamp;
}
/** Execute a declared transition with global/per-stage/per-edge budgets.
* Budget exhaustion fails safely: the run fails with actionable retrospective
* evidence rather than looping unbounded. */
function takeDeclaredTransition(
run: WorkflowRun,
stage: WorkflowStageState,
rule: WorkflowTransitionRule,
outcome: string,
summary: string,
attempt: number,
timestamp: string,
evidenceKeys: string[],
): { retry: boolean; completed: boolean; run: WorkflowRun; transition?: WorkflowTransitionRecord; exhausted?: boolean } {
const definitionBudget = run.maxTransitions;
const counts = transitionCounts(run, stage.id, outcome, rule.target);
const edgeExhausted = rule.maxTransitions !== undefined && counts.forEdge >= rule.maxTransitions;
const stageExhausted = stage.maxTransitions !== undefined && counts.fromStage >= stage.maxTransitions;
const globalExhausted = definitionBudget !== undefined && counts.total >= definitionBudget;
if (edgeExhausted || stageExhausted || globalExhausted) {
stage.completedAt = timestamp;
run.status = "failed";
delete run.currentStage;
run.updatedAt = timestamp;
return {
retry: false,
completed: false,
run,
exhausted: true,
};
}
const record = recordTransition(run, stage.id, rule, outcome, attempt, evidenceKeys.slice(0, 32), timestamp);
if (rule.target === WORKFLOW_TERMINAL_TARGET) {
stage.completedAt = timestamp;
run.status = "completed";
delete run.currentStage;
run.completedAt = timestamp;
run.updatedAt = timestamp;
return { retry: false, completed: true, run, transition: record };
}
const target = run.stages.find((candidate) => candidate.id === rule.target);
if (!target) {
// Definition validation prevents this; fail closed anyway.
run.status = "failed";
delete run.currentStage;
return { retry: false, completed: false, run, exhausted: true };
}
stage.summary = summary;
enterStage(run, target, timestamp);
return { retry: false, completed: false, run, transition: record };
}
/** sha256 over the canonical values of one evidence key. */
export function evidenceValueSha256(evidence: WorkflowEvidence | WorkflowEvidenceInput, key: string): string | undefined {
const values = evidence[canonicalWorkflowEvidenceKey(key)];
if (!values || values.length === 0) return undefined;
return createHash("sha256").update([...values].sort().join("\n"), "utf8").digest("hex");
}
/** Enforce the immutable reproduction oracle and plan-hash prerequisites.
* Called before a checkpoint mutates the run. */
function enforceOracles(
run: WorkflowRun,
stageId: string,
evidence: WorkflowEvidence,
): void {
if (run.oracle) {
const presented = evidenceValueSha256(evidence, run.oracle.evidenceKey);
if (presented !== undefined && presented !== run.oracle.sha256) {
throw new ProtocolError(
400,
`evidence ${run.oracle.evidenceKey} does not match the immutable reproduction oracle captured at ${run.oracle.capturedAt}; the confirmed reproduction may not be weakened`,
"weakened_reproduction",
);
}
}
if (run.requirePlanHash?.includes(stageId) && !run.planHash) {
throw new ProtocolError(
400,
`stage ${stageId} requires an approved plan hash before it can checkpoint`,
"plan_hash_required",
);
}
}
/** Capture oracle/plan-hash snapshots when their defining stage passes. */
function captureOracles(run: WorkflowRun, stageId: string, evidence: WorkflowEvidence, timestamp: string): void {
if (run.reproOracle?.stageId === stageId) {
const sha256 = evidenceValueSha256(evidence, run.reproOracle.evidenceKey);
if (sha256 !== undefined) {
run.oracle = { evidenceKey: run.reproOracle.evidenceKey, sha256, capturedAt: timestamp };
}
}
if (run.planHashConfig?.stageId === stageId) {
const sha256 = evidenceValueSha256(evidence, run.planHashConfig.evidenceKey);
if (sha256 !== undefined) {
run.planHash = { evidenceKey: run.planHashConfig.evidenceKey, sha256, capturedAt: timestamp };
}
}
}
export function checkpointRun(
run: WorkflowRun,
stageId: string,
status: WorkflowCheckpointStatus,
summary: string,
evidence: WorkflowEvidenceInput,
timestamp: string,
verifiedEvidence: WorkflowVerifiedEvidence = {},
outcome?: string,
): { retry: boolean; completed: boolean; run: WorkflowRun; degraded?: boolean; transition?: WorkflowTransitionRecord; exhausted?: boolean } {
if (run.status !== "running") throw new ProtocolError(409, `workflow is ${run.status}`, "workflow_terminal");
const stage = run.stages.find((candidate) => candidate.id === stageId);
if (!stage) throw new ProtocolError(404, `workflow stage not found: ${stageId}`, "workflow_stage_not_found");
if (stage.id !== run.currentStage || stage.status !== "in_progress") {
throw new ProtocolError(409, `stage ${stageId} is not currently active`, "workflow_stage_out_of_order");
}
const accumulatedEvidence = mergeWorkflowEvidence(stage.evidence, evidence);
const accumulatedVerifiedEvidence = mergeVerifiedWorkflowEvidence(stage.verifiedEvidence, verifiedEvidence);
enforceOracles(run, stageId, accumulatedEvidence);
const peerStatuses = status === "passed"
? requireCompleteEvidence(stage, accumulatedEvidence, accumulatedVerifiedEvidence, run.id)
: [];
const degradedRequirements = peerStatuses.filter((peerStatus) => peerStatus.degraded);
stage.attempts += 1;
stage.summary = summary;
// Failed or warning evidence remains available in the journal, but it is
// intentionally not trusted to satisfy a later passing attempt.
if (status === "passed") {
stage.evidence = accumulatedEvidence;
if (Object.keys(accumulatedVerifiedEvidence).length) stage.verifiedEvidence = accumulatedVerifiedEvidence;
captureOracles(run, stageId, accumulatedEvidence, timestamp);
if (degradedRequirements.length) {
stage.degraded = true;
stage.degradedRequirements = degradedRequirements.map((peerStatus) => peerStatus.requirementKey);
}
}
stage.updatedAt = timestamp;
run.updatedAt = timestamp;
if (status !== "passed") {
stage.status = status;
if (stage.attempts >= stage.maxAttempts) {
stage.completedAt = timestamp;
run.status = "failed";
delete run.currentStage;
return { retry: false, completed: false, run };
}
if (stage.autoResumeLimit !== undefined && stage.attempts >= stage.autoResumeLimit) {
stage.status = "in_progress";
const reason = summary || `autoResumeLimit of ${stage.autoResumeLimit} reached on stage ${stage.id}`;
const receipt = validateTerminalReceipt({
schema: TERMINAL_RECEIPT_SCHEMA,
status: "audit_escalation",
seat: stage.id,
runId: run.id,
stageId: stage.id,
timestamp,
host: "pi",
model: "default",
escalationReason: reason,
});
const expiresAt = new Date(Date.parse(timestamp) + 24 * 60 * 60 * 1000).toISOString();
waitForWorkflowSignal(run, stage.id, "audit_escalation", reason, timestamp, expiresAt);
stage.receipt = receipt;
stage.auditEscalation = {
reason,
timestamp,
receipt,
};
return { retry: false, completed: false, run };
}
stage.status = status;
// Declared failure outcomes create typed transitions (bounded); the
// outcome identity defaults to the checkpoint status.
const outcomeKey = outcome ?? status;
const rule = resolveOutcomeRule(stage, outcomeKey);
if (rule && stage.attempts < stage.maxAttempts) {
const result = takeDeclaredTransition(run, stage, rule, outcomeKey, summary, stage.attempts, timestamp, Object.keys(accumulatedEvidence));
// Re-arm the source stage for future re-entry (the durable journal
// already captured the failed attempt). Self-edges stay in_progress.
if (result.transition !== undefined && !result.completed && rule.target !== stage.id) {
stage.status = "pending";
}
return result;
}
stage.status = "in_progress";
return { retry: true, completed: false, run };
}
stage.status = "passed";
stage.completedAt = timestamp;
const passedRule = resolveOutcomeRule(stage, outcome ?? "passed");
if (passedRule) {
return takeDeclaredTransition(run, stage, passedRule, outcome ?? "passed", summary, stage.attempts, timestamp, Object.keys(accumulatedEvidence));
}
const next = run.stages.find((candidate) => candidate.status === "pending");
if (next) {
next.status = "in_progress";
next.startedAt = timestamp;
next.updatedAt = timestamp;
run.currentStage = next.id;
return {
retry: false,
completed: false,
run,
...(degradedRequirements.length ? { degraded: true } : {}),
};
}
run.status = "completed";
delete run.currentStage;
run.completedAt = timestamp;
return {
retry: false,
completed: true,
run,
...(degradedRequirements.length ? { degraded: true } : {}),
};
}
export function waitForWorkflowSignal(
run: WorkflowRun,
stageId: string,
signalKey: string,
summary: string,
timestamp: string,
expiresAt: string,
evidence: WorkflowEvidenceInput = {},
verifiedEvidence: WorkflowVerifiedEvidence = {},
): WorkflowRun {
if (run.status !== "running") throw new ProtocolError(409, `workflow is ${run.status}`, "workflow_not_running");
const stage = run.stages.find((candidate) => candidate.id === stageId);
if (!stage) throw new ProtocolError(404, `workflow stage not found: ${stageId}`, "workflow_stage_not_found");
if (stage.id !== run.currentStage || stage.status !== "in_progress") {
throw new ProtocolError(409, `stage ${stageId} is not currently active`, "workflow_stage_out_of_order");
}
if (Date.parse(expiresAt) <= Date.parse(timestamp)) {
throw new ProtocolError(400, "workflow signal expiry must be in the future", "workflow_wait_invalid");
}
stage.evidence = mergeWorkflowEvidence(stage.evidence, evidence);
const accumulatedVerifiedEvidence = mergeVerifiedWorkflowEvidence(stage.verifiedEvidence, verifiedEvidence);
if (Object.keys(accumulatedVerifiedEvidence).length) stage.verifiedEvidence = accumulatedVerifiedEvidence;
stage.status = "waiting";
stage.updatedAt = timestamp;
run.status = "waiting";
run.waiting = { stageId, signalKey, summary, createdAt: timestamp, expiresAt };
run.updatedAt = timestamp;
return run;
}
export function resumeWorkflowFromSignal(
run: WorkflowRun,
signalKey: string,
status: WorkflowCheckpointStatus,
summary: string,
evidence: WorkflowEvidenceInput,
timestamp: string,
): { retry: boolean; completed: boolean; run: WorkflowRun; stageId: string; degraded?: boolean } {
if (run.status !== "waiting" || !run.waiting) {
throw new ProtocolError(409, `workflow is ${run.status}`, "workflow_not_waiting");
}
if (run.waiting.signalKey !== signalKey) {
throw new ProtocolError(409, `workflow is waiting for ${run.waiting.signalKey}`, "workflow_signal_mismatch");
}
const stageId = run.waiting.stageId;
const stage = run.stages.find((candidate) => candidate.id === stageId);
if (!stage || stage.id !== run.currentStage || stage.status !== "waiting") {
throw new ProtocolError(409, "workflow wait state is inconsistent", "workflow_wait_inconsistent");
}
const accumulatedEvidence = mergeWorkflowEvidence(stage.evidence, evidence);
if (status === "passed") {
requireCompleteEvidence(
stage,
accumulatedEvidence,
normalizeVerifiedWorkflowEvidence(stage.verifiedEvidence),
run.id,
);
}
run.status = "running";
stage.status = "in_progress";
delete run.waiting;
const result = checkpointRun(run, stageId, status, summary, evidence, timestamp);
return { ...result, stageId };
}
export function approveWorkflowDegradation(
run: WorkflowRun,
stageId: string,
requirement: string,
reason: string,
approvalId: string,
timestamp: string,
): { run: WorkflowRun; approval: WorkflowDegradationApproval; created: boolean } {
if (run.status !== "running" && run.status !== "waiting") {
throw new ProtocolError(409, `workflow is ${run.status}`, "workflow_terminal");
}
const stage = run.stages.find((candidate) => candidate.id === stageId);
if (!stage) throw new ProtocolError(404, `workflow stage not found: ${stageId}`, "workflow_stage_not_found");
if (
stage.id !== run.currentStage
|| (stage.status !== "in_progress" && stage.status !== "waiting")
) {
throw new ProtocolError(409, `stage ${stageId} is not currently active`, "workflow_stage_out_of_order");
}
const requirementKey = canonicalWorkflowEvidenceKey(requirement);
const policy = stage.resolvedEvidencePolicies?.[requirementKey];
if (!policy?.degradation) {
throw new ProtocolError(
400,
`requirement ${requirementKey} does not permit degraded quorum`,
"workflow_degradation_forbidden",
);
}
const attempt = activeWorkflowAttempt(stage);
const existing = stage.degradationApprovals?.find(
(candidate) => candidate.requirementKey === requirementKey && candidate.attempt === attempt,
);
if (existing) {
if (existing.reason !== reason.trim()) {
throw new ProtocolError(
409,
`degradation was already approved for ${requirementKey} attempt ${attempt}`,
"workflow_degradation_conflict",
);
}
return { run, approval: existing, created: false };
}
const approval: WorkflowDegradationApproval = {
schema: "pi-mesh.workflow-degradation-approval.v1",
id: approvalId,
requirementKey,
attempt,
policyMinProducers: policy.minProducers,
approvedMinProducers: policy.degradation.minProducers,
approvedBy: "kxm-admin",
reason: requireString(reason, "reason", { max: 1_000 }),
approvedAt: timestamp,
};
(stage.degradationApprovals ??= []).push(approval);
stage.updatedAt = timestamp;
run.updatedAt = timestamp;
return { run, approval, created: true };
}
export function escalateWorkflowStage(
run: WorkflowRun,
stageId: string,
reason: string,
timestamp: string,
options: {
seat?: string;
host?: string;
model?: string;
evidence?: TerminalReceiptEvidence;
metrics?: TerminalReceiptMetrics;
} = {},
): { run: WorkflowRun; receipt: TerminalReceipt } {
if (run.status !== "running") throw new ProtocolError(409, `workflow is ${run.status}`, "workflow_terminal");
const stage = run.stages.find((s) => s.id === stageId);
if (!stage) throw new ProtocolError(404, `workflow stage not found: ${stageId}`, "workflow_stage_not_found");
if (stage.id !== run.currentStage || stage.status !== "in_progress") {
throw new ProtocolError(409, `stage ${stageId} is not currently active`, "workflow_stage_out_of_order");
}
const receipt: TerminalReceipt = validateTerminalReceipt({
schema: TERMINAL_RECEIPT_SCHEMA,
status: "audit_escalation",
seat: options.seat ?? stage.id,
runId: run.id,
stageId,
timestamp,
host: options.host ?? "pi",
model: options.model ?? "default",
escalationReason: reason,
...(options.evidence ? { evidence: options.evidence } : {}),
...(options.metrics ? { metrics: options.metrics } : {}),
});
const expiresAt = new Date(Date.parse(timestamp) + 24 * 60 * 60 * 1000).toISOString();
waitForWorkflowSignal(run, stageId, "audit_escalation", reason, timestamp, expiresAt);
stage.receipt = receipt;
stage.auditEscalation = {
reason,
timestamp,
receipt,
};
return { run, receipt };
}
export function resumeWorkflowFromRuling(
run: WorkflowRun,
ruling: string,
timestamp: string,
options: {
status?: WorkflowCheckpointStatus;
evidence?: WorkflowEvidenceInput;
} = {},
): { retry: boolean; completed: boolean; run: WorkflowRun; stageId: string } {
if (run.status !== "waiting" || !run.waiting) {
throw new ProtocolError(409, `workflow is ${run.status}`, "workflow_not_waiting");
}
if (run.waiting.signalKey !== "audit_escalation") {
throw new ProtocolError(
409,
`workflow is waiting for signal '${run.waiting.signalKey}', not 'audit_escalation'`,
"workflow_signal_mismatch",
);
}
const stageId = run.waiting.stageId;
const stage = run.stages.find((s) => s.id === stageId);
if (!stage) throw new ProtocolError(404, `workflow stage not found: ${stageId}`, "workflow_stage_not_found");
const status = options.status ?? "passed";
const evidence = options.evidence ?? {};
if (stage.auditEscalation) {
stage.auditEscalation.ruling = ruling;
if (stage.auditEscalation.receipt) {
stage.auditEscalation.receipt.ruling = ruling;
}
}
stage.summary = `Resumed by operator ruling: ${ruling}`;
return resumeWorkflowFromSignal(
run,
"audit_escalation",
status,
`Resumed: ${ruling}`,
evidence,
timestamp,
);
}