import { z, type ZodTypeAny } from 'zod' import { AGENT_ICON_NAMES } from './agentIcons' import { AGENT_TAG_MAX_LENGTH, AGENT_TAGS_MAX_COUNT } from './agentTags' /** * A single proposed action emitted by a proposal agent. `payload` is shaped * per-agent via the agent's `result.schema`; the generic form keeps it open. */ export const proposedActionSchema = z.object({ type: z.string().min(1), payload: z.record(z.string(), z.unknown()), /** * How much damage this action does if it is wrong — one of the inputs the * auto-approval policy weighs alongside confidence. * * DECLARED by the agent, never inferred from `type`: guessing "delete" is * high-risk from a substring gets the interesting cases exactly backwards — a * `notify` that emails ten thousand customers is not low risk, and a * `deleteDraft` is not high. Absent means `medium`, the conservative reading of * "nobody said". */ risk: z.enum(['low', 'medium', 'high']).optional(), }) export type ProposedAction = z.infer /** * Storage bounds on the model-authored parts of the envelope. `agent_proposals.payload` * is an ENCRYPTED jsonb column and every option is rendered in the Caseload, so array * size and free-text length are a crypto cost and a render cost, not just a schema nicety. */ export const PROPOSAL_OPTIONS_MAX = 10 export const PROPOSAL_OPTION_ID_MAX = 100 export const PROPOSAL_OPTION_LABEL_MAX = 120 export const PROPOSAL_RATIONALE_MAX = 2000 /** * One mutually-exclusive alternative within a proposal. `actions` is the plan that * runs if this option is the one chosen — a conjunction inside a disjunction — so it * is `.min(1)`: "I have nothing to propose" is an EMPTY option set, never an option * carrying an empty plan. * * `id` is stable within the proposal (the disposition names it, audit records it, an * eval asserts against it); a positional index would silently re-point if the agent * reordered its options. */ export const proposalOptionSchema = z.object({ id: z.string().min(1).max(PROPOSAL_OPTION_ID_MAX), label: z.string().min(1).max(PROPOSAL_OPTION_LABEL_MAX), rationale: z.string().max(PROPOSAL_RATIONALE_MAX).optional(), confidence: z.number().min(0).max(1).optional(), actions: z.array(proposedActionSchema).min(1), }) export type ProposalOption = z.infer /** * The proposal envelope carried by a proposal AgentResult: N ranked options, of * which the disposition selects AT MOST one. `options` may legally be empty — "I * considered this and have nothing to propose" is a real answer, terminated by the * `none_proposed` disposition rather than queued for a human. * * Envelope `rationale` explains the option SET; why one option was ranked where it * was lives on the option itself. */ export const agentProposalSchema = z.object({ options: z.array(proposalOptionSchema).max(PROPOSAL_OPTIONS_MAX), rationale: z.string().max(PROPOSAL_RATIONALE_MAX).optional(), }) export type AgentProposalPayload = z.infer /** * One file an artifact-producing agent made. It references a captured * `agent_run_artifacts` row rather than carrying bytes: the file plane already * stores, hashes and encrypts them, and a second copy in the run output would be * an unencrypted one. */ export const agentArtifactRefSchema = z.object({ /** FK id → agent_run_artifacts; absent when capture is still in flight. */ artifactId: z.string().uuid().nullable().optional(), fileName: z.string().min(1).max(300), mimeType: z.string().min(1).max(150).nullable().optional(), /** The agent's own words about the file. */ caption: z.string().max(2000).nullable().optional(), }) export type AgentArtifactRef = z.infer /** * What an agent PRODUCED, as opposed to what it found or what it proposes. * * A drafted email, a report, a generated document: it mutates nothing, so it is * terminal like a research result rather than a decision anyone must dispose. * The third kind exists because the other two describe it badly — folding a * produced document into `data` loses the file plane, and folding it into a * proposal invents a decision nobody was asked to make. */ export const agentArtifactResultSchema = z.object({ artifacts: z.array(agentArtifactRefSchema).min(1).max(20), summary: z.string().max(PROPOSAL_RATIONALE_MAX).optional(), }) export type AgentArtifactPayload = z.infer /** * The AgentResult union (the return contract). Generic helper so callers can * narrow `data`/`proposal` against their own agent `result.schema`. */ export function agentResultSchema(dataSchema: ZodTypeAny = z.unknown()) { return z.discriminatedUnion('kind', [ z.object({ kind: z.literal('research'), data: dataSchema }), z.object({ kind: z.literal('proposal'), proposal: agentProposalSchema }), z.object({ kind: z.literal('artifact'), ...agentArtifactResultSchema.shape }), ]) } export const baseAgentResultSchema = agentResultSchema() export type AgentResult = | { kind: 'research'; data: T } | { kind: 'proposal'; proposal: AgentProposalPayload } | ({ kind: 'artifact' } & AgentArtifactPayload) /** * What an agent is FOR. An AUTHORING fact declared on the agent definition — * distinct from `resultKind`, which is the RUNTIME fact of what came back. The two * can disagree (a `decision_maker` that found nothing returns a `research`-shaped * result); that is a finding, not a crash. * * The vocabularies deliberately do NOT share a word: the authoring type is * `researcher` and the runtime kind is `research`, so a reader can never mistake * one for the other (unification spec §7). * * The type is not structural: `decision_maker` and `action` return the SAME proposal * envelope and differ only in the action vocabulary they are narrowed to. What it * buys is a listable, filterable, assertable property an agent has before it has run. */ export const agentTypeSchema = z.enum(['researcher', 'decision_maker', 'action']) export type AgentType = z.infer /** * Trace-list filter facets (trace-eval overlay). `needs-review` is the union * facet the traces list tabs use: failed eval OR low confidence. */ export const runFilterFacet = z.enum(['overridden', 'low-confidence', 'eval-fail', 'needs-review']) export type RunFilterFacet = z.infer /** Relative time windows for trace/metrics queries. */ export const runWindow = z.enum(['24h', '7d', '30d', '90d']) export type RunWindow = z.infer /** * Case-insensitive run-id prefix search (engineers paste id prefixes from * logs). Accepts hex with optional dashes; normalization strips the dashes. * Minimum 4 hex chars keeps the range selective. */ export const runIdPrefixSchema = z .string() .trim() .regex(/^[0-9a-fA-F-]{4,36}$/) /** Strips dashes + lowercases; null when the result is not 4–32 hex chars. */ export function normalizeUuidPrefix(raw: string): string | null { const hex = raw.trim().toLowerCase().replace(/-/g, '') if (!/^[0-9a-f]{4,32}$/.test(hex)) return null return hex } /** * Translates a normalized hex prefix into inclusive uuid bounds * (`9f3c` → `9f3c0000-…-000000` / `9f3cffff-…-ffffff`). Postgres orders uuids * bytewise — identical to hex-string order — so a `$gte`/`$lte` pair is exact * prefix semantics without the uuid-vs-`ilike` cast problem. */ export function uuidPrefixRange(raw: string): { from: string; to: string } | null { const hex = normalizeUuidPrefix(raw) if (!hex) return null const dashed = (filled: string) => `${filled.slice(0, 8)}-${filled.slice(8, 12)}-${filled.slice(12, 16)}-${filled.slice(16, 20)}-${filled.slice(20, 32)}` return { from: dashed(hex.padEnd(32, '0')), to: dashed(hex.padEnd(32, 'f')), } } /** The run-list spelling of the same prefix normalization (`GET /runs?idPrefix=`). */ export const normalizeRunIdPrefix = normalizeUuidPrefix /** The run-list spelling of the same uuid range (`GET /runs?idPrefix=`). */ export const runIdPrefixRange = uuidPrefixRange /** Query schema for GET /runs (list + ?id= detail). */ export const runListQuerySchema = z .object({ page: z.coerce.number().min(1).default(1), pageSize: z.coerce.number().min(1).max(100).default(50), id: z.string().uuid().optional(), /** Run-id prefix search (see `runIdPrefixRange`); ignored when `id` is set. */ idPrefix: runIdPrefixSchema.optional(), agentId: z.string().optional(), status: z.enum(['running', 'ok', 'error', 'cancelled']).optional(), resultKind: z.enum(['research', 'proposal', 'artifact']).optional(), /** The agent's DECLARED type (`agent_runs.agent_type`); runs without one never match. */ agentType: agentTypeSchema.optional(), /** Only runs carrying the operator triage flag (`flagged_at` set). */ flagged: z.coerce.boolean().optional(), // Trace facets + window (trace-eval overlay). filter: runFilterFacet.optional(), window: runWindow.optional(), sortField: z.string().optional(), sortDir: z.enum(['asc', 'desc']).optional(), }) .passthrough() export type RunListQuery = z.infer /** Body schema for POST /agents/:id/run (playground). */ export const agentRunRequestSchema = z.object({ input: z.unknown(), }) export type AgentRunRequest = z.infer /** The dispositions an operator may set through the dispose endpoint (area 03). */ export type ProposalDisposition = 'approved' | 'edited' | 'rejected' /** * Body schema for POST /proposals/:id/dispose. The endpoint only ever serves the * human verdicts — `pending`/`auto_approved` are internal-only and never accepted * over the wire. `edited` overrides the proposal payload (requires reason); * `rejected` requires a reason. */ export const disposeProposalSchema = z .object({ disposition: z.enum(['approved', 'edited', 'rejected']), payload: z.record(z.string(), z.unknown()).optional(), reason: z.string().min(1).optional(), /** * Which option the operator picked. Required for `approved` and `edited` — * editing means choosing an option AND changing its payload, so there is * nothing to edit without naming which. Forbidden for `rejected`: no option * runs, so naming one would record a choice that was never made. */ selectedOptionId: z.string().min(1).max(PROPOSAL_OPTION_ID_MAX).optional(), }) .superRefine((value, ctx) => { if ((value.disposition === 'edited' || value.disposition === 'rejected') && !value.reason) { ctx.addIssue({ code: 'custom', path: ['reason'], message: '[internal] reason required for edit/reject' }) } if (value.disposition === 'edited' && !value.payload) { ctx.addIssue({ code: 'custom', path: ['payload'], message: '[internal] payload required for edit' }) } const needsOption = value.disposition === 'approved' || value.disposition === 'edited' if (needsOption && !value.selectedOptionId) { ctx.addIssue({ code: 'custom', path: ['selectedOptionId'], message: '[internal] selectedOptionId required for approve/edit', }) } if (!needsOption && value.selectedOptionId) { ctx.addIssue({ code: 'custom', path: ['selectedOptionId'], message: '[internal] selectedOptionId forbidden for reject', }) } }) export type DisposeProposalInput = z.infer /** * `none_proposed` is a STORED disposition and never operator-settable: it is written * at creation for a proposal whose option set is empty, so the `WAIT_FOR_SIGNAL` step * terminates instead of parking on a decision nobody can make. */ export const proposalDispositionValues = [ 'pending', 'auto_approved', 'approved', 'edited', 'rejected', 'none_proposed', ] as const export type ProposalDispositionValue = (typeof proposalDispositionValues)[number] /** * Why an auto-approval that cleared its threshold was still held for a human. * Its own column rather than `disposition_reason`, which is `text` and holds the * OPERATOR's reason — writing a machine reason there corrupts the override signal * the correction flywheel and evals read. */ export const autoDispositionBlockValues = [ 'near_tie', 'risk', 'guardrail', 'trace_incomplete', 'policy', ] as const export const autoDispositionBlockSchema = z.enum(autoDispositionBlockValues) export type AutoDispositionBlock = z.infer /** * `disposition` accepts one value or a comma-separated list (e.g. * `approved,auto_approved,edited` for the Caseload "Approved" tab) — additive * on the original single-enum contract. */ const proposalDispositionFilter = z .string() .refine( (value) => value .split(',') .every((token) => (proposalDispositionValues as readonly string[]).includes(token)), { message: '[internal] invalid disposition filter' }, ) /** Query schema for GET /proposals (list + ?id= detail). */ export const proposalListQuerySchema = z .object({ page: z.coerce.number().min(1).default(1), pageSize: z.coerce.number().min(1).max(100).default(50), id: z.string().uuid().optional(), agentId: z.string().optional(), workflowInstanceId: z.string().uuid().optional(), disposition: proposalDispositionFilter.optional(), sortField: z.string().optional(), sortDir: z.enum(['asc', 'desc']).optional(), }) .passthrough() export type ProposalListQuery = z.infer // ── Sample / reference result schema ────────────────────────────────────── // The real demo agent ships in area 05; this proposal result schema is the // single source for the example `deals.health_check` agent referenced by the // area-01 SDK doc and the throwaway smoke-test `ai-agents.ts`. // Tightened so object-mode generation always yields a usable proposal: a // REQUIRED confidence (drives the disposition threshold — a missing one would // fail-closed and always park) and a typed `set_stage` action with a non-empty // stage (the effector reads the chosen option's `actions[0].payload.stage`). With these // required, `generateObject` constrains the model to fill them. export const dealHealthCheckResult = z.object({ kind: z.literal('proposal'), proposal: z.object({ actions: z .array( z.object({ type: z.literal('set_stage'), payload: z.object({ stage: z.string().min(1) }), }), ) .min(1), confidence: z.number().min(0).max(1), rationale: z.string().min(1), }), }) export type DealHealthCheckResult = z.infer // ── Trace ingestion (trace-eval overlay) ─────────────────────────────────── // The normalized trace a runtime adapter POSTs to /trace/ingest. tenantId and // organizationId are NEVER taken from the body — they are derived server-side // from the authenticated/HMAC principal so a caller cannot ingest cross-tenant. // Idempotency key is (runtime, externalRunId). Large payloads (input/output and // per-tool request/response) are offloaded to storage-s3 by the service; only // redacted summaries stay on the row. /** A single tool invocation within a span. `*Payload` are full payloads the service offloads. */ export const traceToolCallIngestSchema = z.object({ toolName: z.string().min(1), requestSummary: z.unknown().optional(), responseSummary: z.unknown().optional(), requestPayload: z.unknown().optional(), responsePayload: z.unknown().optional(), status: z.enum(['ok', 'error']).default('ok'), latencyMs: z.number().int().nonnegative().optional(), errorMessage: z.string().optional(), }) export type TraceToolCallIngest = z.infer /** One execution-trace span. `externalSpanId` links children regardless of arrival order. */ export const traceSpanIngestSchema = z.object({ externalSpanId: z.string().min(1), parentExternalSpanId: z.string().nullable().optional(), sequence: z.number().int().nonnegative(), name: z.string().min(1), kind: z.enum(['llm', 'tool', 'system']), startedAt: z.string().datetime(), endedAt: z.string().datetime().nullable().optional(), durationMs: z.number().int().nonnegative().nullable().optional(), status: z.enum(['ok', 'error']).default('ok'), attributes: z.unknown().optional(), toolCalls: z.array(traceToolCallIngestSchema).optional(), }) export type TraceSpanIngest = z.infer /** The run envelope POSTed to /trace/ingest. */ export const traceIngestSchema = z.object({ runtime: z.string().min(1), externalRunId: z.string().min(1), agentId: z.string().min(1), agentVersion: z.string().optional(), model: z.string().optional(), status: z.enum(['running', 'ok', 'error', 'cancelled']).optional(), workflowInstanceId: z.string().uuid().nullable().optional(), stepId: z.string().nullable().optional(), proposalId: z.string().uuid().nullable().optional(), confidence: z.number().optional(), inputTokens: z.number().int().nonnegative().optional(), outputTokens: z.number().int().nonnegative().optional(), costMinor: z.number().int().nonnegative().optional(), currency: z.string().length(3).optional(), latencyMs: z.number().int().nonnegative().optional(), input: z.unknown().optional(), output: z.unknown().optional(), outputSummary: z.unknown().optional(), contextRouting: z.unknown().optional(), spans: z.array(traceSpanIngestSchema).optional(), }) export type TraceIngest = z.infer /** Shape returned by GET /runs/:id — the full run with its trace tree. */ export type RunDetailResponse = { run: Record spans: Array> toolCalls: Array> } // ── Corrections & eval cases (flywheel) ──────────────────────────────────── export const correctionAction = z.enum(['edit', 'reject', 'override', 'answer']) export type CorrectionActionInput = z.infer /** * Body schema for POST /corrections. The route derives proposedValue, agentId, * run input, and scope from the proposal/run server-side; the client supplies * only the verdict, the mandatory reason, and (for edits) the corrected value. */ export const createCorrectionRequestSchema = z .object({ proposalId: z.string().uuid(), action: correctionAction, reason: z.string().min(1), correctedValue: z.unknown().optional(), }) .superRefine((value, ctx) => { if (value.action === 'edit' && value.correctedValue === undefined) { ctx.addIssue({ code: 'custom', path: ['correctedValue'], message: '[internal] correctedValue required for edit/override' }) } }) export type CreateCorrectionRequest = z.infer /** Versioned envelope for the agent_orchestrator eval-case export (STABLE/ADDITIVE-ONLY). */ export const EVAL_CASE_EXPORT_VERSION = 1 as const export type EvalCaseExportItem = { id: string sourceType: 'correction' | 'golden_run' agentDefinitionId: string processType: string | null input: unknown expected: unknown | null assertions: unknown | null approvedByUserId: string | null createdAt: string } export type EvalCaseExport = { version: typeof EVAL_CASE_EXPORT_VERSION generatedAt: string count: number cases: EvalCaseExportItem[] } /** Query schema for GET /eval-cases/export. */ export const evalCaseExportQuerySchema = z .object({ agentDefinitionId: z.string().optional(), }) .passthrough() export type EvalCaseExportQuery = z.infer export const evalCaseStatusSchema = z.enum(['draft', 'approved', 'archived']) export const evalCaseSourceTypeSchema = z.enum(['correction', 'golden_run']) /** * Query schema for the read-only GET /eval-cases list. The route projects * metadata columns only — the encrypted `input`/`expected` payloads are never * selected, so no filter may reference them either. */ export const evalCaseListQuerySchema = z .object({ page: z.coerce.number().min(1).default(1), pageSize: z.coerce.number().min(1).max(100).default(50), status: evalCaseStatusSchema.optional(), agentDefinitionId: z.string().max(100).optional(), sourceType: evalCaseSourceTypeSchema.optional(), sortField: z.string().optional(), sortDir: z.enum(['asc', 'desc']).optional(), }) .passthrough() export type EvalCaseListQuery = z.infer // ── Eval assertion management (F9) ────────────────────────────────────────── /** * Create/update schemas for `AgentEvalAssertion` rows (editable → optimistic * lock applies). `config` parameterizes the scorer/judge; it stays permissive * (`unknown`) and is narrowed only at this zod boundary. `appliesTo` is an agent * id or `'*'` (every agent). Only `deterministic` assertions are gate-graded — * the route enforces that `llm_judge` is always `warn` (the judge cannot block). */ export const evalAssertionType = z.enum(['deterministic', 'llm_judge']) export type EvalAssertionType = z.infer export const evalAssertionSeverity = z.enum(['gate', 'warn']) export type EvalAssertionSeverity = z.infer export const evalAssertionCreateSchema = z.object({ key: z.string().min(1).max(100), /** * Registry scorer to run. OPTIONAL for backward compatibility: an existing * client that only sends `key` keeps working, because the route falls back to * `key` — the pre-column resolution rule. New clients should send it explicitly, * since it is what allows several assertions to share one scorer. * * Config is NOT validated here: `evalAssertionUpdateSchema` derives from this * object via `.partial()`, and Zod v4 rejects composition over refined schemas * (`.ai/lessons.md` — use `safeExtend`). Per-scorer config validation therefore * runs in the route through `parseScorerConfig`, which also yields a far better * 422 body than a refinement could. */ scorerKey: z.string().min(1).max(100).optional(), title: z.string().min(1).max(200), description: z.string().max(4000).optional(), appliesTo: z.string().min(1).max(100).default('*'), type: evalAssertionType, severity: evalAssertionSeverity, config: z.unknown().optional(), enabled: z.boolean().optional(), }) export type EvalAssertionCreateInput = z.infer export const evalAssertionUpdateSchema = z .object({ id: z.string().uuid() }) .merge(evalAssertionCreateSchema.partial()) export type EvalAssertionUpdateInput = z.infer /** Query schema for GET /eval-assertions (list). */ export const evalAssertionListQuerySchema = z .object({ page: z.coerce.number().min(1).default(1), pageSize: z.coerce.number().min(1).max(100).default(50), id: z.string().uuid().optional(), appliesTo: z.string().optional(), scorerKey: z.string().optional(), type: evalAssertionType.optional(), severity: evalAssertionSeverity.optional(), enabled: z.coerce.boolean().optional(), sortField: z.string().optional(), sortDir: z.enum(['asc', 'desc']).optional(), }) .passthrough() export type EvalAssertionListQuery = z.infer // ── Runtime guardrails (Phase 1) ──────────────────────────────────────────── /** * Guardrail phase/kind/result unions — mirror the TS string-unions on the * AgentGuardrailCheck entity (kept as Zod enums for runtime validation of the * verdict + persisted check shape). */ export const guardrailPhase = z.enum(['input', 'output']) export type GuardrailPhaseInput = z.infer export const guardrailKind = z.enum([ 'prompt_injection', 'pii', 'grounding', 'schema', 'moderation', 'tool_scope', ]) export type GuardrailKindInput = z.infer export const guardrailResult = z.enum(['pass', 'warn', 'block']) export type GuardrailResultInput = z.infer /** * Redacted-only evidence carried on an AgentGuardrailCheck / verdict check. NEVER * raw PII or plaintext spans — only a redacted detail string and pointers/offsets * into the encrypted artifact store. Phase 1 populates `detail` (e.g. the schema * error path) and `pointers`; richer redaction lands with the PII phase. */ export const guardrailEvidenceSchema = z .object({ /** Short, redacted human/debug detail — never raw PII (schema-error path etc.). */ detail: z.string().optional(), /** storage-s3 keys / offsets into the encrypted artifact store. */ pointers: z.array(z.string()).optional(), /** * Prompt-injection detector rule ids that fired (redaction-safe — the rule id, * never the matched text). See `lib/guardrails/promptInjection.ts`. */ rules: z.array(z.string()).optional(), /** Count of untrusted spans flagged (a number, never the span content). */ flaggedSpans: z.number().int().nonnegative().optional(), /** * The tool/action that triggered a tool-scope `block` (the tool id, which is a * configured allowlist key — not user/untrusted data). */ tool: z.string().optional(), }) .passthrough() export type GuardrailEvidence = z.infer /** One check within a verdict (and the shape attached to the proposal's guardResults). */ export const guardrailCheckSchema = z.object({ kind: guardrailKind, result: guardrailResult, guardrailSetVersion: z.string().min(1), evidence: guardrailEvidenceSchema.optional(), }) export type GuardrailCheck = z.infer /** * The verdict GuardrailService.checkInput/checkOutput returns. `result` is the * worst severity across `checks`; `blockedReason` is set only on a `block`. */ export const guardrailVerdictSchema = z.object({ result: guardrailResult, checks: z.array(guardrailCheckSchema), blockedReason: z .object({ phase: guardrailPhase, kind: guardrailKind }) .optional(), }) export type GuardrailVerdict = z.infer /** The `guardResults` jsonb attached to an AgentProposal (the verdict's checks). */ export const guardResultsSchema = z.array(guardrailCheckSchema) export type GuardResults = z.infer /** * One UNTRUSTED span screened by the pre-call prompt-injection check (Wave 3, * Phase 3). `text` is attacker-controllable document/retrieval content — the * detector reads it but it is NEVER persisted to evidence (only the provenance * locator + matched rule ids are). Mirrors the `document`/`retrieval` sources the * Wave-2 ContextResolver assembles. */ export const untrustedSpanSchema = z.object({ sourceKind: z.enum(['document', 'retrieval']), /** Source attachment id / retrieval ref — a pointer, never the content. */ sourceRef: z.string().min(1), /** `page:N[#bbox]` (document) or the retrieval locator — a pointer into the artifact. */ locator: z.string().min(1), /** The raw untrusted text. Screened in-memory; never written to evidence. */ text: z.string(), }) export type UntrustedSpan = z.infer /** * A tool/action the model attempted (output phase tool-scope backstop). Untrusted * document text must NEVER authorize a tool call — the tool-scope check rejects any * attempt outside the per-capability `ai_assistant` allowlist regardless of how it * was elicited. `isMutation` reflects the tool's registered mutation flag (a * read-only agent under `read-only` policy may invoke NO mutation tool). */ export const attemptedToolSchema = z.object({ name: z.string().min(1), isMutation: z.boolean().optional(), }) export type AttemptedTool = z.infer /** Query schema for GET /guardrail-checks (list + ?id= detail). */ export const guardrailCheckListQuerySchema = z .object({ page: z.coerce.number().min(1).default(1), pageSize: z.coerce.number().min(1).max(100).default(50), id: z.string().uuid().optional(), agentRunId: z.string().uuid().optional(), proposalId: z.string().uuid().optional(), phase: guardrailPhase.optional(), kind: guardrailKind.optional(), result: guardrailResult.optional(), sortField: z.string().optional(), sortDir: z.enum(['asc', 'desc']).optional(), }) .passthrough() export type GuardrailCheckListQuery = z.infer // ── Metric rollups (F2) ───────────────────────────────────────────────────── /** * The `metrics` jsonb shape stored on an AgentMetricRollup row — the same KPIs * the /agents/:id/metrics endpoint computes live, precomputed per window. Null * rates mean "no denominator" (e.g. no evaluated runs) rather than zero. */ export const agentMetricRollupMetricsSchema = z.object({ totalRuns: z.number().int().nonnegative(), evaluatedRuns: z.number().int().nonnegative(), evalPassRate: z.number().min(0).max(1).nullable(), overrides: z.number().int().nonnegative(), overrideRate: z.number().min(0).max(1).nullable(), avgLatencyMs: z.number().nonnegative().nullable(), costMinorTotal: z.number().nonnegative(), disposedProposals: z.number().int().nonnegative(), approveUnchangedRate: z.number().min(0).max(1).nullable(), // Additive observability keys (UX data-honesty pass). Optional so rollup rows // written before the upgrade still parse for the original KPIs — readers MUST // treat a missing key as "no fresh rollup for that metric" and live-compute. errorRuns: z.number().int().nonnegative().optional(), errorRate: z.number().min(0).max(1).nullable().optional(), p95LatencyMs: z.number().nonnegative().nullable().optional(), evalPassedRuns: z.number().int().nonnegative().optional(), }) export type AgentMetricRollupMetrics = z.infer /** * Query schema for GET /metrics/agents — batch per-agent window metrics for * the registry/overview surfaces. `ids` is comma-separated; the route enforces * the 1–50 cap after splitting. */ export const agentMetricsBatchQuerySchema = z.object({ window: z.enum(['24h', '7d', '30d']).default('7d'), ids: z.string().min(1), }) export type AgentMetricsBatchQuery = z.infer // ── Context bundles / TDCR assembly (context overlay, Phase 1) ────────────── /** * Source kind a `ContextModule` may expose. `entity` = an OM structured record * read via `queryEngine`/`query_index`; `document` = an ingested attachment * (Phase 3); `retrieval` = a ranked `searchService` snippet (Phase 2). */ export const contextSourceKind = z.enum(['entity', 'document', 'retrieval']) export type ContextSourceKind = z.infer /** A source the packer selected & packed into the bundle (routed). */ export const contextRoutedSourceSchema = z.object({ kind: contextSourceKind, ref: z.string().min(1), locator: z.string().optional(), tokens: z.number().int().nonnegative(), score: z.number().optional(), }) export type ContextRoutedSource = z.infer /** A candidate the packer excluded, with a reason (audit of the prune decision). */ export const contextPrunedSourceSchema = z.object({ kind: z.string().min(1), ref: z.string().min(1), reason: z.string().min(1), }) export type ContextPrunedSource = z.infer /** * Provenance for one fact in the bundle — links a routed fact back to its source * so compliance lineage and guardrails grounding read the same record. Stamped at * assembly time, never reconstructed. */ export const contextProvenanceSchema = z.object({ factId: z.string().min(1), sourceKind: contextSourceKind, sourceRef: z.string().min(1), locator: z.string().optional(), }) export type ContextProvenance = z.infer /** One redaction applied before packing (P4 populates richer rules). */ export const contextRedactionAppliedSchema = z.object({ field: z.string().min(1), rule: z.string().min(1), }) export type ContextRedactionApplied = z.infer export const contextBundleRoutedSourcesSchema = z.array(contextRoutedSourceSchema) export const contextBundlePrunedSourcesSchema = z.array(contextPrunedSourceSchema) export const contextBundleSourcesSchema = z.array(contextProvenanceSchema) export const contextBundleRedactionAppliedSchema = z.array(contextRedactionAppliedSchema) // ── Document ingest / OCR extraction (context overlay, Phase 3) ───────────── /** * A document locator points a fact back into its source document — the page (and * optional region) the fact was extracted from. The string form persisted on the * bundle is `page:` or `page:#,,,` so it round-trips through * the existing `ContextProvenance.locator`/`ContextRoutedSource.locator` string * columns without a schema change. Structured here for typed assembly; serialized * by `formatDocumentLocator`. */ export const documentRegionSchema = z.tuple([ z.number(), z.number(), z.number(), z.number(), ]) export type DocumentRegion = z.infer export const documentLocatorSchema = z.object({ page: z.number().int().positive(), region: documentRegionSchema.optional(), }) export type DocumentLocator = z.infer /** * One typed fact extracted from a document. Carries full lineage: the source * attachment id (`sourceRef`), the page/region `locator`, and a `confidence` * score in [0,1]. Extracted `value` is UNTRUSTED data (attacker-controllable * document content) — never an instruction; Wave 3 guardrails treat it as such. */ export const documentFactSchema = z.object({ /** Field name within the doc-type schema (e.g. `invoice_total`, `policy_number`). */ field: z.string().min(1), /** Extracted value — UNTRUSTED document content, never an instruction. */ value: z.string(), /** Source attachment id (FK id → attachments; NOT an ORM relation). */ sourceRef: z.string().min(1), /** Page/region the fact was extracted from (lineage → contestability). */ locator: documentLocatorSchema, /** Extraction confidence in [0,1]; low-confidence facts are excludable from routing. */ confidence: z.number().min(0).max(1), }) export type DocumentFact = z.infer /** Result of one document ingest run: the doc-type classification + its typed facts. */ export const documentExtractionSchema = z.object({ /** Source attachment id the facts were extracted from. */ sourceRef: z.string().min(1), /** Classified document type (e.g. `invoice`, `claim_form`, `unknown`). */ docType: z.string().min(1), /** The swappable OCR/extraction provider id that produced the facts. */ engine: z.string().min(1), facts: z.array(documentFactSchema), }) export type DocumentExtraction = z.infer /** Query schema for GET /context-bundles (list + ?id= detail) — trace read route. */ export const contextBundleListQuerySchema = z .object({ page: z.coerce.number().min(1).default(1), pageSize: z.coerce.number().min(1).max(100).default(50), id: z.string().uuid().optional(), agentRunId: z.string().uuid().optional(), workflowInstanceId: z.string().uuid().optional(), capability: z.string().optional(), sortField: z.string().optional(), sortDir: z.enum(['asc', 'desc']).optional(), }) .passthrough() export type ContextBundleListQuery = z.infer // ── Grounding / cite-or-abstain (Wave 3, Phase 4) ──────────────────────────── /** * A single citation a factual claim carries — an id INTO the run's * `AgentContextBundle.sources` (or a `retrieve()` snippet). The grounding check * resolves it against the citable sources surfaced for the run: a citation is * valid iff a citable source with the same `sourceRef` + `locator` exists and its * score clears the set's `minScore`. Pointers only — never raw span text. */ export const groundingCitationSchema = z.object({ sourceKind: contextSourceKind, sourceRef: z.string().min(1), locator: z.string().min(1), }) export type GroundingCitation = z.infer /** * One factual claim emitted by a FACTUAL capability's proposal. `citations` is the * cite-or-abstain contract: a factual claim with zero resolvable citations is a * `block` (the model's only compliant alternative is to abstain — omit the claim). * `claim` is a short human label used only to point at WHICH claim lacked support * in evidence (never raw PII/payload — the capability authors a redaction-safe label). */ export const groundingClaimSchema = z.object({ claim: z.string().min(1), citations: z.array(groundingCitationSchema).default([]), }) export type GroundingClaim = z.infer /** A citable source surfaced for the run (bundle `sources` + `retrieve()` snippets). */ export const citableSourceSchema = z.object({ sourceKind: contextSourceKind, sourceRef: z.string().min(1), locator: z.string().min(1), score: z.number(), }) export type CitableSource = z.infer /** * The per-capability grounding policy body. Versioned config (a guardrail SET): * declares the capability is factual + the severity of each grounding failure mode * + the minimum citable-source score a citation must clear. `claimsPath` points at * the array of factual claims within the proposal output (dot path, default * `proposal.claims`). The `version` recorded on each check is the CONTENT-HASH of * this body — editing it produces a new version; re-syncing an unchanged body is a * no-op (idempotent). */ export const groundingSeverity = z.enum(['warn', 'block']) export type GroundingSeverity = z.infer export const guardrailSetBodySchema = z.object({ capability: z.string().min(1), /** Marks the capability factual — only factual capabilities run the grounding gate. */ factual: z.boolean(), kind: z.literal('grounding'), /** Dot path to the factual-claims array within the proposal output. */ claimsPath: z.string().min(1).default('proposal.claims'), /** A factual claim with zero resolvable citations. */ missingCitation: groundingSeverity.default('block'), /** A citation that resolves to no citable source (or below `minScore`). */ unresolvableCitation: groundingSeverity.default('block'), /** Minimum citable-source score a citation must clear to be considered resolved. */ minScore: z.number().default(0), }) export type GuardrailSetBody = z.infer // ── Agent identity & on-behalf-of (Wave 4, Phase 1) ────────────────────────── /** * The authentication path an `AgentPrincipal` uses. `internal` (the only mode * provisioned in Phase 1) = in-process `INVOKE_AGENT`, NO network auth and NO * interactive credential. `oauth_client` (Phase 3) + `authmd` (Phase 4) are * forward-compatible external seams declared now, provisioned later. */ export const agentCredentialMode = z.enum(['internal', 'oauth_client', 'authmd']) export type AgentCredentialModeInput = z.infer /** * Input to provision (idempotently) an agent principal: the agent definition id, * the human-readable name stamped on the provisioned agent `User`, the scoped * feature grants for the agent's least-privilege `Role`, and the credential mode. * The provisioning service derives the agent `User` email + role name * deterministically from `agentDefinitionId` + scope, so a re-run is a no-op. */ export const provisionAgentPrincipalSchema = z.object({ agentDefinitionId: z.string().min(1).max(100), displayName: z.string().min(1).max(200).optional(), /** Least-privilege ACL feature ids granted to the agent's scoped role. */ roleFeatures: z.array(z.string().min(1)).default([]), credentialMode: agentCredentialMode.default('internal'), }) export type ProvisionAgentPrincipalInput = z.infer /** Query schema for GET /identity/principals (list + ?id= detail). */ export const agentPrincipalListQuerySchema = z .object({ page: z.coerce.number().min(1).default(1), pageSize: z.coerce.number().min(1).max(100).default(50), id: z.string().uuid().optional(), agentDefinitionId: z.string().optional(), credentialMode: agentCredentialMode.optional(), enabled: z.coerce.boolean().optional(), sortField: z.string().optional(), sortDir: z.enum(['asc', 'desc']).optional(), }) .passthrough() export type AgentPrincipalListQuery = z.infer // ── External OAuth client-credentials + delegation grant (Wave 4 Phase 3) ───── /** * RFC 6749 §4.4 client-credentials token request. Only `client_credentials` is * accepted (a non-conforming `grant_type` is rejected → `unsupported_grant_type`). * `scope` is OPTIONAL and only ever NARROWS within the grant's server-side scope * — the client can never widen beyond what the AgentDelegationGrant authorizes. * Tenant/organization are NEVER read from client input; they are derived from the * authenticated principal + grant so a client cannot mint a cross-tenant token. */ export const oauthTokenRequestSchema = z.object({ grant_type: z.literal('client_credentials'), client_id: z.string().min(1).max(200), client_secret: z.string().min(1).max(500), /** Optional space-delimited scope subset; intersected with the grant's scopes. */ scope: z.string().max(2000).optional(), }) export type OAuthTokenRequest = z.infer /** RFC 6749 §5.1 access-token response. No secret is ever echoed. */ export const oauthTokenResponseSchema = z.object({ access_token: z.string(), token_type: z.literal('Bearer'), expires_in: z.number().int().positive(), scope: z.string(), }) export type OAuthTokenResponse = z.infer /** * The audience-scoped JWT claims minted for an external agent. `aud:'agent'` * isolates the signing key (an agent token can never be replayed as a staff or * customer session). `scope`/`tenantId`/`organizationId`/`grantId` are * server-derived and unforgeable by the client. Verification re-loads the grant * by `grantId` and rejects when it is revoked/expired (revocation is immediate). */ export const agentTokenClaimsSchema = z.object({ iss: z.literal('open-mercato'), aud: z.literal('agent'), /** The agent principal's `auth.User` id — the actor on every attributed write. */ sub: z.string().uuid(), /** The human delegator this agent acts on behalf of, or null when none. */ obo: z.string().uuid().nullable(), tenantId: z.string().uuid(), organizationId: z.string().uuid(), /** Space-delimited `:` scope grants. */ scope: z.string(), /** FK id → agent_delegation_grants; the per-request revocation check key. */ grantId: z.string().uuid(), }) export type AgentTokenClaims = z.infer /** * Input to create an AgentDelegationGrant — links an external (`oauth_client`) * AgentPrincipal to the human delegator + the scopes it may mint tokens for. * Tenant/organization come from the authenticated request scope, never the body. */ export const createAgentDelegationGrantSchema = z.object({ /** FK id → agent_principals (the external principal this grant authorizes). */ agentPrincipalId: z.string().uuid(), /** FK id → auth.User — the human delegating authority to the agent. */ delegatorUserId: z.string().uuid(), /** `:` scopes the minted token may carry (non-empty). */ scopes: z.array(z.string().min(1)).min(1), /** Optional hard expiry; tokens never outlive this even before revocation. */ expiresAt: z.coerce.date().nullable().optional(), }) export type CreateAgentDelegationGrantInput = z.infer /** Body for POST /identity/grants/:id/revoke (optimistic-lock token optional). */ export const revokeAgentDelegationGrantSchema = z .object({ /** Optional expected `updated_at` token (also accepted via the standard header). */ expectedUpdatedAt: z.string().optional(), }) .partial() export type RevokeAgentDelegationGrantInput = z.infer // ── auth.md / ID-JAG self-registration (Wave 4 Phase 4) ────────────────────── /** * The OAuth grant type the platform exposes for the ID-JAG / JWT-bearer flow. * RFC 7523 (`urn:ietf:params:oauth:grant-type:jwt-bearer`) is the standard * external agents present an issuer-signed identity assertion under. Additive — * the OAuth-now `client_credentials` grant is unchanged. */ export const ID_JAG_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer' as const /** * The public `/well-known` agent-auth discovery metadata. Read-only and free of * secrets — it advertises WHERE to authenticate, the supported grant types * (client-credentials now + the ID-JAG / JWT-bearer flow), and the audience an * external assertion must target. JWKS/issuer verification material is NEVER * exposed here (the platform validates the assertion server-side against its * trusted-issuer registry — there is no client-fetched key set to leak). */ export const agentAuthDiscoverySchema = z.object({ issuer: z.string().min(1), /** Absolute path of the OAuth client-credentials token endpoint (RFC 6749 §4.4). */ token_endpoint: z.string().min(1), /** Absolute path of the ID-JAG / JWT-bearer self-registration endpoint. */ agent_auth_endpoint: z.string().min(1), grant_types_supported: z.array(z.string().min(1)), /** The audience an external ID-JAG assertion MUST target to be accepted. */ agent_assertion_audience: z.string().min(1), /** The minted access token's audience (an agent token, isolated from staff/customer). */ token_audience: z.string().min(1), token_endpoint_auth_methods_supported: z.array(z.string().min(1)), }) export type AgentAuthDiscovery = z.infer /** * RFC 7523 §2.1 JWT-bearer token request carrying an issuer-signed ID-JAG * assertion. Only the JWT-bearer grant is accepted here (a non-conforming * `grant_type` → `unsupported_grant_type`). `assertion` is the compact JWS the * provider signed; `scope` is OPTIONAL and only ever NARROWS within the resolved * grant. Tenant/org are NEVER read from the request — they are derived from the * resolved/onboarded principal, so a caller cannot self-assign a cross-tenant scope. */ export const idJagTokenRequestSchema = z.object({ grant_type: z.literal(ID_JAG_GRANT_TYPE), /** The compact issuer-signed identity assertion (ID-JAG / JWT-bearer). */ assertion: z.string().min(1).max(8000), /** Optional space-delimited scope subset; intersected with the grant's scopes. */ scope: z.string().max(2000).optional(), }) export type IdJagTokenRequest = z.infer /** * The validated claims of an issuer-signed ID-JAG assertion. `iss` selects the * trusted-issuer verification key (server-side registry — never client-supplied); * `aud` MUST equal the platform's assertion audience (a wrong-audience assertion * is rejected, mirroring the token-side audience isolation). `sub` is the external * agent's stable subject — the idempotency key for onboarding. `org_id`/`tenant_id` * bind the assertion to a concrete tenant the issuer is authorized for; the * onboarding service verifies the issuer is allowed to provision into that org. * Tenant/org are taken from the SIGNED assertion, never from request input. */ export const idJagAssertionClaimsSchema = z.object({ iss: z.string().min(1), sub: z.string().min(1), aud: z.string().min(1), /** The tenant the issuer is provisioning the agent into (must be issuer-authorized). */ tenant_id: z.string().uuid(), /** The organization the issuer is provisioning the agent into (must be issuer-authorized). */ org_id: z.string().uuid(), /** The agent definition id the external agent maps to. */ agent_definition_id: z.string().min(1).max(100), /** The human delegator the agent acts on behalf of, or null/absent for system grants. */ delegator_user_id: z.string().uuid().nullable().optional(), /** Requested `:` scopes; the onboarded grant carries these. */ scopes: z.array(z.string().min(1)).optional(), /** Optional display name stamped on the provisioned agent `User`. */ display_name: z.string().min(1).max(200).optional(), }) export type IdJagAssertionClaims = z.infer // ── Process definitions + executions (spec 2026-09-06) ─────────────────────── /** * 5- or 6-field cron expression SHAPE gate. Semantic validation (does this * actually parse and schedule?) is applied server-side at the route layer via * `withScheduleSemanticChecks` — deliberately NOT here, because this file is * imported by client bundles and the semantic check pulls in * `@open-mercato/scheduler` (cron-parser + luxon). */ const cronExpression = z .string() .min(1) .max(100) .regex(/^\S+(\s+\S+){4,5}$/, 'Invalid cron expression') /** * True when the value is an IANA timezone the runtime can actually resolve * (`"Europe/Warsaw"` passes, `"Warsaw"` does not). Dependency-free — safe for * both client zod schemas and server validators. */ export function isValidIanaTimeZone(value: string): boolean { try { new Intl.DateTimeFormat('en', { timeZone: value }) return true } catch { return false } } const scheduleTimezone = z .string() .min(1) .max(64) .refine(isValidIanaTimeZone, 'Invalid IANA timezone (expected e.g. Europe/Warsaw)') /** * WorkflowEventTriggerConfig-shaped trigger config (mirrored locally, no * cross-module import). **The persisted shape is ARRAYS of typed objects, not * maps** — `filterConditions` is a list of `{ field, operator, value? }` and * `contextMapping` a list of `{ targetKey, sourceExpression, defaultValue? }`. * The spec sketched both as `z.record(...)`; carrying that across would have * silently dropped every stored trigger config on read. */ export const processEventTriggerConfigSchema = z .object({ filterConditions: z .array( z.object({ field: z.string().min(1), operator: z.enum([ 'eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'contains', 'startsWith', 'endsWith', 'in', 'notIn', 'exists', 'notExists', ]), value: z.unknown().optional(), }), ) .optional(), /** How the event payload becomes run input. Without it an event-triggered run has NO input. */ contextMapping: z .array( z.object({ targetKey: z.string().min(1), sourceExpression: z.string().min(1), defaultValue: z.unknown().optional(), }), ) .optional(), debounceMs: z.number().int().min(0).max(86_400_000).optional(), maxConcurrentInstances: z.number().int().min(1).max(1000).optional(), }) .strict() export type ProcessEventTriggerConfig = z.infer /** * Exact event id OR a trailing-wildcard pattern (`claims.*`) — the shape the * retired `agent_task_event_triggers.event_pattern` column accepted. Naming this * `eventId` would have silently dropped wildcard subscription. */ export const processEventPatternSchema = z .string() .min(1) .max(255) .regex(/^[a-z0-9_]+(\.[a-z0-9_]+)*(\.\*)?$|^[a-z0-9_]+\.\*$/i, 'Invalid event pattern') /** * How a process can be entered — ONE declared list replacing three unrelated * mechanisms (cron on the definition, a sibling event-trigger table, and an * undocumented run route). Stored as `agent_process_definitions.triggers` jsonb. * * Cron SHAPE only here (this file is imported by client bundles); the semantic * parse runs server-side through `withScheduleSemanticChecks`. */ export const processTriggerSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('schedule'), cron: cronExpression, timezone: scheduleTimezone.default('UTC'), enabled: z.boolean().default(true), }), z.object({ kind: z.literal('event'), eventPattern: processEventPatternSchema, config: processEventTriggerConfigSchema.nullable().optional(), /** Order among triggers matching the same event. */ priority: z.number().int().min(-1000).max(1000).default(0), enabled: z.boolean().default(true), }), z.object({ kind: z.literal('manual'), /** Features a caller needs beyond `processes.run` to start it by hand. */ requireFeatures: z.array(z.string().min(1).max(200)).max(50).default([]), }), ]) export type ProcessTrigger = z.infer export type ProcessScheduleTrigger = Extract export type ProcessEventTrigger = Extract export type ProcessManualTrigger = Extract /** * Storage bound on the declared entry points. `triggers` is scanned by the * wildcard event subscriber on every domain event, so the list length is a * dispatch cost, not just a schema nicety. */ export const PROCESS_TRIGGERS_MAX = 20 export const processTriggersSchema = z.array(processTriggerSchema).max(PROCESS_TRIGGERS_MAX) /** * A business-facing stage of a process — the `process_definitions.milestones` * VOCABULARY. A milestone is a business EVENT a workflow emits (a step declares * `milestone: ''` in its advanced config), never an alias for a step: it * carries no `stepId`, so a stage can be reached after a parallel join, after a * retry, or after ten steps, and renaming a step cannot change what a business * reader sees. A declared key that no step in the bound workflow emits is a * WARNING (`collectMilestoneIssues`), never an error — a definition mid-edit must * stay saveable. */ export const processMilestoneSchema = z.object({ /** Stable business key, e.g. `analysis_completed`. Matched against emitted events. */ key: z.string().min(1).max(100).regex(/^[a-z0-9_]+$/, 'Use lowercase letters, digits and underscores'), /** Business-facing, authored here — never read from a step. */ label: z.string().min(1).max(200), order: z.number().int().min(0).max(1000), }) export type ProcessMilestone = z.infer /** Storage bound on the declared stage vocabulary. */ export const PROCESS_MILESTONES_MAX = 50 export const processMilestonesSchema = z .array(processMilestoneSchema) .max(PROCESS_MILESTONES_MAX) .superRefine((milestones, ctx) => { // Two milestones sharing a key collapse into one row in every keyed renderer // and make a reorder ambiguous — reject rather than silently drop. const seen = new Set() milestones.forEach((milestone, index) => { if (seen.has(milestone.key)) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: [index, 'key'], message: 'Duplicate milestone key', }) } seen.add(milestone.key) }) }) /** * One milestone an execution actually reached — * `process_instances.milestones_reached` jsonb, appended from the workflow's * `milestone_reached` events. `data` is whatever the emitting step attached; it * is display evidence, never execution state. */ export const processMilestoneReachedSchema = z.object({ key: z.string().min(1).max(100), at: z.string().datetime(), data: z.record(z.string(), z.unknown()).nullable().optional(), }) export type ProcessMilestoneReached = z.infer export const processMilestonesReachedSchema = z.array(processMilestoneReachedSchema).max(PROCESS_MILESTONES_MAX) /** * Why an execution was entered — `process_instances.triggered_by` jsonb. `ref` is * the user id (manual), the event name (event), or absent (schedule). This is the * INVOKER identity: provenance only, never an ACL identity. */ export const processRunTriggeredBySchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('schedule'), ref: z.string().min(1).max(200).optional() }), z.object({ kind: z.literal('event'), ref: z.string().min(1).max(255) }), z.object({ kind: z.literal('manual'), ref: z.string().min(1).max(200).optional() }), /** System entries with no declared trigger (backfills, fixtures, replays). */ z.object({ kind: z.literal('system'), ref: z.string().min(1).max(200).optional() }), ]) export type ProcessRunTriggeredBy = z.infer /** * WHAT a completed execution produced — the `process_instances.outcome_*` columns. * * OPTIONAL BY DECISION: a research or monitoring process produces nothing and * stays a perfectly valid process, so the whole outcome is nullable rather than * a required completion field. It belongs to the BUSINESS execution's completion, * never to a single agent run. * * FK-id + snapshot per `packages/core/AGENTS.md` § Cross-Module Coupling: `id` * references the produced record by value, `label` is a SNAPSHOT so the * reference stays readable when the module that owns it is absent, and this is * never a cross-module ORM relation. * * `type` is deliberately only length-bounded, not pattern-bounded: storage must * accept whatever a producing module declares. Link resolution reads the * `:` prefix and simply declines to link when there is none. */ export const processOutcomeSchema = z.object({ /** Entity id of the produced record, e.g. `claims:claim`. */ type: z.string().min(1).max(150), id: z.string().min(1).max(200), label: z.string().min(1).max(200).optional(), }) export type ProcessOutcome = z.infer /** * How a definition's workflow is authored. `single_agent` materializes a real * `START → INVOKE_AGENT → END` workflow definition owned by this process; * `workflow` binds one the user authored in the Studio. Both end in exactly one * `WorkflowDefinition` — the mode only records who wrote it, so the form knows * whether it may regenerate. */ export const processWorkflowModeSchema = z.enum(['single_agent', 'workflow']) export type ProcessWorkflowMode = z.infer /** * The INVOKE_AGENT config a `single_agent` definition materializes. Mirrors core's * `invokeAgentConfigSchema` (no cross-module import) and is NOT persisted on the * definition: the generated workflow is the single source of truth for it, and the * form reads it back from there. */ export const processSingleAgentSchema = z.object({ agentId: z.string().min(1).max(150), onResult: z.union([ z.object({ autoApproveThreshold: z.number().min(0).max(1), autoApproveMargin: z.number().min(0).max(1).default(0), }), z.object({ alwaysAsk: z.literal(true) }), ]), outputMapping: z.record(z.string(), z.string()).nullable().optional(), }) export type ProcessSingleAgent = z.infer const processDefinitionShape = { name: z.string().min(1).max(255), description: z.string().max(4000).nullable().optional(), /** * How the bound workflow is authored. `single_agent` requires `singleAgent` * and generates the workflow; `workflow` requires an existing `workflowId`. */ workflowMode: processWorkflowModeSchema.default('workflow'), /** `WorkflowDefinition.workflowId`. Required in `workflow` mode; generated in `single_agent` mode. */ workflowId: z.string().min(1).max(150).nullable().optional(), singleAgent: processSingleAgentSchema.nullable().optional(), inputDefaults: z.record(z.string(), z.unknown()).nullable().optional(), /** JSON-Schema restricted to the OUTCOME-compatible subset; compiled lazily at start time. */ inputSchema: z.record(z.string(), z.unknown()).nullable().optional(), /** JSON-Schema describing the business outcome a completed execution produces. */ outcomeSchema: z.record(z.string(), z.unknown()).nullable().optional(), /** * The least-privilege features the bound workflow executes with. Writable ONLY * for a workflow this definition generated — a hand-authored workflow owns its * own grant and the form shows it read-only. */ grantedFeatures: z.array(z.string().min(1).max(200)).max(200).optional(), triggers: processTriggersSchema.optional(), milestones: processMilestonesSchema.optional(), uiMetadata: z.record(z.string(), z.unknown()).nullable().optional(), enabled: z.boolean().optional(), } type ProcessDefinitionShapeInput = { workflowMode: ProcessWorkflowMode workflowId?: string | null singleAgent?: ProcessSingleAgent | null } /** * Every process points at a workflow — that is the whole model. In `workflow` * mode the caller names one; in `single_agent` mode the agent config is what the * generated workflow is built FROM, so it is the required half instead. */ function requiresWorkflowPointer(data: ProcessDefinitionShapeInput): boolean { return data.workflowMode === 'single_agent' ? !!data.singleAgent : !!data.workflowId } const WORKFLOW_POINTER_MESSAGE = 'A process must point at a workflow: choose an agent (single-agent mode) or an existing workflow' export const processDefinitionCreateSchema = z .object(processDefinitionShape) .refine(requiresWorkflowPointer, { message: WORKFLOW_POINTER_MESSAGE, path: ['workflowId'] }) export type ProcessDefinitionCreateInput = z.infer export const processDefinitionUpdateSchema = z .object({ id: z.string().uuid(), ...processDefinitionShape }) .refine(requiresWorkflowPointer, { message: WORKFLOW_POINTER_MESSAGE, path: ['workflowId'] }) export type ProcessDefinitionUpdateInput = z.infer export const processDefinitionListQuerySchema = z .object({ id: z.string().uuid().optional(), /** Narrows to the definitions bound to one workflow — the milestone lookup the execution detail makes. */ workflowId: z.string().min(1).max(150).optional(), enabled: z.coerce.boolean().optional(), search: z.string().max(200).optional(), page: z.coerce.number().int().min(1).default(1), pageSize: z.coerce.number().int().min(1).max(100).default(50), sortField: z.string().optional(), sortDir: z.enum(['asc', 'desc']).optional(), }) .passthrough() export type ProcessDefinitionListQuery = z.infer /** `POST /processes/:id/executions` request body — always async, returns 202 + executionId. */ export const processExecutionStartSchema = z.object({ input: z.record(z.string(), z.unknown()).optional(), idempotencyKey: z.string().min(1).max(200).optional(), sourceEntityType: z.string().min(1).max(100).optional(), sourceEntityId: z.string().uuid().optional(), }) export type ProcessExecutionStartRequest = z.infer // ── Execution subject & caseload projection (spec 2026-06-25) ──────────────── /** * The `subject` descriptor a workflow's INVOKE_AGENT node declares (static or * `{{context.*}}`-interpolated) — "what business record this process is about". * Travels via event payloads / transient run ctx only; never a run/proposal column. */ export const processSubjectSchema = z .object({ subjectType: z.string().min(1).max(100).nullable().optional(), subjectId: z.string().min(1).max(200).nullable().optional(), subjectLabel: z.string().min(1).max(200).nullable().optional(), subjectTitle: z.string().min(1).max(300).nullable().optional(), valueMinor: z.coerce.number().int().nullable().optional(), currency: z.string().length(3).nullable().optional(), fraud: z.coerce.boolean().nullable().optional(), /** Non-filterable display extras (e.g. subjectParty, ownerLabel). */ facets: z.record(z.string(), z.unknown()).nullable().optional(), }) .passthrough() export type ProcessSubject = z.infer export const processInstanceStatusSchema = z.enum([ 'running', 'waiting_on_you', 'question_open', 'docs_requested', 'fraud_hold', 'auto_completing', 'auto_completed', 'completed', 'failed', 'cancelled', ]) export const processListScopeSchema = z.enum([ 'all', 'needs_decision', 'stuck_24h', 'high_value', 'fraud_flagged', ]) export type ProcessListScope = z.infer /** * `GET /executions` — the one business-execution surface, replacing the split * between the old run ledger and the projection list. */ export const processExecutionListQuerySchema = z .object({ page: z.coerce.number().min(1).default(1), pageSize: z.coerce.number().min(1).max(100).default(50), id: z.string().uuid().optional(), /** Narrows to the execution behind one workflow instance. */ workflowInstanceId: z.string().uuid().optional(), processDefinitionId: z.string().uuid().optional(), scope: processListScopeSchema.optional(), status: processInstanceStatusSchema.optional(), subjectType: z.string().max(100).optional(), sourceEntityType: z.string().max(100).optional(), sourceEntityId: z.string().uuid().optional(), q: z.string().max(200).optional(), sortField: z.string().optional(), sortDir: z.enum(['asc', 'desc']).optional(), }) .passthrough() export type ProcessExecutionListQuery = z.infer // Per-agent presentation settings (agent_settings). `icon` is a stable lucide // name from the canonical vocabulary; `null` clears it back to the type/initials // fallback. Keep the enum sourced from AGENT_ICON_NAMES so validator, seed, and // client renderer never drift. export const agentIconNameSchema = z.enum(AGENT_ICON_NAMES) export const agentSettingUpdateSchema = z.object({ agentId: z.string().min(1).max(100), icon: agentIconNameSchema.nullable(), }) export type AgentSettingUpdate = z.infer // Operator-authored agent tags. Free text rather than a fixed vocabulary, so the // schema only enforces the storage bounds — `normalizeAgentTags` is what trims, // lowercases and dedupes before either side compares them. export const agentTagsSchema = z .array(z.string().trim().min(1).max(AGENT_TAG_MAX_LENGTH)) .max(AGENT_TAGS_MAX_COUNT) // Request body for PUT /agents/[id]/settings. `agentId` comes from the route // param, so the body carries only the editable settings plus the optional // optimistic-lock expectation (the settings row's current updatedAt). Both // fields are optional and an omitted one is left untouched, so a caller can save // tags without restating the icon (and older clients that only send `icon` keep // working unchanged). export const agentIconWriteSchema = z.object({ icon: agentIconNameSchema.nullable().optional(), tags: agentTagsSchema.optional(), updatedAt: z.string().datetime().nullable().optional(), }) export type AgentIconWrite = z.infer // ── Eval runs (replay engine) ─────────────────────────────────────────────── /** * Per-case override of a suite-level assertion, stored in * `AgentEvalCase.assertions`. References are by `assertionId`, NOT by key: the * unique index is per (org, appliesTo, key), so a `'*'` row and an agent-specific * row routinely share a slug and a key-based reference would be ambiguous. */ export const evalCaseAssertionRefSchema = z.object({ assertionId: z.string().uuid(), /** Shallow-merged over the stored config, then re-validated against the scorer. */ configOverride: z.record(z.string(), z.unknown()).nullable().optional(), disabled: z.boolean().optional(), }) export type EvalCaseAssertionRefInput = z.infer export const evalRunCreateSchema = z.object({ agentDefinitionId: z.string().min(1).max(100), evalCaseIds: z.array(z.string().uuid()).min(1).max(500), /** Repeat each case to MEASURE judge/model variance rather than assume it away. */ repeatCount: z.coerce.number().int().min(1).max(20).default(1), /** * A human reads a manual run, so a rubric may decide pass/fail there. CI passes * false: the gate stays deterministic and promotion reproducible. */ judgeMayGate: z.boolean().default(true), }) export type EvalRunCreateInput = z.infer export const evalSuiteRunStatus = z.enum(['queued', 'running', 'completed', 'failed', 'cancelled']) export const evalRunListQuerySchema = z.object({ page: z.coerce.number().min(1).default(1), pageSize: z.coerce.number().min(1).max(100).default(50), agentDefinitionId: z.string().optional(), status: evalSuiteRunStatus.optional(), }) export type EvalRunListQuery = z.infer /** Case runs are paged by keyset on (suite_run_id, created_at) — see _suite_idx. */ export const evalCaseRunListQuerySchema = z.object({ pageSize: z.coerce.number().min(1).max(100).default(50), after: z.string().optional(), }) export type EvalCaseRunListQuery = z.infer /** * Eval-case authoring. `input`/`expected` are free-form per agent (the agent's own * `result.schema` constrains what a real run produces), so they stay `unknown` * here; both columns are ENCRYPTED at rest via the module's `defaultEncryptionMaps`. */ export const evalCaseCreateSchema = z.object({ agentDefinitionId: z.string().min(1).max(100), name: z.string().max(200).nullable().optional(), input: z.unknown(), expected: z.unknown().optional(), processType: z.string().max(100).nullable().optional(), /** Per-case overrides of the suite-level assertion set. */ assertions: z.array(evalCaseAssertionRefSchema).nullable().optional(), }) export type EvalCaseCreateInput = z.infer export const evalCaseUpdateSchema = z .object({ id: z.string().uuid() }) .merge(evalCaseCreateSchema.partial()) export type EvalCaseUpdateInput = z.infer