/** * ActionExecutionPolicy — pure rules for `canExecute`. * * Order: consent → idempotency → capability → rate-limit. * Failure of any step returns `ActionExecutionRefused` with a typed reason. * This is the invariant core of the Action spine (CLAUDE.md #2, #3, #5, #7). */ import type { Action, AdapterCapability, ActionExecutionRefused, } from "@courtmitra/contracts"; import { consentHashMatches } from "./consent-hash.js"; // --------------------------------------------------------------------------- // Refusal result // --------------------------------------------------------------------------- export interface ExecutionRefusal { refused: true; reason: string; code: ActionExecutionRefused["code"]; } export interface ExecutionAllowance { refused: false; } export type CanExecuteResult = ExecutionRefusal | ExecutionAllowance; // --------------------------------------------------------------------------- // Adapter capability check // --------------------------------------------------------------------------- function adapterForAction(action: Action): string | null { const t = action.actionType; if (t === "send_email" || t === "takedown") return "resend_email"; if (t === "post_social") return "social_publisher"; // All other types (generate_packet, manual_packet, prepare_portal, // submit_portal, inform_user, etc.) are local/user-driven — no adapter needed. return null; } function capabilityIsAvailable(cap: AdapterCapability): boolean { return cap.status === "available" || cap.status === "dev_only"; } const executedProofStates = new Set([ "sent_by_email", "sent_via_channel", "posted_to_social", "user_confirmed_external_submission", "receipt_verified", ]); // --------------------------------------------------------------------------- // Public gate — pure function, zero side-effects // --------------------------------------------------------------------------- /** * Evaluate whether an action may be executed. * * @param action — the Action row to execute. * @param consentGrant — the consent grant (null if not yet granted). * @param outgoingPreview — the ConsentPreview computed from the EXACT outgoing payload. * @param allCapabilities — full capability registry (adapter list). * @returns `{ refused: false }` or `{ refused: true, reason, code }`. */ export function canExecute( action: Action, consentGrant: { actionPreviewHash: string } | null, outgoingPreview: Parameters[1], allCapabilities: AdapterCapability[], ): CanExecuteResult { // 1. Consent must exist if (!consentGrant) { return { refused: true, reason: "Consent has not been granted for this action.", code: "consent_not_found", }; } // 2. Consent hash must match the outgoing payload (CLAUDE.md #5) if (!consentHashMatches(consentGrant.actionPreviewHash, outgoingPreview)) { return { refused: true, reason: "The action draft has changed since consent was given — re-approval required.", code: "consent_hash_mismatch", }; } // 3. Action must be in an executable status. // A preview is not executable until consent has explicitly moved it to approved. // "executing" is allowed for worker retries after the attempt row is recorded. const executableStatuses = new Set(["approved", "executing"]); if (!executableStatuses.has(action.status)) { return { refused: true, reason: `Action status is "${action.status}", not "approved" or "executing".`, code: "action_not_approved", }; } // 4. Idempotency key must not already have been used (caller checks DB) if (action.status === "succeeded" || executedProofStates.has(action.proofState)) { return { refused: true, reason: "This action has already been executed (idempotency key consumed).", code: "idempotency_key_used", }; } // 5. Adapter capability must be available const adapterName = adapterForAction(action); if (adapterName) { const matching = allCapabilities.find((c) => c.adapter === adapterName); if (!matching || !capabilityIsAvailable(matching)) { return { refused: true, reason: matching ? `Adapter "${adapterName}" is not available (status: ${matching.status}). ${matching.reason ?? ""}` : `No adapter found for "${adapterName}".`, code: "adapter_capability_blocked", }; } } return { refused: false }; }