Record a refund
A support agent wants to refund a charge, and a person, Alice, approves it first. You will record
three entries: Alice’s approval, the agent’s intent that cites the approval by its entry hash
and names the policy and rule it relied on, and the outcome once the refund ran. Then you read
them back and verify the stream against its pinned fingerprint. Pick a language once: every tab on
the page follows it.
Prerequisites
Section titled “Prerequisites”- A shell with
jqandopenssl(for the CLI and REST tabs). - The tool for your language:
cargo install tenbri-cli --version 0.1.0-beta.1 --lockedPython 3.10 or later.
pip install --pre tenbriuv add --prerelease allow tenbripoetry add --allow-prereleases tenbriNode 22.18 or later, which runs .ts files directly, in a project with "type": "module"
(npm pkg set type=module).
npm install tenbri@nextpnpm add tenbri@nextyarn add tenbri@nextbun add tenbri@nextcurl, plus the tenbri CLI to create the stream and tenbrid to serve it:
cargo install tenbri-cli --version 0.1.0-beta.1 --lockedcargo install tenbri-server --version 0.1.0-beta.1 --lockedSet these in every terminal you use. Keys live in $TENBRI_KEYS, never inside the stream; this
recipe keeps them in ./keys. The digest key keys the HMAC of each input the agent read, so a
guessed policy text cannot be confirmed from the log without it.
export TENBRI_KEYS=./keysexport TENBRI_DIGEST_KEY='a 32-byte secret, kept apart....'-
Create the stream, then record the approval, the intent and the outcome.
An approval needs a
principal(the approver) and anaction(what is approved). The intent cites it as{"by": <approver>, "entry": <entry hash>}, where the entry hash is SHA-256 of a zero byte followed by the stored line (canonical JSON), assha256:<hex>. The intent is durable before the refund runs; the outcome closes it byid.Terminal window tenbri init audit/supportcreated stream tnb1:4282909ee7474c3eeeb243ba852f77da at audit/supportstream key fingerprint (pin it): sha256:4282909ee7474c3eeeb243ba852f77da5b1f10dc68e6a83c4b6b26ada3749123keys: ./keys/tnb1-4282909ee7474c3eeeb243ba852f77daverify with: tenbri verify audit/support --key sha256:4282909ee7474c3eeeb243ba852f77da5b1f10dc68e6a83c4b6b26ada3749123initwrites two key entries (seq 1 and 2): the stream key and the first session key.tenbri appendreads a draft on stdin and prints the entry as stored.Terminal window APPROVAL=$(tenbri append audit/support <<'EOF'{"kind": "approval","principal": {"kind": "person", "id": "alice"},"action": {"type": "issue_refund", "target": "stripe:charge/ch_3PqX"}}EOF)APPROVAL_HASH="sha256:$(printf '\0%s' "$APPROVAL" | shasum -a 256 | cut -d' ' -f1)"POLICY_DIGEST="hmac-sha256:$(printf '%s' 'refunds under 100 EUR are automatic' \| openssl dgst -sha256 -hmac "$TENBRI_DIGEST_KEY" -r | cut -d' ' -f1)"INTENT=$(tenbri append audit/support <<EOF{"kind": "decision","phase": "intent","source": "sdk","principal": {"kind": "agent", "id": "support-assistant"},"action": {"type": "issue_refund", "target": "stripe:charge/ch_3PqX"},"inputs": [{"ref": "policy:refund", "version": "14", "digest": "$POLICY_DIGEST"}],"rule": {"ref": "rule:refund-under-100", "version": "3", "result": "allow"},"approval": {"by": {"kind": "person", "id": "alice"}, "entry": "$APPROVAL_HASH"}}EOF)echo "$INTENT"{"action":{"target":"stripe:charge/ch_3PqX","type":"issue_refund"},"approval":{"by":{"id":"alice","kind":"person"},"entry":"sha256:2cd7ac6d…"},"id":"01M5CWDKWVDZDN2HEZRAJZAF96","inputs":[{"digest":"hmac-sha256:100d69e9…","ref":"policy:refund","version":"14"}],"kind":"decision","phase":"intent","prev":"sha256:2cd7ac6d…","principal":{"id":"support-assistant","kind":"agent"},"rule":{"ref":"rule:refund-under-100","result":"allow","version":"3"},"seq":4,"sig":"ed25519:a8b663a6…","signer":"sess:2026-10-20:63214e562e83","source":"sdk","stream":"tnb1:4282909ee7474c3eeeb243ba852f77da","ts":"2026-10-20T14:46:03.931Z","v":1}Run the refund, then record the outcome:
Terminal window INTENT_ID=$(echo "$INTENT" | jq -r .id)tenbri append audit/support <<EOF{"kind": "decision","phase": "outcome","source": "sdk","intent": "$INTENT_ID","status": "ok","principal": {"kind": "agent", "id": "support-assistant"},"action": {"type": "issue_refund", "target": "stripe:charge/ch_3PqX"}}EOF{"action":{"target":"stripe:charge/ch_3PqX","type":"issue_refund"},"id":"01M5CWDKXS5350P7C6Z68AH64F","intent":"01M5CWDKWVDZDN2HEZRAJZAF96","kind":"decision","phase":"outcome","prev":"sha256:ace27fa3…","principal":{"id":"support-assistant","kind":"agent"},"seq":5,"sig":"ed25519:9f617ebc…","signer":"sess:2026-10-20:63214e562e83","source":"sdk","status":"ok","stream":"tnb1:4282909ee7474c3eeeb243ba852f77da","ts":"2026-10-20T14:46:03.961Z","v":1}Tenbri.open(..., create=True)creates the stream and its keys when there is none.run.readpins what the agent read to its next decision, with the keyed digest of the content.run.decisionwrites the intent before the block runs and the outcome when it exits:ok,refusedif the block raisesRefused,errorfor any other exception.refund.py import hashlibimport jsonimport osfrom tenbri import TenbriACTION = "issue_refund"TARGET = "stripe:charge/ch_3PqX"def entry_hash(entry: dict) -> str:"""SHA-256 of a zero byte and the stored line (canonical JSON), as sha256:<hex>."""line = json.dumps(entry, sort_keys=True, separators=(",", ":"), ensure_ascii=False)return "sha256:" + hashlib.sha256(b"\0" + line.encode()).hexdigest()def issue_refund(target: str) -> None:print("refunding", target) # call your payment provider herelog = Tenbri.open("./audit/support",create=True,digest_key=os.environ["TENBRI_DIGEST_KEY"].encode(),)print("pin this:", log.fingerprint)# 1. Alice approves the refund.approval = log.append({"kind": "approval","principal": "person:alice","action": {"type": ACTION, "target": TARGET},})print("approval", approval["seq"], entry_hash(approval))# 2. The agent reads the policy, then decides. The intent is durable before the block runs,# and the outcome (ok, refused or error) is written when it exits.with log.run("agent:support-assistant") as run:run.read("policy:refund", "14", "refunds under 100 EUR are automatic")with run.decision(ACTION,TARGET,rule={"ref": "rule:refund-under-100", "version": "3", "result": "allow"},approval={"by": approval["principal"], "entry": entry_hash(approval)},) as d:issue_refund(TARGET)print("intent", d.intent["seq"], d.intent["id"])print("outcome", d.outcome["seq"], d.outcome["status"])log.close()Terminal window python refund.pypin this: sha256:880e1b8e200041f24082c70119104c2ccb4943e5f4fbaf4ea4acac31bfdf5b5dapproval 3 sha256:58548abce620767b44083ed7a378001e3f97f388386113869c8f0377dc63db98refunding stripe:charge/ch_3PqXintent 4 01M5CWGXGXV3CAA8N0PBQC5H6Aoutcome 5 okTenbri.open(..., { create: true })creates the stream and its keys when there is none.run.readpins what the agent read to its next decision, with the keyed digest of the content.run.decisionwrites the intent before the callback runs and the outcome when it settles:ok,refusedif it throwsRefused,errorfor anything else it throws.refund.ts import { createHash } from 'node:crypto'import { Tenbri, type Entry } from 'tenbri'const action = 'issue_refund'const target = 'stripe:charge/ch_3PqX'// SHA-256 of a zero byte and the stored line (canonical JSON), as sha256:<hex>.function canonical(v: unknown): string {if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`if (v !== null && typeof v === 'object') {const keys = Object.keys(v).sort()return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical((v as Record<string, unknown>)[k])}`).join(',')}}`}return JSON.stringify(v)}function entryHash(entry: Entry): string {return 'sha256:' + createHash('sha256').update('\0' + canonical(entry)).digest('hex')}async function issueRefund(target: string): Promise<void> {console.log('refunding', target) // call your payment provider here}const log = await Tenbri.open('./audit/support', {create: true,digestKey: process.env.TENBRI_DIGEST_KEY!,})console.log('pin this:', log.fingerprint)// 1. Alice approves the refund.const approval = await log.append({kind: 'approval',principal: 'person:alice',action: { type: action, target },})console.log('approval', approval.seq, entryHash(approval))// 2. The agent reads the policy, then decides. The intent is durable before the callback runs,// and the outcome (ok, refused or error) is written when it returns or throws.await log.run({ principal: 'agent:support-assistant' }, async (run) => {run.read('policy:refund', { version: '14', content: 'refunds under 100 EUR are automatic' })await run.decision({action,target,rule: { ref: 'rule:refund-under-100', version: '3', result: 'allow' },approval: { by: approval.principal, entry: entryHash(approval) },},async (intent) => {console.log('intent', intent.seq, intent.id)await issueRefund(target)},)})await log.close()Terminal window node refund.tspin this: sha256:f473143f58376df588970d1c67e14f182abfe1f647a20ca3216004b3e3e036d2approval 3 sha256:14f6ff18cedbc2c36afea4455e56591c5cd1e63c86c7ad00f732bd903c8f5554intent 4 01M5CWHFDQ010Y6RTHHXFG0TNKrefunding stripe:charge/ch_3PqXtenbridstamps each entry with the principal of the token that wrote it, so Alice and the agent each get a token. Create the stream and two tokens:Terminal window tenbri init audit/supporttenbrid token # the agent'stenbrid token # Alice'screated stream tnb1:72fd273c29ebab40a1a41b462fd93a48 at audit/supportstream key fingerprint (pin it): sha256:72fd273c29ebab40a1a41b462fd93a48ccf0433701074b1166efa6d370bdd7b1keys: ./keys/tnb1-72fd273c29ebab40a1a41b462fd93a48verify with: tenbri verify audit/support --key sha256:72fd273c29ebab40a1a41b462fd93a48ccf0433701074b1166efa6d370bdd7b1token (give it to the client; it is not stored): tnbt_1245d7ba…[[tokens]]sha256 = "sha256:96c1591b903835f5ab4797d30e69d9f2ba68f76341a0fcc199922a702eb81abc"principal = { kind = "agent", id = "..." }token (give it to the client; it is not stored): tnbt_486b9257…[[tokens]]sha256 = "sha256:556744cb6a66b70224c577e0cc21723af187a59fb3f0cce19f1b507141731612"principal = { kind = "agent", id = "..." }tenbrid.toml keys = "keys"[streams.support]path = "audit/support"key = "sha256:72fd273c29ebab40a1a41b462fd93a48ccf0433701074b1166efa6d370bdd7b1"[[tokens]] # the agentsha256 = "sha256:96c1591b903835f5ab4797d30e69d9f2ba68f76341a0fcc199922a702eb81abc"principal = { kind = "agent", id = "support-assistant" }[[tokens]] # Alice, who approvessha256 = "sha256:556744cb6a66b70224c577e0cc21723af187a59fb3f0cce19f1b507141731612"principal = { kind = "person", id = "alice" }Terminal window tenbrid --config tenbrid.tomltenbrid 0.1.0-beta.1: REST on http://127.0.0.1:7070, gRPC on 127.0.0.1:7071, 1 streamsIn another terminal (with
TENBRI_DIGEST_KEYset), Alice approves. Every write carries anIdempotency-Key: the entry id, a ULID your client generates.Terminal window export TENBRI_URL=http://127.0.0.1:7070export AGENT_TOKEN=tnbt_1245d7ba… # the first tokenexport ALICE_TOKEN=tnbt_486b9257… # the second tokencurl -s -X POST $TENBRI_URL/v1/streams/support/approvals \-H "Authorization: Bearer $ALICE_TOKEN" \-H "Idempotency-Key: 01K80A1B2C3D4E5F6G7H8J9K0M" \-H "Content-Type: application/json" \-d '{"action":{"type":"issue_refund","target":"stripe:charge/ch_3PqX"}}'{"entry":{"action":{"target":"stripe:charge/ch_3PqX","type":"issue_refund"},"id":"01K80A1B2C3D4E5F6G7H8J9K0M","kind":"approval","prev":"sha256:3185c1ee…","principal":{"id":"alice","kind":"person"},"seq":3,"sig":"ed25519:ec1d90f3…","signer":"sess:2026-10-20:f65f44f09615","stream":"tnb1:72fd273c29ebab40a1a41b462fd93a48","ts":"2026-10-20T14:48:39.546Z","v":1},"replayed":false}GET /entries/{id}returns an entry with its entry hash, so the agent does not compute it. The agent records the intent, runs the refund, then records the outcome:Terminal window APPROVAL_HASH=$(curl -s $TENBRI_URL/v1/streams/support/entries/01K80A1B2C3D4E5F6G7H8J9K0M \-H "Authorization: Bearer $AGENT_TOKEN" | jq -r .hash)POLICY_DIGEST="hmac-sha256:$(printf '%s' 'refunds under 100 EUR are automatic' \| openssl dgst -sha256 -hmac "$TENBRI_DIGEST_KEY" -r | cut -d' ' -f1)"curl -s -X POST $TENBRI_URL/v1/streams/support/intents \-H "Authorization: Bearer $AGENT_TOKEN" \-H "Idempotency-Key: 01K80A1C5N6P7Q8R9S0T1V2W3X" \-H "Content-Type: application/json" \-d @- <<EOF{"action": {"type": "issue_refund", "target": "stripe:charge/ch_3PqX"},"inputs": [{"ref": "policy:refund", "version": "14", "digest": "$POLICY_DIGEST"}],"rule": {"ref": "rule:refund-under-100", "version": "3", "result": "allow"},"approval": {"by": {"kind": "person", "id": "alice"}, "entry": "$APPROVAL_HASH"}}EOFcurl -s -X POST $TENBRI_URL/v1/streams/support/outcomes \-H "Authorization: Bearer $AGENT_TOKEN" \-H "Idempotency-Key: 01K80A1D8Y9Z0A1B2C3D4E5F6G" \-H "Content-Type: application/json" \-d '{"intent": "01K80A1C5N6P7Q8R9S0T1V2W3X", "status": "ok"}'{"entry":{"action":{"target":"stripe:charge/ch_3PqX","type":"issue_refund"},"approval":{"by":{"id":"alice","kind":"person"},"entry":"sha256:260375ca…"},"durability":"local","id":"01K80A1C5N6P7Q8R9S0T1V2W3X","inputs":[{"digest":"hmac-sha256:100d69e9…","ref":"policy:refund","version":"14"}],"kind":"decision","phase":"intent","prev":"sha256:260375ca…","principal":{"id":"support-assistant","kind":"agent"},"rule":{"ref":"rule:refund-under-100","result":"allow","version":"3"},"seq":4,"sig":"ed25519:d68dc2a5…","signer":"sess:2026-10-20:f65f44f09615","source":"sdk","stream":"tnb1:72fd273c29ebab40a1a41b462fd93a48","ts":"2026-10-20T14:48:48.455Z","v":1},"replayed":false}{"entry":{"action":{"target":"stripe:charge/ch_3PqX","type":"issue_refund"},"id":"01K80A1D8Y9Z0A1B2C3D4E5F6G","intent":"01K80A1C5N6P7Q8R9S0T1V2W3X","kind":"decision","phase":"outcome","prev":"sha256:97e16924…","principal":{"id":"support-assistant","kind":"agent"},"seq":5,"sig":"ed25519:9fa15665…","signer":"sess:2026-10-20:f65f44f09615","source":"sdk","status":"ok","stream":"tnb1:72fd273c29ebab40a1a41b462fd93a48","ts":"2026-10-20T14:48:48.489Z","v":1},"replayed":false}The outcome needs only
intentandstatus:principalandactiondefault to the intent’s.In every tab, the intent’s
prevequals the approval’s entry hash: the approval is the entry just before it, so the hash you computed is the one the chain uses. Anapproval.entrythat does not name an earlier approval entry is refused withbad_approval. -
Read the entries back and verify.
Pin the fingerprint the stream printed when you created it. Reads then check the stream against it, and each match carries its verification status.
--verifychecks the answers against the stream itself, even when a query index exists.Terminal window PIN=sha256:4282909ee7474c3eeeb243ba852f77da5b1f10dc68e6a83c4b6b26ada3749123tenbri query audit/support --action issue_refund --key $PIN --verify \| jq -c '{verification, seq, kind: .entry.kind, phase: .entry.phase, status: .entry.status, approved_by: .entry.approval.by.id}'tenbri verify audit/support --key $PIN{"verification":"verified","seq":3,"kind":"approval","phase":null,"status":null,"approved_by":null}{"verification":"verified","seq":4,"kind":"decision","phase":"intent","status":null,"approved_by":"alice"}{"verification":"verified","seq":5,"kind":"decision","phase":"outcome","status":"ok","approved_by":null}verified 5 entries, stream tnb1:4282909ee7474c3eeeb243ba852f77da, pinned to sha256:4282909ee7474c3eeeb243ba852f77da5b1f10dc68e6a83c4b6b26ada3749123tenbri tail audit/support -n 3 --no-followprints the same entries as stored.check.py import osfrom tenbri import Tenbriwith Tenbri.open("./audit/support", key=os.environ["PIN"]) as log:for m in log.query(action="issue_refund"):e = m.entryprint(m.verification, e["seq"], e["kind"], e.get("phase", "-"), e.get("status", "-"))report = log.verify()print("ok:", report.ok, "entries:", report.entries, "pinned to:", report.pinned_key)Terminal window PIN=sha256:880e1b8e200041f24082c70119104c2ccb4943e5f4fbaf4ea4acac31bfdf5b5d python check.pyverified 3 approval - -verified 4 decision intent -verified 5 decision outcome okok: True entries: 5 pinned to: sha256:880e1b8e200041f24082c70119104c2ccb4943e5f4fbaf4ea4acac31bfdf5b5dcheck.ts import { Tenbri } from 'tenbri'const log = await Tenbri.open('./audit/support', { key: process.env.PIN! })for (const { verification, entry: e } of await log.query({ action: 'issue_refund' })) {console.log(verification, e.seq, e.kind, e.phase ?? '-', e.status ?? '-')}const report = await log.verify()console.log('ok:', report.ok, 'entries:', report.entries, 'pinned to:', report.pinnedKey)await log.close()Terminal window PIN=sha256:f473143f58376df588970d1c67e14f182abfe1f647a20ca3216004b3e3e036d2 node check.tsverified 3 approval - -verified 4 decision intent -verified 5 decision outcome okok: true entries: 5 pinned to: sha256:f473143f58376df588970d1c67e14f182abfe1f647a20ca3216004b3e3e036d2tenbridchecks the stream against thekeypinned intenbrid.toml./verifyreads the whole stream from storage again; compare itspinned_keywith the fingerprint you hold.Terminal window curl -s "$TENBRI_URL/v1/streams/support/entries?action=issue_refund" \-H "Authorization: Bearer $AGENT_TOKEN" \| jq -c '.entries[] | {verification, seq, kind: .entry.kind, phase: .entry.phase, status: .entry.status, approved_by: .entry.approval.by.id}'curl -s $TENBRI_URL/v1/streams/support/verify -H "Authorization: Bearer $AGENT_TOKEN"{"verification":"verified","seq":3,"kind":"approval","phase":null,"status":null,"approved_by":null}{"verification":"verified","seq":4,"kind":"decision","phase":"intent","status":null,"approved_by":"alice"}{"verification":"verified","seq":5,"kind":"decision","phase":"outcome","status":"ok","approved_by":null}{"stream":"tnb1:72fd273c29ebab40a1a41b462fd93a48","entries":5,"pinned_key":"sha256:72fd273c29ebab40a1a41b462fd93a48ccf0433701074b1166efa6d370bdd7b1"}
What happened
Section titled “What happened”You wrote three signed entries that chain to each other. The intent cites the approval by its
entry hash and pins the policy version and rule it relied on, so none of them can be swapped or
edited without breaking verification. The outcome names its intent by id, which closes it: an
intent without an outcome is reconciled with status: unknown by a later writer after an hour.
Related
Section titled “Related”- Streams and entries
- Integrity
- Entry format
- CLI reference
- API reference
- Adapters: record decisions from an agent framework without writing them by hand.
