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
5 changes: 5 additions & 0 deletions .changeset/add-approval-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agentcommercekit/ack-pay": patch
---

Add approval request and decision model types.
41 changes: 41 additions & 0 deletions docs/ack-pay/hitl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,45 @@ Human oversight may be integrated at three key points in the payment lifecycle:

!["Example Human Intervention"](/images/human.png)

## Approval Request and Decision Shape

ACK-Pay keeps approval orchestration outside the core protocol. Payment Services,
Client applications, and policy systems can use the exported
`PaymentApprovalRequest` and `PaymentApprovalDecision` types as a minimal shared
shape when a payment must pause for a human or owner-controlled policy decision.

```ts
import type {
PaymentApprovalDecision,
PaymentApprovalRequest,
} from "@agentcommercekit/ack-pay"

const approvalRequest: PaymentApprovalRequest = {
id: "approval_123",
paymentRequestId: "payment_request_123",
paymentOptionId: "usd-base-usdc",
requesterDid: "did:web:agent.example.com",
reason: "Payment exceeds the autonomous spend limit",
expiresAt: "<FUTURE_ISO_TIMESTAMP>",
metadata: {
policyRef: "policy://merchant-spend-v3",
},
}

const approvalDecision: PaymentApprovalDecision = {
requestId: approvalRequest.id,
decision: "approved",
approverDid: "did:web:owner.example.com",
decidedAt: new Date().toISOString(),
metadata: {
ticketId: "risk-review-456",
},
}
```

Applications decide how these objects are transported, authenticated, stored, and
bound to execution. A denied decision should stop before signing or settlement;
an approved decision can be recorded in receipt metadata or an application audit
log as supporting evidence.

Integrating these Human-in-the-Loop mechanisms allows organizations to balance the efficiency of automation with the accountability and guardrails provided by human oversight.
1 change: 1 addition & 0 deletions packages/ack-pay/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from "./create-payment-request-token"
export * from "./errors"
export * from "./create-signed-payment-request"
export * from "./verify-payment-request-token"
export * from "./payment-approval"
export * from "./payment-request"
export * from "./receipt-claim-verifier"
export * from "./verify-payment-receipt"
98 changes: 98 additions & 0 deletions packages/ack-pay/src/payment-approval.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import * as v from "valibot"
import { describe, expect, it } from "vitest"

import * as valibot from "./schemas/valibot"
import * as zodv3 from "./schemas/zod/v3"
import * as zodv4 from "./schemas/zod/v4"

const schemas = {
valibot: {
approvalRequest: (value: unknown) =>
v.safeParse(valibot.paymentApprovalRequestSchema, value).success,
approvalDecision: (value: unknown) =>
v.safeParse(valibot.paymentApprovalDecisionSchema, value).success,
},
zodv3: {
approvalRequest: (value: unknown) =>
zodv3.paymentApprovalRequestSchema.safeParse(value).success,
approvalDecision: (value: unknown) =>
zodv3.paymentApprovalDecisionSchema.safeParse(value).success,
},
zodv4: {
approvalRequest: (value: unknown) =>
zodv4.paymentApprovalRequestSchema.safeParse(value).success,
approvalDecision: (value: unknown) =>
zodv4.paymentApprovalDecisionSchema.safeParse(value).success,
},
}

