File size: 4,532 Bytes
1b87b65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b86ec3d
 
 
 
 
 
1b87b65
 
 
 
 
 
7aa8d9d
 
 
 
 
 
 
 
1b87b65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7aa8d9d
 
 
 
1b87b65
 
 
7aa8d9d
1b87b65
 
 
 
 
7aa8d9d
1b87b65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
/**
 * 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 };
}