diff --git a/.changeset/ack-pay-approval-model.md b/.changeset/ack-pay-approval-model.md new file mode 100644 index 00000000..38968da6 --- /dev/null +++ b/.changeset/ack-pay-approval-model.md @@ -0,0 +1,10 @@ +--- +"@agentcommercekit/ack-pay": minor +--- + +Add a minimal HITL payment approval request/decision model for demos. + +`PaymentApprovalRequest` and `PaymentApprovalDecision` give examples a shared +object shape for pre-execution human sign-off without pulling a policy engine +into ACK core. Docs in `docs/ack-pay/hitl.mdx` show the request → decision → +receipt path. diff --git a/docs/ack-pay/hitl.mdx b/docs/ack-pay/hitl.mdx index 2f40e059..0fdb3269 100644 --- a/docs/ack-pay/hitl.mdx +++ b/docs/ack-pay/hitl.mdx @@ -27,3 +27,42 @@ Human oversight may be integrated at three key points in the payment lifecycle: !["Example Human Intervention"](/images/human.png) Integrating these Human-in-the-Loop mechanisms allows organizations to balance the efficiency of automation with the accountability and guardrails provided by human oversight. + +## Approval request and decision + +ACK-Pay does not run a policy engine. Demos and Payment Services that need a shared object model for pre-execution sign-off can use the optional types exported from `@agentcommercekit/ack-pay`: + +```ts +import type { + PaymentApprovalDecision, + PaymentApprovalRequest, +} from "@agentcommercekit/ack-pay" + +const approvalRequest: PaymentApprovalRequest = { + id: "approval-1", + paymentRequestId: "payment-123", + paymentOptionId: "usdc-base", + requesterDid: "did:web:agent.example.com", + reason: "Amount exceeds agent spend policy", + expiresAt: "2026-09-03T12:30:00.000Z", +} + +const approvalDecision: PaymentApprovalDecision = { + requestId: approvalRequest.id, + decision: "approved", + approverDid: "did:web:owner.example.com", + decidedAt: "2026-09-03T12:01:00.000Z", +} +``` + +`isPaymentApprovalRequest` / `isPaymentApprovalDecision` are type guards for the same shapes. + +### Example flow + +1. Client or agent constructs a Payment Request. +2. Policy (outside ACK) requires human sign-off → emit a `PaymentApprovalRequest`. +3. Owner or operator records a `PaymentApprovalDecision`. +4. On `approved`, the Payment Service executes and issues a Payment Receipt as usual. +5. On `denied`, skip execution; do not issue a receipt. + +This is a documentation and type boundary, not a workflow runtime. Wire `id` / `paymentRequestId` in your own store. diff --git a/packages/ack-pay/src/index.ts b/packages/ack-pay/src/index.ts index bd4091ff..a334198e 100644 --- a/packages/ack-pay/src/index.ts +++ b/packages/ack-pay/src/index.ts @@ -4,5 +4,6 @@ export * from "./errors" export * from "./create-signed-payment-request" export * from "./verify-payment-request-token" export * from "./payment-request" +export * from "./payment-approval" export * from "./receipt-claim-verifier" export * from "./verify-payment-receipt" diff --git a/packages/ack-pay/src/payment-approval.test.ts b/packages/ack-pay/src/payment-approval.test.ts new file mode 100644 index 00000000..f0df2d2d --- /dev/null +++ b/packages/ack-pay/src/payment-approval.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest" + +import { + isPaymentApprovalDecision, + isPaymentApprovalRequest, +} from "./payment-approval" + +const request = { + id: "approval-1", + paymentRequestId: "payment-123", + paymentOptionId: "usdc-base", + requesterDid: "did:web:agent.example.com", + reason: "Amount exceeds agent spend policy", + expiresAt: "2026-09-03T12:00:00.000Z", +} + +const decision = { + requestId: "approval-1", + decision: "approved" as const, + approverDid: "did:web:owner.example.com", + decidedAt: "2026-09-03T12:01:00.000Z", +} + +describe("isPaymentApprovalRequest", () => { + it("accepts a minimal request", () => { + expect( + isPaymentApprovalRequest({ + id: "a", + paymentRequestId: "p", + }), + ).toBe(true) + }) + + it("accepts a fully populated request", () => { + expect(isPaymentApprovalRequest(request)).toBe(true) + }) + + it("rejects missing paymentRequestId", () => { + expect(isPaymentApprovalRequest({ id: "a" })).toBe(false) + }) + + it("rejects date-only expiresAt values", () => { + expect( + isPaymentApprovalRequest({ + ...request, + expiresAt: "2026-09-03", + }), + ).toBe(false) + }) +}) + +describe("isPaymentApprovalDecision", () => { + it("accepts approved and denied", () => { + expect(isPaymentApprovalDecision(decision)).toBe(true) + expect( + isPaymentApprovalDecision({ + ...decision, + decision: "denied", + reason: "Out of policy", + }), + ).toBe(true) + }) + + it("rejects unknown decisions and invalid timestamps", () => { + expect(isPaymentApprovalDecision({ ...decision, decision: "maybe" })).toBe( + false, + ) + expect( + isPaymentApprovalDecision({ ...decision, decidedAt: "not-a-date" }), + ).toBe(false) + expect( + isPaymentApprovalDecision({ ...decision, decidedAt: "2026-09-03" }), + ).toBe(false) + }) +}) diff --git a/packages/ack-pay/src/payment-approval.ts b/packages/ack-pay/src/payment-approval.ts new file mode 100644 index 00000000..ed5a4fc8 --- /dev/null +++ b/packages/ack-pay/src/payment-approval.ts @@ -0,0 +1,43 @@ +import * as v from "valibot" + +/** ISO-8601 date-time strings only — not date-only (`YYYY-MM-DD`). */ +const isoTimestamp = v.pipe(v.string(), v.isoTimestamp()) + +export const paymentApprovalRequestSchema = v.object({ + id: v.string(), + paymentRequestId: v.string(), + paymentOptionId: v.optional(v.string()), + requesterDid: v.optional(v.string()), + reason: v.optional(v.string()), + expiresAt: v.optional(isoTimestamp), + metadata: v.optional(v.record(v.string(), v.unknown())), +}) + +export const paymentApprovalDecisionSchema = v.object({ + requestId: v.string(), + decision: v.picklist(["approved", "denied"]), + approverDid: v.optional(v.string()), + reason: v.optional(v.string()), + decidedAt: isoTimestamp, + metadata: v.optional(v.record(v.string(), v.unknown())), +}) + +export type PaymentApprovalRequest = v.InferOutput< + typeof paymentApprovalRequestSchema +> +export type PaymentApprovalDecision = v.InferOutput< + typeof paymentApprovalDecisionSchema +> +export type PaymentApprovalDecisionKind = PaymentApprovalDecision["decision"] + +export function isPaymentApprovalRequest( + value: unknown, +): value is PaymentApprovalRequest { + return v.is(paymentApprovalRequestSchema, value) +} + +export function isPaymentApprovalDecision( + value: unknown, +): value is PaymentApprovalDecision { + return v.is(paymentApprovalDecisionSchema, value) +}