courtmitra / packages /domain /src /policies /action-execution-policy.ts
Vanshik Waghela
fix: allow non-email action types (generate_packet, manual_packet, etc.) without external adapter
b86ec3d
Raw
History Blame
4.53 kB
/**
* 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 };
}