/**
* Shopify Agentic MCP Gateway — Guardrail Middleware
*
* AI hallucination guard. Validates agent-claimed data against actual
* Shopify data to prevent incorrect prices, inventory, mandate violations,
* and invalid checkout state transitions.
*/
import type {
CheckoutStatus,
GuardrailCheckResult,
Mandate,
IntentPayload,
} from "../types.js";
export interface GuardrailContext {
/** Price validation */
claimedPrice?: number;
actualPrice?: number;
priceTolerance?: number;
/** Inventory validation */
variantId?: string;
requestedQty?: number;
actualQty?: number;
/** Mandate validation */
mandate?: Mandate;
checkoutTotal?: number;
/** Checkout state transition validation */
currentCheckoutStatus?: CheckoutStatus;
nextCheckoutStatus?: CheckoutStatus;
}
/**
* Valid checkout status transitions.
* Checkout can only move forward through these transitions:
*
* incomplete -> requires_escalation (needs buyer input)
* incomplete -> ready_for_complete (all info gathered)
* requires_escalation -> incomplete (escalation resolved, back to building)
* requires_escalation -> ready_for_complete (escalation resolved, ready)
*
* Note: ready_for_complete is terminal — cannot go backwards.
*/
const VALID_CHECKOUT_TRANSITIONS: ReadonlyMap<
CheckoutStatus,
ReadonlySet<CheckoutStatus>
> = new Map([
[
"incomplete",
new Set<CheckoutStatus>(["requires_escalation", "ready_for_complete"]),
],
[
"requires_escalation",
new Set<CheckoutStatus>(["incomplete", "ready_for_complete"]),
],
["ready_for_complete", new Set<CheckoutStatus>()],
]);
export class Guardrail {
/**
* Validate that an agent's claimed price matches the actual price
* within an acceptable tolerance.
*
* @param claimed - The price the agent claims (minor units)
* @param actual - The actual price from Shopify (minor units)
* @param tolerance - Acceptable relative deviation (default 0.01 = 1%)
* @returns true if the claimed price is within tolerance
*/
validatePrice(
claimed: number,
actual: number,
tolerance: number = 0.01,
): boolean {
if (actual === 0) {
return claimed === 0;
}
const deviation = Math.abs(claimed - actual) / Math.abs(actual);
return deviation <= tolerance;
}
/**
* Validate that the requested quantity does not exceed available inventory.
*
* @param variantId - The product variant being checked (for logging context)
* @param requestedQty - The quantity the agent is trying to add/order
* @param actualQty - The actual available inventory from Shopify
* @returns true if the requested quantity is available
*/
validateInventory(
_variantId: string,
requestedQty: number,
actualQty: number,
): boolean {
if (requestedQty <= 0) {
return false;
}
return requestedQty <= actualQty;
}
/**
* Validate that the checkout total does not exceed the mandate's max_amount.
* Only applies to mandates with an IntentPayload.
*
* @param mandate - The AP2 mandate issued by the buyer's agent
* @param checkoutTotal - The actual checkout total (minor units)
* @returns true if the checkout total is within the mandate's limits
*/
validateMandateAmount(mandate: Mandate, checkoutTotal: number): boolean {
if (mandate.status !== "active") {
return false;
}
// Check expiration
const expiresAt = new Date(mandate.expires_at);
if (expiresAt <= new Date()) {
return false;
}
// For intent mandates, check max_amount
if (mandate.type === "intent") {
const payload = mandate.payload as IntentPayload;
return checkoutTotal <= payload.max_amount;
}
// For other mandate types, the amount check is handled differently
return true;
}
/**
* Validate that a checkout status transition is allowed.
*
* @param current - The current checkout status
* @param next - The proposed next checkout status
* @returns true if the transition is valid
*/
validateCheckoutTransition(
current: CheckoutStatus,
next: CheckoutStatus,
): boolean {
if (current === next) {
return true; // No-op transitions are allowed
}
const validNextStates = VALID_CHECKOUT_TRANSITIONS.get(current);
if (!validNextStates) {
return false;
}
return validNextStates.has(next);
}
/**
* Run all applicable guardrail checks based on the provided context.
* Any single failure causes the entire result to fail (strict mode).
*
* @param context - The guardrail context containing fields for each check
* @returns Aggregate result with individual check details
*/
runAllChecks(context: GuardrailContext): GuardrailCheckResult {
const checks: GuardrailCheckResult["checks"] = [];
// Price check
if (
context.claimedPrice !== undefined &&
context.actualPrice !== undefined
) {
const pricePassed = this.validatePrice(
context.claimedPrice,
context.actualPrice,
context.priceTolerance,
);
checks.push({
name: "price_validation",
passed: pricePassed,
message: pricePassed
? `Price ${context.claimedPrice} matches actual ${context.actualPrice}`
: `Price mismatch: claimed ${context.claimedPrice}, actual ${context.actualPrice}`,
});
}
// Inventory check
if (
context.variantId !== undefined &&
context.requestedQty !== undefined &&
context.actualQty !== undefined
) {
const inventoryPassed = this.validateInventory(
context.variantId,
context.requestedQty,
context.actualQty,
);
checks.push({
name: "inventory_validation",
passed: inventoryPassed,
message: inventoryPassed
? `Variant ${context.variantId}: ${context.requestedQty} requested, ${context.actualQty} available`
: `Insufficient inventory for variant ${context.variantId}: requested ${context.requestedQty}, available ${context.actualQty}`,
});
}
// Mandate check
if (
context.mandate !== undefined &&
context.checkoutTotal !== undefined
) {
const mandatePassed = this.validateMandateAmount(
context.mandate,
context.checkoutTotal,
);
checks.push({
name: "mandate_validation",
passed: mandatePassed,
message: mandatePassed
? `Checkout total ${context.checkoutTotal} within mandate limits`
: `Checkout total ${context.checkoutTotal} exceeds mandate limits or mandate is not active`,
});
}
// Checkout transition check
if (
context.currentCheckoutStatus !== undefined &&
context.nextCheckoutStatus !== undefined
) {
const transitionPassed = this.validateCheckoutTransition(
context.currentCheckoutStatus,
context.nextCheckoutStatus,
);
checks.push({
name: "checkout_transition_validation",
passed: transitionPassed,
message: transitionPassed
? `Transition ${context.currentCheckoutStatus} -> ${context.nextCheckoutStatus} is valid`
: `Invalid transition: ${context.currentCheckoutStatus} -> ${context.nextCheckoutStatus}`,
});
}
// Strict: all checks must pass
const passed = checks.length > 0 && checks.every((c) => c.passed);
return { passed, checks };
}
}