Decision Recording
base install For: governance & audit
Install
pip install briefcase-ai@capture, DecisionSnapshot, and the exporters are in the base package.
Persisting snapshots needs a storage backend.
Why persist a decision
A decision that vanishes the moment it runs can’t be replayed, audited, or proven. Recording it turns a fleeting model call into a durable, verifiable record:
- Replay — re-run the exact inputs later to check whether behavior changed.
- Audit — answer “what did the agent decide, and on what basis?” months later.
- Correlate —
fingerprint()hashes the inputs, so the same question asked twice is recognizable as the same question.
There are two ways to record. The lightweight @capture decorator records a dict per call and hands it to an exporter. The native DecisionSnapshot builds a structured record you can persist and replay.
How recording flows
-
Capture —
@capturewrapsclassify_ticketand records its inputs, outputs, timing, and type for each call. -
Export — the recorded dict is handed to an exporter (console, a
.jsonlfile, or your own). -
Persist — for storage and replay, build a native
DecisionSnapshotand save it to a backend. -
Replay & verify — later, load the snapshot, re-run it, and compare its
fingerprint().
flowchart LR A["classify_ticket()"] -->|"@capture"| B["recorded dict"] B --> C[Exporter] A -->|"native API"| D[DecisionSnapshot] D --> E[Backend] E --> F["Replay & Verify"]
Record a decision with @capture
The simplest way to record a decision is the @capture decorator. Pass an exporter to send each record somewhere:
from briefcase import capturefrom briefcase.exporters import BaseExporter
class CollectingExporter(BaseExporter): def __init__(self): self.records = []
async def export(self, decision): self.records.append(decision) return True
async def flush(self): ...
async def close(self): ...
exporter = CollectingExporter()
@capture(decision_type="classification", context_version="v1", exporter=exporter, async_capture=False)def classify_ticket(text: str) -> str: # call your model here return "account_access"
classify_ticket("Reset my password")print(exporter.records[0])The decorator wraps the call, records a dict (decision id, inputs, outputs,
timing, decision_type, context_version), and exports it through the
exporter you pass. It does not persist a native DecisionSnapshot on its own;
use the native objects below when you need storage or replay.
@capture parameters
| Parameter | Default | Description |
|---|---|---|
decision_type | None | Label for the kind of decision recorded |
context_version | None | Version tag for the surrounding context or prompt |
max_input_chars | 1000 | Truncate recorded inputs to this length |
max_output_chars | 1000 | Truncate recorded outputs to this length |
exporter | None | Exporter that receives each recorded dict |
async_capture | True | Export off the calling thread |
@capture works with or without arguments:
from briefcase import capture
@capturedef classify(text: str) -> str: # call your model here return "billing"Emit records
@capture records a decision but has nowhere to send it until you configure an
exporter. briefcase.observe() wires one up in a single line and returns it.
import briefcase
mem = briefcase.observe("memory") # or "console", or a "*.jsonl" path
@briefcase.capture(decision_type="classification", async_capture=False)def classify_ticket(text: str) -> str: # call your model here return "account_access"
classify_ticket("Reset my password")print(mem.records[0])The per-call exporter= argument shown above overrides the global one set by
observe(). See Exporters for the stock exporters and
how to write a custom one.
Build a native DecisionSnapshot
When you need storage or replay, build a structured DecisionSnapshot:
from briefcase import DecisionSnapshot, Input, Output, ModelParameters
decision = DecisionSnapshot("classify_ticket")decision.add_input(Input("text", "Reset my password", "string"))
params = ModelParameters("your-model")params.with_provider("your-provider")params.with_parameter("temperature", 0.0)decision.with_model_parameters(params)
output = Output("category", "account_access", "string")output.with_confidence(0.92)decision.add_output(output)
decision.with_execution_time(12.5)decision.with_module("triage_service")decision.add_tag("environment", "production")
print(decision.function_name)print(decision.fingerprint())Fingerprints identify the question, not the answer
fingerprint() is a SHA-256 over the function name, the input names and values,
and the model name. It is stable across processes and survives a save-and-load
round trip, so it is how you recognize that two records are the same decision
being asked again.
digest = decision.fingerprint() # stable across processes# later, on a loaded snapshot:assert loaded.fingerprint() == digest # same question, same inputsKey classes
@capture— decorator that records a dict and exports itDecisionSnapshot— structured record you can persist and replay; exposesfingerprint()andcontent_hash()Input/Output— typed wrappers;Output.with_confidence(score)attaches a confidence valueModelParameters— model name, provider, and per-call parametersSnapshot— groups multiple decisions;add_decision(decision)appends to it
| Field | Description | Why it matters |
|---|---|---|
function_name | The recorded function | Identifies which decision this is |
inputs | Typed inputs | The exact inputs a replay re-runs against |
outputs | Typed outputs | What the agent actually decided |
tags | Arbitrary key/value tags | Carries your own context (e.g. environment, queue) |
execution_time_ms | How long the call took | Anchors performance over time |
fingerprint() | Hash of function name, inputs, and model name | Recognizes the same decision asked again. Excludes outputs |
content_hash() | Hash of inputs, outputs, model parameters, tags, and error | Detects a record that changed after it was written |
@capture vs DecisionSnapshot vs persisted storage
Three layers, each for a different need — pick by what you’re trying to do.
| Use this | When you want to… | Lifetime |
|---|---|---|
@capture decorator | Instrument a real function (like classify_ticket) with zero boilerplate and stream a lightweight record to an exporter | Per call |
DecisionSnapshot | Build a structured record by hand — to persist, replay, or fingerprint it | In-memory object |
Persisted backend (SqliteBackend) | Keep decisions durably so you can query, replay, and audit them weeks later | Durable |
Persist a decision
Save a DecisionSnapshot to a storage backend so it can be queried or replayed
later. This is the bridge from Capture into the Store & Query act.
import briefcasefrom briefcase import DecisionSnapshot, Input, Outputfrom briefcase.storage import SqliteBackend
briefcase.init()
decision = DecisionSnapshot("classify_ticket")decision.add_input(Input("text", "Reset my password", "string"))decision.add_output(Output("category", "account_access", "string"))decision.with_execution_time(12.5)
backend = SqliteBackend.in_memory() # or SqliteBackend("decisions.db")decision_id = backend.save_decision(decision)
restored = backend.load_decision(decision_id)print(restored.function_name)Choose what content to retain
@capture now separates observability from content retention:
import briefcase
briefcase.observe("console")
@briefcase.capture(capture_content="hash", async_capture=False)def classify_ticket(text: str) -> str: return "account_access"
classify_ticket("reset my password")| Mode | Inputs and outputs | Error field |
|---|---|---|
full | Bounded repr, optionally rewritten by redact | Bounded exception message |
hash | SHA-256, character count, and type | Exception class plus message hash |
none | Type and argument shape only | Exception class only |
With no decorator or global exporter, capture calls the wrapped function
directly. It does not build a record, render values, or run the redact hook.
Background exports share one FIFO worker; call
wait_for_pending_exports() before a process exits when queued records must
finish.
Limits
@capture records reprs, truncated to 1000 characters. Inputs and outputs are
stored as text, not as objects, so a large payload is cut and a rich object becomes
whatever its __repr__ says. Raise max_input_chars / max_output_chars when the
detail matters.
A lazy return value is recorded as the object, not its contents. A generator
function records <generator object gen at 0x...> because the decorator returns
before anything is consumed. Materialize inside the function (return a list) when
you need the values in the record.
The fingerprint excludes outputs. That is what makes it a grouping key. Reach
for content_hash() when the question is “same answer?”.
@capture and DecisionSnapshot are separate paths. The decorator never
produces a snapshot, so nothing captured by @capture is queryable or replayable
until you build a snapshot yourself and save it.
Export failures are swallowed. A record can be lost without the decorated call noticing. Exporters has the detail.
API reference
briefcase has the full @capture, DecisionSnapshot,
Input, Output, and ModelParameters signatures.