From 49fb4625a7acbd2aed96bd8070b76b39f831a344 Mon Sep 17 00:00:00 2001 From: airdropzamani Date: Thu, 3 Sep 2026 05:41:14 +0300 Subject: [PATCH 1/2] feat(ack-pay): add HITL approval request and decision types Give demos a shared PaymentApprovalRequest / PaymentApprovalDecision shape for pre-execution human sign-off, without a policy engine in ACK core. Fixes #93 Co-authored-by: Cursor --- .changeset/ack-pay-approval-model.md | 10 +++ docs/ack-pay/hitl.mdx | 39 ++++++++++++ packages/ack-pay/src/index.ts | 1 + packages/ack-pay/src/payment-approval.test.ts | 63 +++++++++++++++++++ packages/ack-pay/src/payment-approval.ts | 45 +++++++++++++ 5 files changed, 158 insertions(+) create mode 100644 .changeset/ack-pay-approval-model.md create mode 100644 packages/ack-pay/src/payment-approval.test.ts create mode 100644 packages/ack-pay/src/payment-approval.ts 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..96befe8f 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:00: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..aab5cdc9 --- /dev/null +++ b/packages/ack-pay/src/payment-approval.test.ts @@ -0,0 +1,63 @@ +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) + }) +}) + +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) + }) +}) diff --git a/packages/ack-pay/src/payment-approval.ts b/packages/ack-pay/src/payment-approval.ts new file mode 100644 index 00000000..4aebecf8 --- /dev/null +++ b/packages/ack-pay/src/payment-approval.ts @@ -0,0 +1,45 @@ +import * as v from "valibot" + +const isoTimestamp = v.pipe( + v.string(), + v.check((input) => !Number.isNaN(new Date(input).getTime()), "Invalid date"), +) + +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) +} From 926470377b442a9b89c0696eb507b48b6e042088 Mon Sep 17 00:00:00 2001 From: airdropzamani Date: Thu, 3 Sep 2026 06:46:26 +0300 Subject: [PATCH 2/2] fix(ack-pay): tighten HITL approval timestamp examples and validation Require ISO date-times via valibot isoTimestamp (reject date-only), and keep the docs decision inside the approval request expiry window. Co-authored-by: Cursor --- docs/ack-pay/hitl.mdx | 2 +- packages/ack-pay/src/payment-approval.test.ts | 12 ++++++++++++ packages/ack-pay/src/payment-approval.ts | 6 ++---- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/ack-pay/hitl.mdx b/docs/ack-pay/hitl.mdx index 96befe8f..0fdb3269 100644 --- a/docs/ack-pay/hitl.mdx +++ b/docs/ack-pay/hitl.mdx @@ -44,7 +44,7 @@ const approvalRequest: PaymentApprovalRequest = { paymentOptionId: "usdc-base", requesterDid: "did:web:agent.example.com", reason: "Amount exceeds agent spend policy", - expiresAt: "2026-09-03T12:00:00.000Z", + expiresAt: "2026-09-03T12:30:00.000Z", } const approvalDecision: PaymentApprovalDecision = { diff --git a/packages/ack-pay/src/payment-approval.test.ts b/packages/ack-pay/src/payment-approval.test.ts index aab5cdc9..f0df2d2d 100644 --- a/packages/ack-pay/src/payment-approval.test.ts +++ b/packages/ack-pay/src/payment-approval.test.ts @@ -38,6 +38,15 @@ describe("isPaymentApprovalRequest", () => { 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", () => { @@ -59,5 +68,8 @@ describe("isPaymentApprovalDecision", () => { 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 index 4aebecf8..ed5a4fc8 100644 --- a/packages/ack-pay/src/payment-approval.ts +++ b/packages/ack-pay/src/payment-approval.ts @@ -1,9 +1,7 @@ import * as v from "valibot" -const isoTimestamp = v.pipe( - v.string(), - v.check((input) => !Number.isNaN(new Date(input).getTime()), "Invalid date"), -) +/** 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(),