BACKGROUND
AI is increasingly being used to help make decisions that affect people: who gets hired, who gets a loan, how a patient is treated, who qualifies for a service. When one of those decisions is challenged, someone has to be able to explain how it was made and who was responsible for it.
In most systems, they can’t. The story of a single decision is scattered across places that don’t talk to each other: an application log, a record of the AI call in a developer’s debugging tool, a human sign-off sitting in an email. Nothing links them, and nothing marks the AI’s part in that decision. Often, there’s no clean answer.
This is exactly what regulators are starting to ask for. Canada’s national AI strategy, for one, treats trust as the thing that has to come before adoption, and it expects a human to stay in the loop on decisions.
So we set out to build that missing record: something that captures each AI decision as it happens and keeps it in a form a person can later stand behind. We wanted it to lean on tools a team might already run rather than being a whole platform in its own right. We called it ai-flight-recorder, after the black box on an aircraft that quietly records what happened so it can be reviewed later.
HYPOTHESIS
The goal was a record of every AI decision an app makes about a person: what the AI was asked, what it decided, and, for the decisions that matter most, a human’s sign-off on top. Get that, and answering “how was this case handled?” becomes a matter of looking in one place.
Our hypothesis was that most of the machinery for this already exists. Teams that run AI apps already use tools to watch what their app is doing, and those tools already store each AI call, manage user accounts, and let a person review and comment on what the app did. The one piece missing is a record of each call shaped for accountability rather than debugging, plus a way to push the decisions that matter to a human for sign-off. If that piece can be added as a thin layer on top of tools a team already runs, the whole record comes without a new platform to build and maintain.
That layer records decisions and routes the ones that need a look to a human, and it never judges whether a decision was right. That judgment stays with the person; the layer’s job is to give them something to judge and to record what they decide.
APPROACH
Building on Langfuse instead of from scratch
The tool that already does most of this is an open-source project called Langfuse.
Langfuse is an observability tool for LLM apps. Every time your app calls a model, it saves the call as a trace and gives you a web interface to search and read back what happened. It’s the kind of thing teams already reach for once an app is in production. It also has user accounts and access control, and it lets a reviewer attach scores and comments to a trace and gather traces into a queue to work through one at a time. Between those and its tracing, Langfuse already gives us the storage, the review screen, the reviewer accounts, and the queue. Rebuilding weaker versions of all that would have been wasted effort.
Because Langfuse is open source, you can run it on your own servers. That matters for a government or a bank that can’t send its data to an outside service. It does mean running and maintaining Langfuse yourself, which is real work. But most organizations can already run a service like this, and few have a spare team to build a governance platform from scratch.
So ai-flight-recorder sits on top of Langfuse as a thin layer. Langfuse understands traces, but a trace on its own is just a model call. It doesn’t know that this particular call is a loan decision about a specific applicant, backed by specific evidence, or that a human might have approved it. That is the one thing our layer adds: a decision record shaped for governance, and the code that writes it into Langfuse in a consistent way. Here is who owns what:
| Concern | Who owns it |
|---|---|
| Storing decisions, tracing, retention | Langfuse |
| The review interface and annotation queues | Langfuse |
| Reviewer accounts, authentication, access control | Langfuse |
| The governance-shaped decision record | ai-flight-recorder |
| Deciding when a decision needs human review | ai-flight-recorder |
| Mapping the decision record onto Langfuse’s data model | ai-flight-recorder |
Splitting it this way also makes the record harder to tamper with. If the app that makes the decisions also owned the record of them, nothing would stop it from quietly editing that record later. Since the record lives in Langfuse instead, the app can write a decision but can’t go back and change it. An auditor doesn’t have to take the app’s word for anything.
What you end up with is one record per decision. It holds information auditors need: what was decided, on what basis, which model produced it, whether it was flagged for review, and if so, what the reviewer concluded. The decision might be a benefits case in a government program, a loan approval, or a content takedown. Either way, there is now one place to look.
How a decision moves through the tool
Here is how a decision travels from an app’s code into Langfuse, and then to a reviewer.
Marking a decision in code. A developer marks the decision point in their own code. This has to be deliberate, because the tool can’t work out on its own which of an app’s many model calls are the ones that actually decide something about a person. The developer wraps the decision in a context manager or a decorator and fills in what the raw model call can’t know: the type of decision, an anonymous id for the person it concerns, and the options on the table. The outcome is recorded once it’s known. The layer captures everything else on its own: the model, its version, how long the call took, and a confidence signal if there is one.
In practice, marking a decision looks like this:
import ai_flight_recorder
with ai_flight_recorder.decision(
decision_type="loan_eligibility",
subject_ref="cust_1001", # an opaque id, not raw personal information
options=["approve", "deny"],
) as d:
resp = d.completion(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": (
"You are a loan underwriting assistant. Review the application "
"and respond with exactly one word: approve or deny."
),
},
{
"role": "user",
"content": (
"Applicant: John Smith\n"
"Annual income: $42,000\n"
"Requested loan amount: $35,000\n"
"Purpose: home renovation\n"
"Credit score: 640\n"
"Existing monthly debt payments: $1,100\n\n"
"The requested amount is high relative to income, but there have "
"been no missed payments in the last three years. Approve or deny "
"this application?"
),
},
],
logprobs=True,
)
print(d.emit_result.trace_id)
Running the bundled example against a local Langfuse instance produces a decision and prints the trace id it was recorded under:

The pipeline. Once the block finishes without error, the decision runs through a fixed set of steps before it reaches Langfuse. The rule engine checks it and redacts any personal data, a hash is taken, the record is written to Langfuse, and if the decision was flagged it goes into a review queue.

