Skip to content

Decision Recording

base install For: governance & audit

Install

Terminal window
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.
  • Correlatefingerprint() 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

  1. Capture@capture wraps classify_ticket and records its inputs, outputs, timing, and type for each call.

  2. Export — the recorded dict is handed to an exporter (console, a .jsonl file, or your own).

  3. Persist — for storage and replay, build a native DecisionSnapshot and save it to a backend.

  4. 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 capture
from 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

ParameterDefaultDescription
decision_typeNoneLabel for the kind of decision recorded
context_versionNoneVersion tag for the surrounding context or prompt
max_input_chars1000Truncate recorded inputs to this length
max_output_chars1000Truncate recorded outputs to this length
exporterNoneExporter that receives each recorded dict
async_captureTrueExport off the calling thread

@capture works with or without arguments:

from briefcase import capture
@capture
def 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 inputs

Key classes

  • @capture — decorator that records a dict and exports it
  • DecisionSnapshot — structured record you can persist and replay; exposes fingerprint() and content_hash()
  • Input / Output — typed wrappers; Output.with_confidence(score) attaches a confidence value
  • ModelParameters — model name, provider, and per-call parameters
  • Snapshot — groups multiple decisions; add_decision(decision) appends to it
FieldDescriptionWhy it matters
function_nameThe recorded functionIdentifies which decision this is
inputsTyped inputsThe exact inputs a replay re-runs against
outputsTyped outputsWhat the agent actually decided
tagsArbitrary key/value tagsCarries your own context (e.g. environment, queue)
execution_time_msHow long the call tookAnchors performance over time
fingerprint()Hash of function name, inputs, and model nameRecognizes the same decision asked again. Excludes outputs
content_hash()Hash of inputs, outputs, model parameters, tags, and errorDetects 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 thisWhen you want to…Lifetime
@capture decoratorInstrument a real function (like classify_ticket) with zero boilerplate and stream a lightweight record to an exporterPer call
DecisionSnapshotBuild a structured record by hand — to persist, replay, or fingerprint itIn-memory object
Persisted backend (SqliteBackend)Keep decisions durably so you can query, replay, and audit them weeks laterDurable

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 briefcase
from briefcase import DecisionSnapshot, Input, Output
from 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")
ModeInputs and outputsError field
fullBounded repr, optionally rewritten by redactBounded exception message
hashSHA-256, character count, and typeException class plus message hash
noneType and argument shape onlyException 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.

Where this fits