describe.each(Object.entries(schemas))(
"payment approval schemas (%s)",
(_name, schema) => {
it("validates approval requests", () => {
expect(
schema.approvalRequest({
id: "approval-1",
paymentRequestId: "payment-request-1",
paymentOptionId: "usdc-base",
requesterDid: "did:web:merchant.example",
reason: "Amount exceeds automated policy limit.",
expiresAt: "2026-01-01T00:00:00.000Z",
metadata: { policyId: "spend-limit" },
}),
).toBe(true)
})

it("accepts Date timestamps in approval requests", () => {
expect(
schema.approvalRequest({
id: "approval-1",
paymentRequestId: "payment-request-1",
expiresAt: new Date("2026-01-01T00:00:00.000Z"),
}),
).toBe(true)
})

it("rejects malformed approval requests", () => {
expect(
schema.approvalRequest({
id: "approval-1",
requesterDid: "not-a-did",
}),
).toBe(false)
})

it("validates approval decisions", () => {
expect(
schema.approvalDecision({
requestId: "approval-1",
decision: "approved",
approverDid: "did:web:operator.example",
reason: "Reviewed by operator.",
decidedAt: "2026-01-01T00:01:00.000Z",
metadata: { ticketId: "ticket-1" },
}),
).toBe(true)
})

it("accepts Date timestamps in approval decisions", () => {
expect(
schema.approvalDecision({
requestId: "approval-1",
decision: "approved",
decidedAt: new Date("2026-01-01T00:01:00.000Z"),
}),
).toBe(true)
})

it("rejects malformed approval decisions", () => {
expect(
schema.approvalDecision({
requestId: "approval-1",
decision: "pending",
decidedAt: "2026-01-01T00:01:00.000Z",
}),
).toBe(false)
})
},
)
29 changes: 29 additions & 0 deletions packages/ack-pay/src/payment-approval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* A request for human or policy-system approval before a payment is executed.
*
* ACK-Pay does not prescribe an approval workflow. This type gives applications
* and demos a shared object shape for approval-required paths.
*/
export interface PaymentApprovalRequest {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add runtime schemas for approval models

Because this introduces new exported ACK-Pay model types, the repo’s schema contract requires matching Valibot and Zod v3/v4 schemas for new types. As written, applications receiving these approval objects over the wire can import only TypeScript interfaces, so runtime validation through @agentcommercekit/ack-pay/schemas/* is unavailable and inconsistent with existing payment request/receipt models; please add schemas for both PaymentApprovalRequest and PaymentApprovalDecision.

Useful? React with 👍 / 👎.

id: string
paymentRequestId: string
paymentOptionId?: string
requesterDid?: string
reason?: string
expiresAt?: string
metadata?: Record<string, unknown>
}

export type PaymentApprovalDecisionValue = "approved" | "denied"

/**
* The result of a human or policy-system approval request.
*/
export interface PaymentApprovalDecision {
requestId: string
decision: PaymentApprovalDecisionValue
approverDid?: string
reason?: string
decidedAt: string
metadata?: Record<string, unknown>
}
36 changes: 30 additions & 6 deletions packages/ack-pay/src/schemas/valibot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import * as v from "valibot"

const urlOrDidUri = v.union([v.pipe(v.string(), v.url()), didUriSchema])

const timestampSchema = v.pipe(
v.union([v.date(), v.string()]),
v.transform((input) => new Date(input).toISOString()),
)

export const paymentOptionSchema = v.object({
id: v.string(),
amount: v.union([v.pipe(v.number(), v.integer(), v.gtValue(0)), v.string()]),
Expand All @@ -19,12 +24,7 @@ export const paymentRequestSchema = v.object({
id: v.string(),
description: v.optional(v.string()),
serviceCallback: v.optional(v.pipe(v.string(), v.url())),
expiresAt: v.optional(
v.pipe(
v.union([v.date(), v.string()]),
v.transform((input) => new Date(input).toISOString()),
),
),
expiresAt: v.optional(timestampSchema),
paymentOptions: v.pipe(
v.tupleWithRest([paymentOptionSchema], paymentOptionSchema),
v.nonEmpty(),
Expand All @@ -36,3 +36,27 @@ export const paymentReceiptClaimSchema = v.object({
paymentOptionId: v.string(),
metadata: v.optional(v.record(v.string(), v.unknown())),
})

export const paymentApprovalRequestSchema = v.object({
id: v.string(),
paymentRequestId: v.string(),
paymentOptionId: v.optional(v.string()),
requesterDid: v.optional(didUriSchema),
reason: v.optional(v.string()),
expiresAt: v.optional(timestampSchema),
metadata: v.optional(v.record(v.string(), v.unknown())),
})

export const paymentApprovalDecisionValueSchema = v.union([
v.literal("approved"),
v.literal("denied"),
])

export const paymentApprovalDecisionSchema = v.object({
requestId: v.string(),
decision: paymentApprovalDecisionValueSchema,
approverDid: v.optional(didUriSchema),
reason: v.optional(v.string()),
decidedAt: timestampSchema,
metadata: v.optional(v.record(v.string(), v.unknown())),
})
33 changes: 29 additions & 4 deletions packages/ack-pay/src/schemas/zod/v3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { z } from "zod/v3"

const urlOrDidUri = z.union([z.string().url(), didUriSchema])

const timestampSchema = z
.union([z.date(), z.string()])
.transform((val) => new Date(val).toISOString())

export const paymentOptionSchema = z.object({
id: z.string(),
amount: z.union([z.number().int().positive(), z.string()]),
Expand All @@ -19,10 +23,7 @@ export const paymentRequestSchema = z.object({
id: z.string(),
description: z.string().optional(),
serviceCallback: z.string().url().optional(),
expiresAt: z
.union([z.date(), z.string()])
.transform((val) => new Date(val).toISOString())
.optional(),
expiresAt: timestampSchema.optional(),
paymentOptions: z.array(paymentOptionSchema).nonempty(),
})

Expand All @@ -31,3 +32,27 @@ export const paymentReceiptClaimSchema = z.object({
paymentOptionId: z.string(),
metadata: z.record(z.string(), z.unknown()).optional(),
})

export const paymentApprovalRequestSchema = z.object({
id: z.string(),
paymentRequestId: z.string(),
paymentOptionId: z.string().optional(),
requesterDid: didUriSchema.optional(),
reason: z.string().optional(),
expiresAt: timestampSchema.optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})

export const paymentApprovalDecisionValueSchema = z.union([
z.literal("approved"),
z.literal("denied"),
])

export const paymentApprovalDecisionSchema = z.object({
requestId: z.string(),
decision: paymentApprovalDecisionValueSchema,
approverDid: didUriSchema.optional(),
reason: z.string().optional(),
decidedAt: timestampSchema,
metadata: z.record(z.string(), z.unknown()).optional(),
})
33 changes: 29 additions & 4 deletions packages/ack-pay/src/schemas/zod/v4.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import * as z from "zod/v4"

const urlOrDidUri = z.union([z.url(), didUriSchema])

const timestampSchema = z
.union([z.date(), z.string()])
.transform((val) => new Date(val).toISOString())

export const paymentOptionSchema = z.object({
id: z.string(),
amount: z.union([z.number().int().positive(), z.string()]),
Expand All @@ -19,10 +23,7 @@ export const paymentRequestSchema = z.object({
id: z.string(),
description: z.string().optional(),
serviceCallback: z.url().optional(),
expiresAt: z
.union([z.date(), z.string()])
.transform((val) => new Date(val).toISOString())
.optional(),
expiresAt: timestampSchema.optional(),
paymentOptions: z.array(paymentOptionSchema).nonempty(),
})

Expand All @@ -31,3 +32,27 @@ export const paymentReceiptClaimSchema = z.object({
paymentOptionId: z.string(),
metadata: z.record(z.string(), z.unknown()).optional(),
})

export const paymentApprovalRequestSchema = z.object({
id: z.string(),
paymentRequestId: z.string(),
paymentOptionId: z.string().optional(),
requesterDid: didUriSchema.optional(),
reason: z.string().optional(),
expiresAt: timestampSchema.optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})

export const paymentApprovalDecisionValueSchema = z.union([
z.literal("approved"),
z.literal("denied"),
])

export const paymentApprovalDecisionSchema = z.object({
requestId: z.string(),
decision: paymentApprovalDecisionValueSchema,
approverDid: didUriSchema.optional(),
reason: z.string().optional(),
decidedAt: timestampSchema,
metadata: z.record(z.string(), z.unknown()).optional(),
})