Deciding what needs review. The rule engine runs first, because we need to know whether a decision needs review before the record is finalized. Its job is to point a human’s attention at the right decisions. It doesn’t judge whether a decision was good; that is the reviewer’s job. It looks at the finished record and flags the ones worth a human’s time, for reasons a governance reviewer would recognize:
| Rule | Fires when |
|---|---|
| Low confidence | The recorded confidence signal is below a configured threshold |
| High-stakes type | The decision type is one the organization has marked as consequential |
| Sensitive outcome | The outcome is one the organization has marked as sensitive, such as a denial |
| PII in inputs | The prompt contained personal information |
When a rule fires, it records why. Any fired rule marks the decision for review. The organization sets the thresholds and picks which decision types and outcomes count as sensitive.
The other rules only flag; the personal-data rule can also act on what it finds. When it detects personal data in a prompt, it replaces each piece with a labelled placeholder before the record is stored, so the record never becomes a store of raw personal data, which would be its own problem. Detection is pluggable: a simple pattern-based detector ships by default, and a stronger one built on Microsoft’s Presidio is available if you want it.
Sealing the record with a hash. Next we take a short fingerprint of the record, which proves it hasn’t been changed. We compute it after redaction, so it matches what actually gets stored rather than an earlier version that still held personal data. The fingerprint covers the AI’s decision and nothing more. The reviewer’s verdict is added later, in Langfuse, so it isn’t part of the hash. That means the hash proves the decision itself wasn’t altered; who signed off on that decision rests within Langfuse’s own accounts and audit log.
Writing it into Langfuse. With the record sealed, it gets written to Langfuse. This is the trickiest part of our implementation. Langfuse has different places for different kinds of data, each with its own constraints, so we split the record across them deliberately rather than dumping all into one:
| Part of the record | Where it goes | Why |
|---|---|---|
| Decision id and type, subject reference, integrity hash, flag names | Filtering fields (tags, propagated metadata) | Short and searchable, so a reviewer can find decisions by them |
| Prompt and outcome | Trace input and output | No size cap, so full text is safe |
| The “needs review” signal | Score | The mechanism Langfuse uses for evaluations attached to a trace |
| Confidence signal, full flag reasons, model version | Regular metadata | Detail worth keeping for the audit trail |
Getting a flagged decision to a reviewer. A flagged decision only goes into the review queue after the record is safely stored, since a queue item has to point at a trace that already exists. Reviewers open the queue in Langfuse and work through the flagged decisions one at a time, marking each one approved, overridden, or escalated. Decisions that weren’t flagged stay recorded but never enter the queue, so they don’t take up a reviewer’s time. The queue step comes last on purpose: if it fails, the record still exists and a reviewer can find it the normal way. The record is the thing that must not be lost; the queue is only a convenience for getting a human to look.
What we noticed about the reviewer’s experience. Langfuse’s screens are built for developers debugging an app. The default trace view is packed with detail that helps an engineer and gets in the way of someone who just needs to approve or reject a decision. The review queue is better, since it shows one flagged item at a time with the review fields right there, but the content underneath is still a developer’s trace.

A developer can work with that screen. A non-technical reviewer struggles with it. The fix is a purpose-built review screen that pulls the flagged decisions out of Langfuse, shows only what matters for the call, and writes the verdict back, while Langfuse stays the system of record. We didn’t build that screen in this experiment. It is a separate design problem from the capture pipeline, and a working pipeline leaves it entirely open.
TAKEAWAYS
01
Check what your existing tools already do before you build. Every time we scoped out a piece to build, most of it already existed. Detecting personal data in prompts is already handled by the common LLM gateways and by Presidio. Capturing decisions, human review, and review queues are already in Langfuse. The only real gap was small: a decision record shaped for governance, and the code to write it the same way each time. Listing what the tools you already run provide is a good way to find out how little is actually left to build.
02
The decisions worth recording are the ordinary ones. Our first instinct was to prevent bad outcomes: inspect the input and block or scrub the risky call. A gate like that only does anything in the rare case where something is already going wrong, and the vast majority of calls go through exactly as intended. What you actually need, day to day, is a record of those ordinary decisions, so you can explain any one of them later. Switching the question from “should this call happen?” to “is there a record of what was decided and who reviewed it?” is what made the tool worth building.
03
The reviewer’s experience is what makes or breaks it. Most of the effort in tools like this goes into capturing and flagging decisions. But none of it counts unless the person signing off can actually do the review, and that turned out to be the weak point. Tools built for engineers show engineer screens, full of traces and token counts. Hand that to a non-technical reviewer and the oversight exists on paper but is hard to carry out. It’s a separate design problem from the capture side, and we’ve flagged it here without solving it. That’s where the next round of work goes.
04
Be careful what a confidence score can actually tell you. It is tempting to treat a model’s confidence as something you can act on, but the number does not mean what it appears to. We record it, because it is worth having on the trail, and a low value can flag a decision for review. That is all we do with it. Nothing downstream treats it as fact, and no real decision rests on it.
05
The loop back to the app is the piece still missing. Once a reviewer signs off, the app that made the decision often wants to know, so it can act on the verdict. We hoped Langfuse could tell it directly, but its webhooks only fire on prompt changes, and a completed review is stored as a score, which they don’t push out. For now the app has to poll for the verdict instead of being told. It works, but a callback would be cleaner, and it’s the natural next thing to add.
THE CODE
ai-flight-recorder is open source. It’s a Python library that wraps the LLM calls your app makes through LiteLLM and writes governance-shaped decision records into a Langfuse instance you run yourself. The repo comes with a runnable example that needs no outside services, so you can try the whole flow end to end before wiring it up to anything real.
The full source code is on GitHub.