Skip to content

draft: ApprovalRecord schema (#5)#6

Merged
nmrtn merged 2 commits into
mainfrom
feat/approval-record-schema
Jun 1, 2026
Merged

draft: ApprovalRecord schema (#5)#6
nmrtn merged 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.

@nmrtn nmrtn marked this pull request as ready for review June 1, 2026 08:46
@nmrtn nmrtn merged commit b6a0ee4 into main Jun 1, 2026
1 check passed
@nmrtn nmrtn deleted the feat/approval-record-schema branch June 1, 2026 08:46
nmrtn added a commit that referenced this pull request Jun 1, 2026
The ApprovalRecord schema merged on main now exports types from the
package barrel. Documents the "do not publish until implementation lands"
discipline explicitly so the next 0.1.5 ships schema + emission together
instead of a type-only design preview reaching npm by accident.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rpelevin
Copy link
Copy Markdown

rpelevin commented Jun 1, 2026

Nicolas — one implementation-boundary question before the emission PR.

I would keep ApprovalRecord anchored to the staged intent plus the human approval decision, not to final settlement.

My read:

  • staged intent defines what is being approved;
  • human decision grants or denies authority for that exact params_hash;
  • expires_at decides whether that authority is still usable;
  • settlement success/failure is linked afterward as outcome or audit state, not the thing that creates the approval record.

That keeps the approval record useful even when the payment never settles: denied, expired, failed rail, or later retry still has a durable record of what authority existed before dispatch.

Does that match how you plan to wire agent.ts, or are you thinking the first emitted approval_record should wait until settle?

@nmrtn
Copy link
Copy Markdown
Owner Author

nmrtn commented Jun 1, 2026

Yep, that's the plan. Record is born at approval_staged, anchored to the staged intent + params_hash + expires_at. Settlement is one possible terminal outcome (settled / failed), not the thing that creates the record.

One implementation detail I want to flag in case you'd argue with it:

I'm planning to write the record append-once per state transition to the audit JSONL, same shape as the rest of blacktea's events (event: "approval_record", payload is the full record with the lifecycle state at that moment). Querying "current state of intent X" means reading the latest entry for that id. Event-sourced rather than mutated-in-place.

That covers the six states without anything needing to be mutated: staged on the first write, approved or denied on the human decision, expired if the timeout elapses, then settled or failed after the settle attempt. Denied and expired paths still produce a complete record without any settlement happening, which is the case you specifically called out.

Anything you'd push back on before I write the emission?

@rpelevin
Copy link
Copy Markdown

rpelevin commented Jun 3, 2026

I would not push back on append-once per state transition. That is cleaner than mutating one record in place, because the event stream becomes the verifier trail.

The invariant I would lock before emission is:

  • stable intent_id / record identity across every approval_record event;
  • params_hash and expires_at set at staging and not reinterpreted later;
  • each state transition carrying state, timestamp, and source/reason for the transition;
  • settlement/failure attached afterward as outcome state, not as the thing that creates authority.

So the latest event can answer “current state of intent X,” but replaying the stream should answer the audit question: what was staged, what exact params were approved or denied, whether the approval was still valid, and what happened after dispatch.

That model matches the boundary I’d want before emission.

@nmrtn
Copy link
Copy Markdown
Owner Author

nmrtn commented Jun 3, 2026

Really appreciate this. The invariants you listed are the right ones, and the "event stream becomes the verifier trail" framing is the cleanest articulation of why append-once beats mutate-in-place.

Mapping each one against where the schema sits today:

  1. Stable intent_id across every event: already there. Emission discipline, not a schema change.

  2. params_hash and expires_at set at staging and never reinterpreted: both fields exist; the emission code will read them once at staging and carry the same values verbatim on every subsequent event. I'll add an explicit immutability note to the docstring when the implementation lands so nobody recomputes them by accident later.

  3. Per-transition state, timestamp, source, reason: final_state covers state, the AuditEvent wrapper carries ts per write (so timestamp comes along for free), and source / reason is the one piece the schema is missing today. Plan is a small sub-object:

transition: {
  source: "policy" | "human" | "timeout" | "rail";
  reason?: string;  // free-form, optional ("amount_gte: 100", "user rejected in chat", "signature_rejected", etc.)
};

So a denied event from a human via MCP carries transition: { source: "human", reason: "user rejected in chat" }; a failed event carries transition: { source: "rail", reason: "signature_rejected" }; a timeout carries transition: { source: "timeout" }. The audit replay then tells exactly the story you described: what was staged, what params got approved or denied, whether the approval was still valid, and what happened after.

  1. Settlement and failure as outcome state, not authority-creating: aligned. The authority record is born at staging; settle outcomes just append on top.

I'll land the transition sub-object as part of the emission PR rather than a cold schema bump, so the type change ships next to the code that actually populates it. Keeps main honest about what's emitted versus what's just typed.

Thanks for staying with this. The schema is meaningfully better than what we would have shipped without your eye on it.

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