/** * Shared service for processing guardian action decisions. * * Encapsulates the core business logic — validation, conversation scoping, * decision application, and result mapping — so both the HTTP handler and * the message handler can delegate here without duplicating code. */ import { applyGuardianDecision } from "../approvals/guardian-decision-primitive.js"; import { getGuardianRequestOrNull, isGuardianRequestInScopeOrFalse, } from "../channels/gateway-guardian-requests.js"; import { APPROVAL_ACTION_IDS, isApprovalAction, } from "./channel-approval-types.js"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** * Legacy actions that map to canonical ones during client rollout. * All temporal/persistent approval variants collapse to approve_once. * Keep until all clients are updated and no in-flight buttons remain. */ const LEGACY_ACTION_MAP: Record = { approve_10m: "approve_once", approve_conversation: "approve_once", approve_always: "approve_once", }; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface ProcessGuardianDecisionParams { requestId: string; action: string; conversationId?: string; channel: string; // e.g. "vellum" actorContext: { actorPrincipalId: string | undefined; guardianPrincipalId: string | undefined; }; } export type ProcessGuardianDecisionResult = | { ok: true; applied: true; requestId: string; replyText?: string; /** * The action to present on the resolved card — the resolved outcome, not * necessarily the raw button (an access-request `reject` resolves to the * `leave_unverified` park). Lets a client completing the card * optimistically render the correct tone. */ decidedAction?: string; } | { ok: true; applied: false; reason: string; resolverFailureReason?: string; requestId?: string; } | { ok: false; error: "invalid_action" | "invalid_scope"; message: string }; // --------------------------------------------------------------------------- // Core decision processing // --------------------------------------------------------------------------- /** * Process a guardian decision through the unified decision primitive. * * Validates the action, checks conversation scope if applicable, applies the * decision, and maps the result to a caller-agnostic shape that both HTTP * and message handlers can interpret. * * Valid actions are the `ApprovalAction` union; the primitive additionally * scopes the introduction-card actions to `access_request` requests. */ export async function processGuardianDecision( params: ProcessGuardianDecisionParams, ): Promise { const { requestId, conversationId, channel, actorContext } = params; // 1. Canonicalize legacy actions, then validate const action = LEGACY_ACTION_MAP[params.action] ?? params.action; if (!isApprovalAction(action)) { return { ok: false, error: "invalid_action", message: `Invalid action: ${params.action}. Must be one of: ${APPROVAL_ACTION_IDS.join(", ")}`, }; } // 2. Verify conversationId scoping before applying the decision. The // decision is allowed when the conversationId matches the request's // source conversation OR a recorded delivery destination conversation. // Reads degrade fail-closed: an unreachable gateway scopes to not_found // (and the decide below fails loudly anyway). if (conversationId) { const request = await getGuardianRequestOrNull(requestId); if ( request && request.sourceConversationId && !(await isGuardianRequestInScopeOrFalse( requestId, conversationId, channel, )) ) { return { ok: true, applied: false, reason: "not_found" }; } } // 3. Apply the decision through the gateway-native primitive const decisionResult = await applyGuardianDecision({ requestId, action, actorContext: { actorPrincipalId: actorContext.actorPrincipalId, actorExternalUserId: undefined, // Desktop path — no channel-native ID channel, guardianPrincipalId: actorContext.guardianPrincipalId, }, userText: undefined, }); // 4. Map the canonical result if (decisionResult.applied) { if (decisionResult.resolverFailed) { return { ok: true, applied: false, reason: "resolver_failed", resolverFailureReason: decisionResult.resolverFailureReason, requestId: decisionResult.requestId, }; } return { ok: true, applied: true, requestId: decisionResult.requestId, replyText: decisionResult.resolverReplyText, decidedAction: decisionResult.decidedAction, }; } return { ok: true, applied: false, reason: decisionResult.reason, requestId, }; }