Skip to content
Closed
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"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is the line that makes the approval types part of the published API surface. Once released, removing or changing these types is a breaking change. That's the main reason I'd prefer to validate the shapes against a real consumer before committing them here.

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 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Design question: PaymentApprovalRequest has 5 of 7 fields optional.

At this level of optionality, does the type provide enough structure to be useful as a shared contract?

Integrators with strict approval requirements (e.g., mandatory requesterDid + expiresAt for compliance) would need to narrow these anyway, and integrators with loose requirements might just use their own ad-hoc shapes.

Curious if you had specific integration patterns in mind that guided the required vs. optional choices.

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(
Copy link
Copy Markdown
Contributor

@ak68a ak68a May 25, 2026

Choose a reason for hiding this comment

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

This refactoring is a nice win regardless of the approval types discussion .. extracting timestampSchema removes duplication from paymentRequestSchema.expiresAt.

Would you be open to splitting this into its own PR?

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(),
})