/* Extract the action handler pipeline from index.ts into composable, testable steps. Each step is a pure function that receives the accumulated context and returns either a Response (short-circuit) or void (continue to next step). Do not implement rate limiting or validation logic here — delegate to existing modules. RFC-1097: step 6 — compass.migrate codemod run Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order. RFC-1097: sweep — werkstatt-engine clean Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics. RFC-1172: closed agent.traffic telemetry — ports.telemetry sink, TrafficRecorder + boundary emit via withTrafficEmit RFC-1172: fo-review — extractAgentIdentity on all routes, honest mcpProtocolVersion, top-level import, test header RFC-1187: phases 1-3 — contracts, Redis registry, AS core + Bearer pipeline RFC-1169 */ import type { AgentSurfaceManifest, ActionReceipt, AgentProblemDetails, PresentationVerdict, } from "@warpgogol/werkstatt-shared/agent"; import { buildAgentProblem } from "@warpgogol/werkstatt-shared/agent"; import type { TrafficRecorder } from "./telemetry.ts"; import { byteHash, canonicalJsonHashV1, snapshotCanonicalJsonObjectV1, } from "@warpgogol/werkstatt-shared/fingerprint"; import type { CapabilityRecord } from "@warpgogol/werkstatt-shared/ontology"; import type { AgentGatePorts } from "./ports.ts"; import type { VerifiedAgent } from "@warpgogol/werkstatt-shared/agent"; import { resolveBearerAgent } from "./agent-auth.ts"; import type { RateLimiter } from "./limits.ts"; import { emitAgentUsage } from "./usage.ts"; import { checkPresentationRequirement } from "./presentation.ts"; import { validateAgainstCapabilitySchema, buildIntegrationEventFromAction, type ValidationErrorCode, type ValidationFieldError, } from "./actions.ts"; /** RFC-1112: receipt-store TTL — 24h covers client retry windows without accumulating keys. */ export const IDEMPOTENCY_TTL_SECONDS = 86400; /** RFC-1112: Idempotency-Key longer than this is ignored (treated as absent). */ export const IDEMPOTENCY_KEY_MAX_LENGTH = 255; /** RFC-1112: MCP params._meta key carrying the idempotency key. */ export const MCP_IDEMPOTENCY_META_KEY = "gogol.dev/idempotencyKey"; export interface ActionContext { capabilityId: string; request: Request; manifest: AgentSurfaceManifest; catalog: CapabilityRecord[]; ports: AgentGatePorts; rawBody: string; clientIp: string; agentIdentity: Record; /** RFC-1112: caller-supplied Idempotency-Key (validated length), if any. */ idempotencyKey?: string; /** RFC-1112: sha256 of the raw request body — computed only when a key is present. */ bodyHash?: string; // Mutable fields populated by pipeline steps capability?: CapabilityRecord; parsed?: unknown; validatedValue?: Record; /** RFC-1187: resolved Bearer identity — set by resolveAgentAuth; drives elevated limits + attribution. */ verifiedAgent?: VerifiedAgent; /** RFC-1172: optional per-request telemetry recorder — steps record what only they know (duplicate replay). */ traffic?: TrafficRecorder; /** RFC-1213: raw verifiable presentation extracted from the transport (header/_meta). */ presentation?: unknown; /** RFC-1213: the bound verdict — the only presentation fact downstream sees. */ presentationVerdict?: PresentationVerdict; } export type StepResult = Response | void; /** RFC-1112: read + bound the Idempotency-Key header. Overlong keys are ignored. */ export function extractIdempotencyKey(request: Request): string | undefined { const key = request.headers.get("Idempotency-Key") ?? undefined; if (!key || key.length > IDEMPOTENCY_KEY_MAX_LENGTH) return undefined; return key; } /** RFC-1112: byte-level body hash for the HTTP surface. */ export function httpBodyHash(rawBody: string): string { return byteHash(rawBody); } /** * RFC-1112: canonical body hash for the MCP surface — arguments arrive * pre-parsed, so the hash is over the canonical JSON snapshot (key-order * independent). Returns undefined when the input cannot be snapshotted. */ export function mcpBodyHash(args: unknown): string | undefined { const snapshot = snapshotCanonicalJsonObjectV1(args ?? {}); return snapshot.ok ? canonicalJsonHashV1(snapshot.value) : undefined; } function jsonResponse( body: unknown, status = 200, extraHeaders?: Record, ): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...extraHeaders }, }); } /** RFC-1112: RFC 9457 error response — every action failure uses this shape. */ export function problemResponse( problem: AgentProblemDetails, extraHeaders?: Record, ): Response { return new Response(JSON.stringify(problem), { status: problem.status, headers: { "content-type": "application/problem+json", ...extraHeaders }, }); } export function resolveCapability(ctx: ActionContext): StepResult { const capability = ctx.catalog.find((c) => c.id === ctx.capabilityId); const ref = ctx.manifest.actions.find((a) => a.id === ctx.capabilityId); if (!capability || !ref) { return problemResponse( buildAgentProblem("unknown-capability", { detail: `No active capability "${ctx.capabilityId}".`, }), ); } ctx.capability = capability; } export function checkPayloadSize(ctx: ActionContext): StepResult { const byteLength = new TextEncoder().encode(ctx.rawBody).length; if (byteLength > ctx.capability!.limits.maxPayloadBytes) { return problemResponse( buildAgentProblem("payload-too-large", { detail: `Payload is ${byteLength} bytes; the limit is ${ctx.capability!.limits.maxPayloadBytes}.`, }), ); } } /** * RFC-1112: idempotency lookup — runs before rate limiting so a replay does not * consume budget. Returns the stored receipt (flipped to duplicate) on a * same-body hit, a 409 problem on a different-body reuse, or void on a miss. * Fail-open: a store read error proceeds without dedup. */ export async function checkIdempotencyReplay(input: { ports: AgentGatePorts; capability: CapabilityRecord; idempotencyKey?: string; bodyHash?: string; }): Promise { const { ports, capability, idempotencyKey, bodyHash } = input; if (!idempotencyKey || !ports.idempotency || capability.sideEffect === "none" || !bodyHash) { return null; } let stored: { receipt: ActionReceipt; bodyHash: string } | null; try { stored = await ports.idempotency.get(idempotencyKey); } catch { return null; } if (!stored) return null; if (stored.bodyHash !== bodyHash) { return buildAgentProblem("idempotency-key-reuse", { detail: "This Idempotency-Key was already used with a different payload.", }); } return { ...stored.receipt, status: "duplicate", duplicate: true }; } /** Pipeline-step wrapper around checkIdempotencyReplay for the HTTP surface. */ export async function checkIdempotency(ctx: ActionContext): Promise { const replay = await checkIdempotencyReplay({ ports: ctx.ports, capability: ctx.capability!, idempotencyKey: ctx.idempotencyKey, bodyHash: ctx.bodyHash, }); if (replay === null) return; if ("receiptId" in replay) { // RFC-1172: replayed from a stored receipt — mark the traffic event. if (ctx.traffic) ctx.traffic.duplicate = true; return jsonResponse(replay, 200); } return problemResponse(replay); } /** * RFC-1187: Bearer resolution — runs after resolveCapability (the elevated * limit lookup needs the record) and before rate limiting. A verified token * stamps `verifiedAgentId` into the observed identity (projected into * `payload._agentIdentity` at dispatch); every failure leaves the field * unset — anonymous is the default, never an error. */ export async function resolveAgentAuth(ctx: ActionContext): Promise { if (!ctx.ports.auth) return; const agent = await resolveBearerAgent(ctx.request, ctx.ports.auth); if (agent) { ctx.verifiedAgent = agent; ctx.agentIdentity.verifiedAgentId = agent.agentId; } } /** * RFC-1187: the minimum shape the limit helpers need — ActionContext satisfies * it on the HTTP route; MCP/A2A handlers build it from their own context so * the elevated tier behaves identically on every action-bearing transport. */ export interface RateLimitSubject { capabilityId: string; clientIp: string; verifiedAgent?: VerifiedAgent; capability?: CapabilityRecord | null; } /** * RFC-1187: the limit applies per verified agent when the capability declares * `perMinutePerAgent` and the caller is authenticated — otherwise the * anonymous per-IP bucket (verified or not). */ export function rateLimitKey(subject: RateLimitSubject): string { if (subject.verifiedAgent && subject.capability?.limits.perMinutePerAgent) { return `${subject.capabilityId}:agent:${subject.verifiedAgent.agentId}`; } return `${subject.capabilityId}:${subject.clientIp}`; } /** RFC-1187: the effective max — elevated tier only when both sides declare it. */ export function rateLimitMax(subject: RateLimitSubject): number { if (subject.verifiedAgent && subject.capability?.limits.perMinutePerAgent) { return subject.capability.limits.perMinutePerAgent; } return subject.capability!.limits.perMinutePerIp; } export function checkRateLimit(ctx: ActionContext, limiter: RateLimiter | null): StepResult { if (!limiter) return; const result = limiter.check(rateLimitKey(ctx)); if (!result.allowed) { return problemResponse( buildAgentProblem("rate-limited", { retryAfterSeconds: result.retryAfterSeconds }), { "Retry-After": String(result.retryAfterSeconds) }, ); } } export function parseJsonBody(ctx: ActionContext): StepResult { try { ctx.parsed = ctx.rawBody.length > 0 ? JSON.parse(ctx.rawBody) : {}; } catch { return problemResponse(buildAgentProblem("invalid-json")); } } export function validateSchema(ctx: ActionContext): StepResult { const validated = validateAgainstCapabilitySchema(ctx.capability!.input, ctx.parsed!); if (!validated.ok) { // RFC-1169: preview capabilities answer validation failures with the // readiness envelope (200), not a 422 problem — "incomplete, ask the user" // must be distinguishable from "malformed". if (ctx.capability!.sideEffect === "none") { return jsonResponse(buildPreviewFailure(validated.errors), 200); } return problemResponse( buildAgentProblem("schema-violation", { schemaRef: `/.well-known/agent.openapi.json#/components/schemas/${ctx.capabilityId}-input`, errors: validated.errors, }), ); } ctx.validatedValue = validated.value; } // --------------------------------------------------------------------------- // RFC-1112: shared execution core — used by the HTTP pipeline AND mcp/handler.ts // so both surfaces share one validation + receipt assembly. // --------------------------------------------------------------------------- export interface ActionExecutionInput { capability: CapabilityRecord; manifest: AgentSurfaceManifest; ports: AgentGatePorts; validatedValue: Record; agentIdentity: Record; idempotencyKey?: string; bodyHash?: string; /** * RFC-1213: the raw verifiable presentation attached by the caller * (X-Agent-Verifiable-Presentation header on HTTP, params._meta on MCP/A2A) * — parsed and verified inside this boundary only. */ presentation?: unknown; } export type ActionExecution = | { kind: "receipt"; receipt: ActionReceipt; verdict?: PresentationVerdict } | { kind: "preview"; envelope: ActionPreviewResult; verdict?: PresentationVerdict } | { kind: "problem"; problem: AgentProblemDetails; verdict?: PresentationVerdict }; /** RFC-1169: one machine-readable field violation on the preview failure branch. */ export interface ActionPreviewProblem { field: string; code: ValidationErrorCode; detail: string; } /** * RFC-1169: the preview-path answer shape. `ready` is the only unconditional * field; everything else is present only in its branch — the closed capability * output subset cannot express conditional presence, so the shape is enforced * by agent.surface.contract.check, not by the schema. */ export interface ActionPreviewResult { ready: boolean; /** Present when ready — deterministic canonical hash of the validated input. */ draftId?: string; /** Present when !ready — absent required field names. */ missing?: string[]; /** Present when !ready — non-missing violations; unmappable errors keep field "". */ problems?: ActionPreviewProblem[]; /** Present when ready — the validated input echoed for user confirmation. */ summary?: Record; /** Present when ready — the submit-sibling capability id from the catalog. */ nextAction?: string; /** Present when ready — human+model readable consequence of nextAction. */ sideEffect?: string; } /** * RFC-1169: build the ready:false branch from validator errors. REQUIRED-coded * errors populate `missing`; everything else lands in `problems` so no * violation is ever dropped. */ export function buildPreviewFailure(errors: ValidationFieldError[]): ActionPreviewResult { return { ready: false, missing: errors.filter((e) => e.code === "REQUIRED").map((e) => e.path), problems: errors .filter((e) => e.code !== "REQUIRED") .map((e) => ({ field: e.path, code: e.code, detail: e.message })), }; } /** RFC-1169: build the ready:true branch — draftId, echoed summary, optional nextAction linkage. */ export function buildPreviewReady( capability: CapabilityRecord, draftId: string, summary: Record, ): ActionPreviewResult { const envelope: ActionPreviewResult = { ready: true, draftId, summary }; if (capability.nextAction) { envelope.nextAction = capability.nextAction.capability; envelope.sideEffect = capability.nextAction.sideEffect; } return envelope; } /** * Execute a validated action: sideEffect "none" returns a deterministic * preview envelope (draftId = canonical hash of the validated input, plus * summary/nextAction per RFC-1169); "write" dispatches the IntegrationEvent * and stores the receipt under the Idempotency-Key when a store is * configured. A failed receipt-store write is non-fatal (the dispatch * already succeeded — the receipt is still returned). */ export async function executeAction(input: ActionExecutionInput): Promise { const { capability, manifest, ports, validatedValue, agentIdentity, idempotencyKey, bodyHash, presentation, } = input; // RFC-1213: the verifier boundary runs inside the shared core so HTTP, MCP, // and A2A enforce identically — required presentations gate previews AND // dispatches, and the check never reaches claim values downstream. const presentationCheck = await checkPresentationRequirement({ ports, siteId: manifest.site, capability, presentation, verifiedAgentId: agentIdentity.verifiedAgentId, }); if (presentationCheck.problem) { return { kind: "problem", problem: presentationCheck.problem, verdict: presentationCheck.verdict, }; } const verdict = presentationCheck.verdict.verified ? presentationCheck.verdict : undefined; if (capability.sideEffect === "none") { const snapshot = snapshotCanonicalJsonObjectV1(validatedValue); if (!snapshot.ok) { return { kind: "problem", problem: buildAgentProblem("schema-violation", { detail: `Input could not be canonicalized: ${snapshot.message}`, }), }; } return { kind: "preview", envelope: buildPreviewReady(capability, canonicalJsonHashV1(snapshot.value), validatedValue), ...(verdict ? { verdict } : {}), }; } const locale = typeof validatedValue.locale === "string" && manifest.languages.supported.includes(validatedValue.locale) ? validatedValue.locale : manifest.languages.default; const event = buildIntegrationEventFromAction( capability, validatedValue, locale, ports.now(), idempotencyKey, ); if (Object.keys(agentIdentity).length > 0) { event.payload._agentIdentity = agentIdentity; } try { const receipt = await ports.dispatch.send(event); // RFC-1212: metered capabilities journal exactly one usage record — only // here, after dispatch.send resolved, so rejected/replayed calls never // bill. Fire-and-forget; the sink dropping is non-fatal (journaled pull). await emitAgentUsage(ports, { siteId: manifest.site, capability, callerIdentity: agentIdentity.verifiedAgentId ?? "anonymous", idempotencyKey, }); if (idempotencyKey && ports.idempotency && bodyHash) { try { await ports.idempotency.put(idempotencyKey, bodyHash, receipt, IDEMPOTENCY_TTL_SECONDS); } catch (err) { console.warn( `agent-gate: receipt store put failed for key "${idempotencyKey}": ${err instanceof Error ? err.message : String(err)}`, ); } } return { kind: "receipt", receipt, ...(verdict ? { verdict } : {}) }; } catch { return { kind: "problem", problem: buildAgentProblem("dispatch-failed", { detail: `The delivery substrate rejected event ${event.eventId}.`, }), }; } } /** Pipeline-step wrapper around executeAction for the HTTP surface. */ export async function dispatchOrPreview(ctx: ActionContext): Promise { const result = await executeAction({ capability: ctx.capability!, manifest: ctx.manifest, ports: ctx.ports, validatedValue: ctx.validatedValue!, agentIdentity: ctx.agentIdentity, idempotencyKey: ctx.idempotencyKey, bodyHash: ctx.bodyHash, presentation: ctx.presentation, }); // RFC-1213: the verdict is the only presentation fact allowed downstream. if (result.verdict) ctx.presentationVerdict = result.verdict; switch (result.kind) { case "receipt": return jsonResponse(result.receipt, 200); case "preview": return jsonResponse(result.envelope, 200); case "problem": return problemResponse(result.problem); } } /** * RFC-1171: deprecation headers for alias-path responses. The request pathname * is matched against manifest `deprecations[].path` with `{id}` substituted by * the requested capability id. Headers appear only once `deprecateAt` has * passed — the declaration can ship ahead of the signal. Returns undefined for * canonical paths and undated/malformed entries. */ export function deprecationHeaders( manifest: AgentSurfaceManifest, capabilityId: string, requestPathname: string, now = Date.now(), ): Record | undefined { for (const dep of manifest.deprecations ?? []) { if (dep.path.replaceAll("{id}", capabilityId) !== requestPathname) continue; const deprecateMs = Date.parse(dep.deprecateAt); const sunsetMs = Date.parse(dep.sunsetAt); if (Number.isNaN(deprecateMs) || Number.isNaN(sunsetMs) || now < deprecateMs) { return undefined; } return { // RFC 9745: Deprecation is a unix-seconds structured field. Deprecation: `@${Math.floor(deprecateMs / 1000)}`, // RFC 8594: Sunset is an HTTP-date. Sunset: new Date(sunsetMs).toUTCString(), Link: `<${dep.successor.replaceAll("{id}", capabilityId)}>; rel="successor-version"`, }; } return undefined; }