Skip to content

draft: ApprovalRecord schema (#5)#6

Draft
nmrtn wants to merge 2 commits into
mainfrom
feat/approval-record-schema
Draft

draft: ApprovalRecord schema (#5)#6
nmrtn wants to merge 2 commits into
mainfrom
feat/approval-record-schema

Conversation

@nmrtn
Copy link
Copy Markdown
Owner

@nmrtn nmrtn commented May 29, 2026

Draft schema for the in-chat approval receipt discussed in #5. Type-only for now: no emission wired into agent.ts, no tests beyond tsc --noEmit.

Based on @rpelevin's proposal in #5 with two refinements:

  • actor is nullable in v1. blacktea today is mostly one human / one agent / one wallet, so forcing a value would mean people invent one. Field reserved for the multi-tenant case.
  • Route enum keeps blacktea's existing names: allow | reject | human_review. reject and deny are the same thing here and the rest of the codebase already uses reject. revise is reserved (returns unsupported in v1) per the discussion.

In this PR

  • src/approval-record.ts: ApprovalRecord interface plus ApprovalRoute and ApprovalFinalState enums.
  • src/index.ts: re-exports.

Not in this PR

  • Emission wiring in agent.ts (lands next).
  • Tests (land with the emission PR).
  • Signing (deferred until the shape is stable, per the discussion).

Open questions for v1

  1. Exact fields hashed into params_hash. Proposing sha256(method + "\n" + url + "\n" + amount + "\n" + currency + "\n" + (recipient_wallet ?? "") + "\n" + stableStringify(body)). Order-stable, no headers (servers can vary headers without changing the spend).
  2. Whether the record lives in the same audit JSONL (tagged event: "approval_record") or in a separate file. Leaning same file for v1.
  3. Whether denied covers both human-deny and policy-reject, or whether those need separate states. Probably keep denied as the human path and use final_state: "settled" with route: "reject" for policy-reject (policy rejects don't go through approval at all, so the question may be moot).

Closes #5.

cc @rpelevin — keen on your read, especially on the params_hash input shape.

Copy link
Copy Markdown

Thanks Nicolas — this is the right shape for v1.

On params_hash, I agree with keeping it settlement-relevant and not including headers by default. Headers drift too much across clients/proxies, and if a header actually changes settlement semantics it should probably be promoted into the explicit request/body shape before hashing.

I would make two small adjustments:

  1. Hash a canonical object rather than a joined string, to avoid delimiter and coercion ambiguity.

Something like:

sha256(JCS({
  method: method.toUpperCase(),
  url,
  amount,
  currency,
  recipient_wallet: recipient_wallet ?? null,
  body
}))
  1. Version the hash input shape now, either by prefixing the value (sha256:jcs-v1:<hex>) or adding a companion params_hash_alg / params_hash_version field. That will save pain if headers, idempotency keys, or a different canonicalization rule become important later.

Same audit JSONL with event: "approval_record" feels right for v1. It keeps the record append-only and queryable without inventing storage too early.

On states, I would keep policy reject out of ApprovalRecord unless a staged approval actually exists. For the human path, route: "reject" + final_state: "denied" is clean. For approved execution, route: "allow" then final_state: "settled" | "failed". That keeps policy rejection from pretending to be a human approval decision.

Nullable actor is a reasonable v1 compromise as long as the field stays present in the object. The important thing is that the schema already has a home for the multi-agent/shared-wallet case.

nmrtn and others added 2 commits May 30, 2026 23:25
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) <noreply@anthropic.com>
Applying @rpelevin's feedback from PR #6:

- params_hash format: tagged string `sha256:jcs-v1:<hex>` 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) <noreply@anthropic.com>
@nmrtn nmrtn force-pushed the feat/approval-record-schema branch from 71cf02f to d6f6188 Compare May 30, 2026 21:26
@nmrtn
Copy link
Copy Markdown
Owner Author

nmrtn commented May 30, 2026

Applied all five, pushed in d6f6188 (also rebased on main to pick up the 0.1.3 work).

  • params_hash is now a tagged string sha256:jcs-v1:<hex>. Atomic alg+version+value, no mix-and-match if the input shape ever changes. Input goes through JCS (RFC 8785) over a canonical object instead of string concat, killing the delimiter and number-coercion ambiguity.
  • final_state is scoped to the human-in-the-loop path. Policy rejects (policy fires reject from the start) never mint an ApprovalRecord; those stay in the audit stream as payment_denied. Doc comment spells it out.
  • event: "approval_record" in the existing audit JSONL is the v1 storage path. Called out inline.
  • actor stays string | null as you accepted.
  • Headers excluded from the hash, per your point.

Switch back to ready-for-review whenever you want to take another pass. Implementation (emission in agent.ts + tests + the JCS dep) is the next PR; I'd rather land the schema first and let the shape settle.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Approval decision record: bind a verifiable receipt to in-chat approvals

2 participants