Spaces:
Running
Running
Vanshik Waghela
fix: allow non-email action types (generate_packet, manual_packet, etc.) without external adapter
b86ec3d | /** | |
| * 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<Action["proofState"]>([ | |
| "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<typeof consentHashMatches>[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 }; | |
| } | |