From e6170aee45f810387a7211a75b4093f9d6cd0d9e Mon Sep 17 00:00:00 2001 From: Nicolas Martin Date: Fri, 29 May 2026 23:21:48 +0200 Subject: [PATCH 1/2] draft: ApprovalRecord schema type (closes #5) Type-only design draft for the in-chat approval receipt discussed in #5. Not yet emitted by agent.ts; implementation lands in a follow-up PR. - ApprovalRoute: "allow" | "reject" | "human_review" (revise reserved) - ApprovalFinalState lifecycle: staged | approved | denied | expired | settled | failed - ApprovalRecord interface with intent_id, mcp_server/tool provenance, nullable actor, settlement target, params_hash binding, expires_at, created_at, route, final_state, audit_event_refs Co-Authored-By: Claude Opus 4.7 (1M context) --- src/approval-record.ts | 85 ++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 5 +++ 2 files changed, 90 insertions(+) create mode 100644 src/approval-record.ts diff --git a/src/approval-record.ts b/src/approval-record.ts new file mode 100644 index 0000000..9ceea52 --- /dev/null +++ b/src/approval-record.ts @@ -0,0 +1,85 @@ +/** + * ApprovalRecord: the structured receipt blacktea mints when a payment + * crosses the approval boundary. + * + * The audit log keeps the full event stream. An ApprovalRecord sits on top + * of it as the queryable decision boundary: one record per intent_id, + * append-only, written into the same audit JSONL for v1 (signing deferred + * until the shape is stable). + * + * Status: design draft, not yet emitted by agent.ts. Implementation lands + * in a follow-up PR. See issue #5. + */ + +/** + * Decision route. `revise` is reserved for a future counter-offer flow + * (returning unsupported in v1 if anyone tries it). + */ +export type ApprovalRoute = "allow" | "reject" | "human_review"; + +/** Lifecycle state of an in-chat approval. */ +export type ApprovalFinalState = + | "staged" + | "approved" + | "denied" + | "expired" + | "settled" + | "failed"; + +export interface ApprovalRecord { + /** Stable id from the staged intent. */ + intent_id: string; + + /** + * Provenance. Which MCP server + tool produced the intent. Null when the + * call did not come through MCP (SDK or CLI usage). + */ + mcp_server: string | null; + tool: string | null; + + /** + * Who initiated the spend. Null in v1: blacktea assumes one human, one + * agent, one wallet. Becomes required once shared-wallet / multi-tenant + * setups land. + */ + actor: string | null; + + /** Settlement target. */ + amount: number; + currency: string; + recipient_wallet?: string; + recipient_url: string; + + /** Why approval was needed and which policy rule fired. */ + reason: string; + rule_fired: string; + + /** + * Hash of the exact settlement-relevant request. Binds approve to settle: + * if the request changes between approval and settle, the record is + * invalid and settle must refuse. + * + * v1 input shape (subject to review): + * sha256(method + "\n" + url + "\n" + amount + "\n" + currency + "\n" + + * (recipient_wallet ?? "") + "\n" + stableStringify(body)) + */ + params_hash: string; + + /** Policy-driven expiry. Honors decision.timeout_seconds, not a fixed TTL. */ + expires_at: string; + + /** ISO-8601 timestamp of when the record was minted. */ + created_at: string; + + /** Decision route. */ + route: ApprovalRoute; + + /** Current lifecycle state. */ + final_state: ApprovalFinalState; + + /** + * Audit event ids that produced this record. The record stays tight, + * the audit log stays the full trail. + */ + audit_event_refs: string[]; +} diff --git a/src/index.ts b/src/index.ts index a75748f..482115b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,6 +47,11 @@ export { isBlackteaError, } from "./errors.js"; +export type { + ApprovalFinalState, + ApprovalRecord, + ApprovalRoute, +} from "./approval-record.js"; export type { Policy, PolicyAction, PolicyCondition, PolicyRule } from "./policy/schema.js"; export { PolicySchema } from "./policy/schema.js"; export { loadPolicy } from "./policy/load.js"; From d6f618839055af7eadc23a63d5af49cbed8ba9ee Mon Sep 17 00:00:00 2001 From: Nicolas Martin Date: Sat, 30 May 2026 23:26:29 +0200 Subject: [PATCH 2/2] draft: tighten ApprovalRecord schema per #6 review Applying @rpelevin's feedback from PR #6: - params_hash format: tagged string `sha256:jcs-v1:` so the algorithm and canonicalization version are atomic with the value. Input shape is JCS-canonicalized (RFC 8785) instead of string concatenation, removing delimiter and number-coercion ambiguity. Headers stay excluded. - final_state: explicitly scoped to the human-in-the-loop path. Policy rejects (the policy fires `reject` from the start) never produce an ApprovalRecord; they stay in the audit stream as `payment_denied`. - Storage clarified inline: append-only in the audit JSONL tagged with `event: "approval_record"`. Type-only, no emission wiring yet. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/approval-record.ts | 47 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/src/approval-record.ts b/src/approval-record.ts index 9ceea52..02f92e7 100644 --- a/src/approval-record.ts +++ b/src/approval-record.ts @@ -1,11 +1,15 @@ /** * ApprovalRecord: the structured receipt blacktea mints when a payment - * crosses the approval boundary. + * crosses the approval boundary AND a human is in the loop. * - * The audit log keeps the full event stream. An ApprovalRecord sits on top - * of it as the queryable decision boundary: one record per intent_id, - * append-only, written into the same audit JSONL for v1 (signing deferred - * until the shape is stable). + * Scope: a record exists only when the policy returned `approval` and a + * staged intent was created. Policy rejects (the policy fires `reject` + * from the start) never produce an ApprovalRecord; those are captured in + * the audit stream as `payment_denied` events. This keeps the record + * scoped to "a human made a decision," not "the policy made a decision." + * + * Storage: append-only in the same audit JSONL for v1, tagged with + * `event: "approval_record"`. Signing deferred until the shape is stable. * * Status: design draft, not yet emitted by agent.ts. Implementation lands * in a follow-up PR. See issue #5. @@ -17,7 +21,18 @@ */ export type ApprovalRoute = "allow" | "reject" | "human_review"; -/** Lifecycle state of an in-chat approval. */ +/** + * Lifecycle state of an in-chat approval. Every state requires a staged + * intent to have existed; policy rejects without a stage never reach this + * enum (they live in the audit stream as `payment_denied`). + * + * staged -> waiting for the human + * approved -> human approved, settle in flight + * denied -> human rejected + * expired -> approval window elapsed without a decision + * settled -> post-approval settle succeeded (terminal happy path) + * failed -> post-approval settle failed (rail down, signature, etc) + */ export type ApprovalFinalState = | "staged" | "approved" @@ -59,9 +74,23 @@ export interface ApprovalRecord { * if the request changes between approval and settle, the record is * invalid and settle must refuse. * - * v1 input shape (subject to review): - * sha256(method + "\n" + url + "\n" + amount + "\n" + currency + "\n" + - * (recipient_wallet ?? "") + "\n" + stableStringify(body)) + * Format: a tagged string `::`. The + * tag is atomic with the value so callers can't mix records hashed under + * different rules. v1 uses `sha256:jcs-v1`: + * + * "sha256:jcs-v1:" + sha256(JCS({ + * method: method.toUpperCase(), + * url, + * amount, + * currency, + * recipient_wallet: recipient_wallet ?? null, + * body + * })) + * + * JCS = RFC 8785 canonical JSON. Headers are intentionally excluded: + * they drift across clients and proxies, and anything that genuinely + * changes settlement semantics should be promoted into the explicit + * request shape before hashing. */ params_hash: string;