Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/ack-pay-approval-model.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 39 additions & 0 deletions docs/ack-pay/hitl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions packages/ack-pay/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
75 changes: 75 additions & 0 deletions packages/ack-pay/src/payment-approval.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
43 changes: 43 additions & 0 deletions packages/ack-pay/src/payment-approval.ts
Original file line number Diff line number Diff line change
@@ -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)
}