How it works, for engineering teams
Logging your agents, made easy. Without changing them.
Watch a simple agent get built and give a strange answer. Then see the record that shows why, and how to fix it.
An evidence layer, not a governance layer.
Made for EU AI Act Article 12 record-keeping. See the mapping.
A real recorded run, signed by your recorder key.
1 · Build: First, an ordinary agent.
Your agent, as you write it
"""A loan-decision agent: one model call, three tools, a clock read, a ruleand a judgment. The model is whatever OPENAI_BASE_URL points at."""import jsonimport time from openai import OpenAI # The credit bureau's mirror, as of its last nightly sync.BUREAU_MIRROR = [ {"applicant_id": "APP-7690", "score": 702, "as_of": "2026-10-30"}, {"applicant_id": "APP-7713", "score": 512, "as_of": "2026-10-30"}, {"applicant_id": "APP-7790", "score": 688, "as_of": "2026-10-30"},]UNDERWRITER = ("You are an underwriter. Apply the policy exactly. Answer as: " "DECISION: <APPROVE|DECLINE|REFER>. REASONS: <codes or none>. " "<one sentence>") def fetch_applicant(applicant_id): """The application, as the lending portal holds it.""" return {"applicant_id": applicant_id, "product": "personal-24", "annual_income": 148000, "monthly_debt": 900, "requested": 25000, "years_employed": 9} def pull_credit_report(applicant_id): """The applicant's report from the bureau mirror, matched by number.""" number = int(applicant_id.split("-")[1]) return min(BUREAU_MIRROR, key=lambda report: abs( int(report["applicant_id"].split("-")[1]) - number)) def check_policy(product): """The underwriting thresholds for a product.""" return {"product": product, "min_score": 620, "max_dti": 0.43, "version": "4.2"} def decide(client, applicant_id): received_at = time.time()Changes every run. applicant = fetch_applicant(applicant_id) report = pull_credit_report(applicant_id) policy = check_policy(applicant["product"]) dti = round(applicant["monthly_debt"] * 12 / applicant["annual_income"], 3)Same in, same out. reply = client.chat.completions.create( model="underwriter-v1", messages=[{"role": "system", "content": UNDERWRITER}, {"role": "user", "content": json.dumps( {"application": applicant, "credit_report": report, "policy": policy, "dti": dti})}]) answer = reply.choices[0].message.content codes = answer.split("REASONS:")[1].split(".")[0].strip() return {"applicant_id": applicant_id, "decision": answer.split("DECISION:")[1].split(".")[0].strip().lower(), "reasons": [] if codes == "none" else codes.split(", "), "explanation": answer.split("REASONS:")[1].split(".", 1)[1].strip(), "received_at": received_at} def main(): client = OpenAI() result = decide(client, "APP-7731") print(json.dumps(result, indent=2)) return result if __name__ == "__main__": main()The record
Nothing records it yet.
You build an agent. Plain Python, the way you already write one. Nothing of BitExact in it.
You build an agent.
It calls a model and three tools. The application, the credit report, the lending policy.
It calls a model and three tools.
A clock read and a rule.
A clock read and a rule.
It makes a decision. Approve, decline or refer, read from the model's answer.
It makes a decision.
2 · Run: It runs. The answer is strange.
Your agent
$ python story_agent.py{ "applicant_id": "APP-7731", "decision": "decline", "reasons": [ "SCORE_BELOW_MIN" ], "explanation": "The credit score of 512 is below the 620 minimum.", "received_at": 1794198590.6084673}$ echo $?0The record
No record. If anyone asks why, there is nothing to show them.
Run it.
Run it.
A strong applicant. Declined. Income 148k, debt-to-income 7.3%, 9 years employed. Turned down on a 512 score.
A strong applicant. Declined.
Nothing to show why.
Nothing to show why.
3 · Add BitExact: Add BitExact. Change nothing.
Your agent, with BitExact
Python + OpenAI
"""A loan-decision agent: one model call, three tools, a clock read, a ruleand a judgment. The model is whatever OPENAI_BASE_URL points at."""import jsonimport timefrom datetime import datetime, timedelta, timezone import bitexactfrom bitexact.adapters import wrap_openaifrom openai import OpenAI # The credit bureau's mirror, as of its last nightly sync.BUREAU_MIRROR = [ {"applicant_id": "APP-7690", "score": 702, "as_of": "2026-10-30"}, {"applicant_id": "APP-7713", "score": 512, "as_of": "2026-10-30"}, {"applicant_id": "APP-7790", "score": 688, "as_of": "2026-10-30"},]UNDERWRITER = ("You are an underwriter. Apply the policy exactly. Answer as: " "DECISION: <APPROVE|DECLINE|REFER>. REASONS: <codes or none>. " "<one sentence>") @bitexact.record_tooldef fetch_applicant(applicant_id): """The application, as the lending portal holds it.""" return {"applicant_id": applicant_id, "product": "personal-24", "annual_income": 148000, "monthly_debt": 900, "requested": 25000, "years_employed": 9} @bitexact.record_tooldef pull_credit_report(applicant_id): """The applicant's report from the bureau mirror, matched by number.""" number = int(applicant_id.split("-")[1]) return min(BUREAU_MIRROR, key=lambda report: abs( int(report["applicant_id"].split("-")[1]) - number)) @bitexact.record_tooldef check_policy(product): """The underwriting thresholds for a product.""" return {"product": product, "min_score": 620, "max_dti": 0.43, "version": "4.2"} def decide(client, applicant_id): received_at = time.time() applicant = fetch_applicant(applicant_id) report = pull_credit_report(applicant_id) policy = check_policy(applicant["product"]) dti = round(applicant["monthly_debt"] * 12 / applicant["annual_income"], 3) reply = client.chat.completions.create( model="underwriter-v1", messages=[{"role": "system", "content": UNDERWRITER}, {"role": "user", "content": json.dumps( {"application": applicant, "credit_report": report, "policy": policy, "dti": dti})}]) answer = reply.choices[0].message.content codes = answer.split("REASONS:")[1].split(".")[0].strip() return {"applicant_id": applicant_id, "decision": answer.split("DECISION:")[1].split(".")[0].strip().lower(), "reasons": [] if codes == "none" else codes.split(", "), "explanation": answer.split("REASONS:")[1].split(".", 1)[1].strip(), "received_at": received_at} def record_outcome(r, result): r.record_decision(retention_deadline=(datetime.now(timezone.utc) + timedelta(days=760)).isoformat(), output_flags={"is_adverse_action": result["decision"] == "decline", "is_automated_decision": True}, reason_codes=result["reasons"] or None) def main(): client = OpenAI() with bitexact.run("loan-APP-7731", workflow="loan-adjudication", workflow_instance="APP-7731", environment="demo", principal="svc:underwriter") as r: client = wrap_openai(client, r) result = decide(client, "APP-7731") record_outcome(r, result) print(json.dumps(result, indent=2)) return result if __name__ == "__main__": main()TypeScript
// A loan-decision agent: one model call, three tools, a clock read, a rule// and a judgment. The model is whatever OPENAI_BASE_URL points at.import { fromEnv, record, recordTool, run, wrapFetch } from "../../../../sdk-ts/src/index.ts"; // The credit bureau's mirror, as of its last nightly sync.const BUREAU_MIRROR = [ { applicant_id: "APP-7690", score: 702, as_of: "2026-10-30" }, { applicant_id: "APP-7713", score: 512, as_of: "2026-10-30" }, { applicant_id: "APP-7790", score: 688, as_of: "2026-10-30" },];const UNDERWRITER = "You are an underwriter. Apply the policy exactly. Answer as: " + "DECISION: <APPROVE|DECLINE|REFER>. REASONS: <codes or none>. " + "<one sentence>"; const number = (applicantId: string) => Number(applicantId.split("-")[1]); const tools = { /** The application, as the lending portal holds it. */ fetchApplicant(applicantId: string) { return { applicant_id: applicantId, product: "personal-24", annual_income: 148000, monthly_debt: 900, requested: 25000, years_employed: 9 }; }, /** The applicant's report from the bureau mirror, matched by number. */ pullCreditReport(applicantId: string) { const distance = (report: { applicant_id: string }) => Math.abs(number(report.applicant_id) - number(applicantId)); return BUREAU_MIRROR.reduce((best, report) => distance(report) < distance(best) ? report : best); }, /** The underwriting thresholds for a product. */ checkPolicy(product: string) { return { product, min_score: 620, max_dti: 0.43, version: "4.2" }; },};tools.fetchApplicant = recordTool("fetch_applicant", tools.fetchApplicant);tools.pullCreditReport = recordTool("pull_credit_report", tools.pullCreditReport);tools.checkPolicy = recordTool("check_policy", tools.checkPolicy); async function decide(client: typeof fetch, applicantId: string) { const receivedAt = Date.now() / 1000; record("nondet", { source: "Date.now() / 1000", value: receivedAt }); const applicant = tools.fetchApplicant(applicantId); const report = tools.pullCreditReport(applicantId); const policy = tools.checkPolicy(applicant.product); const dti = Math.round(applicant.monthly_debt * 12 / applicant.annual_income * 1000) / 1000; const reply = await client(`${process.env.OPENAI_BASE_URL}/chat/completions`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${process.env.OPENAI_API_KEY}` }, body: JSON.stringify({ model: "underwriter-v1", messages: [{ role: "system", content: UNDERWRITER }, { role: "user", content: JSON.stringify( { application: applicant, credit_report: report, policy, dti }) }], }), }); const answer: string = (await reply.json()).choices[0].message.content; const reasoned = answer.split("REASONS:")[1]; const codes = reasoned.split(".")[0].trim(); return { applicant_id: applicantId, decision: answer.split("DECISION:")[1].split(".")[0].trim().toLowerCase(), reasons: codes === "none" ? [] : codes.split(", "), explanation: reasoned.slice(reasoned.indexOf(".") + 1).trim(), received_at: receivedAt };} function recordOutcome(result: { decision: string, reasons: string[] }) { record("decision_record", { retention_deadline: new Date(Date.now() + 760 * 86_400_000).toISOString(), output_flags: { is_adverse_action: result.decision === "decline", is_automated_decision: true }, ...(result.reasons.length ? { reason_codes: result.reasons } : {}), });} async function main() { let client = fetch; return run("loan-APP-7731-ts", async () => { client = wrapFetch(client); const result = await decide(client, "APP-7731"); recordOutcome(result); console.log(JSON.stringify(result, null, 2)); return result; }, { identity: { workflow: "loan-adjudication", workflowInstance: "APP-7731", environment: "demo", principal: "svc:underwriter" } });} await main();await fromEnv()?.flush(10_000);bench/story/langs/typescript/story_agent.ts wrapped as bench/story/langs/typescript/story_agent_bitexact.ts, run as loan-APP-7731-ts and verified when the footage was made.
Go
// A loan-decision agent: one model call, three tools, a clock read, a rule// and a judgment. The model is whatever OPENAI_BASE_URL points at.package main import ( "bytes" "context" "encoding/json" "fmt" "io" "log" "math" "net/http" "os" "strconv" "strings" "time" "github.com/bitexact/bitexact/sdk-go/bitexact") type Applicant struct { ApplicantID string `json:"applicant_id"` Product string `json:"product"` AnnualIncome int `json:"annual_income"` MonthlyDebt int `json:"monthly_debt"` Requested int `json:"requested"` YearsEmployed int `json:"years_employed"`} type CreditReport struct { ApplicantID string `json:"applicant_id"` Score int `json:"score"` AsOf string `json:"as_of"`} type Policy struct { Product string `json:"product"` MinScore int `json:"min_score"` MaxDTI float64 `json:"max_dti"` Version string `json:"version"`} type Decision struct { ApplicantID string `json:"applicant_id"` Decision string `json:"decision"` Reasons []string `json:"reasons"` Explanation string `json:"explanation"` ReceivedAt float64 `json:"received_at"`} // The credit bureau's mirror, as of its last nightly sync.var bureauMirror = []CreditReport{ {"APP-7690", 702, "2026-10-30"}, {"APP-7713", 512, "2026-10-30"}, {"APP-7790", 688, "2026-10-30"},} const underwriter = "You are an underwriter. Apply the policy exactly. Answer as: " + "DECISION: <APPROVE|DECLINE|REFER>. REASONS: <codes or none>. " + "<one sentence>" func number(applicantID string) int { n, _ := strconv.Atoi(strings.Split(applicantID, "-")[1]) return n} // fetchApplicant is the application, as the lending portal holds it.var fetchApplicant = func(ctx context.Context, applicantID string) (Applicant, error) { return Applicant{applicantID, "personal-24", 148000, 900, 25000, 9}, nil} // pullCreditReport is the applicant's report from the bureau mirror, matched by number.var pullCreditReport = func(ctx context.Context, applicantID string) (CreditReport, error) { distance := func(report CreditReport) int { d := number(report.ApplicantID) - number(applicantID) if d < 0 { return -d } return d } best := bureauMirror[0] for _, report := range bureauMirror[1:] { if distance(report) < distance(best) { best = report } } return best, nil} // checkPolicy is the underwriting thresholds for a product.var checkPolicy = func(ctx context.Context, product string) (Policy, error) { return Policy{product, 620, 0.43, "4.2"}, nil} func decide(ctx context.Context, client *http.Client, applicantID string) (Decision, error) { receivedAt := float64(time.Now().UnixMicro()) / 1e6 bitexact.Record(ctx, "nondet", map[string]any{"source": "float64(time.Now().UnixMicro()) / 1e6", "value": receivedAt}) applicant, err := fetchApplicant(ctx, applicantID) if err != nil { return Decision{}, err } report, err := pullCreditReport(ctx, applicantID) if err != nil { return Decision{}, err } policy, err := checkPolicy(ctx, applicant.Product) if err != nil { return Decision{}, err } dti := math.Round(float64(applicant.MonthlyDebt*12)/float64(applicant.AnnualIncome)*1000) / 1000 user, _ := json.Marshal(map[string]any{"application": applicant, "credit_report": report, "policy": policy, "dti": dti}) body, _ := json.Marshal(map[string]any{"model": "underwriter-v1", "messages": []map[string]string{ {"role": "system", "content": underwriter}, {"role": "user", "content": string(user)}}}) req, err := http.NewRequestWithContext(ctx, "POST", os.Getenv("OPENAI_BASE_URL")+"/chat/completions", bytes.NewReader(body)) if err != nil { return Decision{}, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+os.Getenv("OPENAI_API_KEY")) res, err := client.Do(req) if err != nil { return Decision{}, err } defer res.Body.Close() raw, err := io.ReadAll(res.Body) if err != nil { return Decision{}, err } var reply struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.Unmarshal(raw, &reply); err != nil { return Decision{}, err } answer := reply.Choices[0].Message.Content reasoned := strings.Split(answer, "REASONS:")[1] codes := strings.TrimSpace(strings.Split(reasoned, ".")[0]) reasons := []string{} if codes != "none" { reasons = strings.Split(codes, ", ") } return Decision{ ApplicantID: applicantID, Decision: strings.ToLower(strings.TrimSpace(strings.Split(strings.Split(answer, "DECISION:")[1], ".")[0])), Reasons: reasons, Explanation: strings.TrimSpace(strings.SplitN(reasoned, ".", 2)[1]), ReceivedAt: receivedAt, }, nil} func recordOutcome(ctx context.Context, result Decision) { record := map[string]any{ "retention_deadline": time.Now().UTC().AddDate(0, 0, 760).Format(time.RFC3339Nano), "output_flags": map[string]any{"is_adverse_action": result.Decision == "decline", "is_automated_decision": true}, } if len(result.Reasons) > 0 { record["reason_codes"] = result.Reasons } bitexact.Record(ctx, "decision_record", record)} func main() { defer bitexact.Shutdown(10 * time.Second) fetchApplicant = bitexact.RecordTool("fetch_applicant", fetchApplicant) pullCreditReport = bitexact.RecordTool("pull_credit_report", pullCreditReport) checkPolicy = bitexact.RecordTool("check_policy", checkPolicy) ctx := context.Background() client := &http.Client{} client.Transport = bitexact.WrapTransport(http.DefaultTransport) bitexact.Run(ctx, "loan-APP-7731-go", bitexact.Options{Identity: &bitexact.Identity{ Workflow: "loan-adjudication", WorkflowInstance: "APP-7731", Environment: "demo", Principal: "svc:underwriter"}}, func(ctx context.Context) (any, error) { result, err := decide(ctx, client, "APP-7731") if err != nil { log.Fatal(err) } recordOutcome(ctx, result) out, _ := json.MarshalIndent(result, "", " ") fmt.Println(string(out)) return result, nil })}bench/story/langs/go/plain/main.go wrapped as bench/story/langs/go/wrapped/main.go, run as loan-APP-7731-go and verified when the footage was made.
Java
// A loan-decision agent: one model call, three tools, a clock read, a rule// and a judgment. The model is whatever OPENAI_BASE_URL points at. import dev.bitexact.sdk.Bitexact;import java.io.IOException;import java.net.URI;import java.net.http.HttpClient;import java.net.http.HttpRequest;import java.net.http.HttpResponse;import java.time.Duration;import java.time.Instant;import java.util.Comparator;import java.util.LinkedHashMap;import java.util.List;import java.util.Locale;import java.util.Map; public final class StoryAgent { /** A tool: one argument in, one result out. */ interface Tool<A, R> { R apply(A in) throws Exception; } /** The model endpoint: a JSON body posted with an HTTP client. */ interface Model { HttpResponse<String> send(HttpClient client, HttpRequest.Builder request, String json) throws IOException, InterruptedException; } // The credit bureau's mirror, as of its last nightly sync. static final List<Map<String, Object>> BUREAU_MIRROR = List.of( Map.of("applicant_id", "APP-7690", "score", 702, "as_of", "2026-10-30"), Map.of("applicant_id", "APP-7713", "score", 512, "as_of", "2026-10-30"), Map.of("applicant_id", "APP-7790", "score", 688, "as_of", "2026-10-30")); static final String UNDERWRITER = "You are an underwriter. Apply the policy exactly. Answer as: " + "DECISION: <APPROVE|DECLINE|REFER>. REASONS: <codes or none>. " + "<one sentence>"; static int number(String applicantId) { return Integer.parseInt(applicantId.split("-")[1]); } /** The application, as the lending portal holds it. */ static Tool<String, Map<String, Object>> fetchApplicant = applicantId -> Map.of( "applicant_id", applicantId, "product", "personal-24", "annual_income", 148000, "monthly_debt", 900, "requested", 25000, "years_employed", 9); /** The applicant's report from the bureau mirror, matched by number. */ static Tool<String, Map<String, Object>> pullCreditReport = applicantId -> BUREAU_MIRROR.stream() .min(Comparator.comparingInt((Map<String, Object> report) -> Math.abs( number((String) report.get("applicant_id")) - number(applicantId)))) .orElseThrow(); /** The underwriting thresholds for a product. */ static Tool<String, Map<String, Object>> checkPolicy = product -> Map.of( "product", product, "min_score", 620, "max_dti", 0.43, "version", "4.2"); static Model model = (client, request, json) -> client.send( request.header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(json)).build(), HttpResponse.BodyHandlers.ofString()); static Map<String, Object> decide(HttpClient client, String applicantId) throws Exception { double receivedAt = System.currentTimeMillis() / 1000.0; Bitexact.record("nondet", Map.of("source", "System.currentTimeMillis() / 1000.0", "value", receivedAt)); Map<String, Object> applicant = fetchApplicant.apply(applicantId); Map<String, Object> report = pullCreditReport.apply(applicantId); Map<String, Object> policy = checkPolicy.apply((String) applicant.get("product")); double dti = Math.round((int) applicant.get("monthly_debt") * 12.0 / (int) applicant.get("annual_income") * 1000) / 1000.0; HttpResponse<String> reply = model.send(client, HttpRequest.newBuilder(URI.create(System.getenv("OPENAI_BASE_URL") + "/chat/completions")) .header("Authorization", "Bearer " + System.getenv("OPENAI_API_KEY")), JsonText.write(Map.of("model", "underwriter-v1", "messages", List.of( Map.of("role", "system", "content", UNDERWRITER), Map.of("role", "user", "content", JsonText.write(Map.of( "application", applicant, "credit_report", report, "policy", policy, "dti", dti))))))); String answer = (String) JsonText.at(JsonText.parse(reply.body()), "choices", 0, "message", "content"); String reasoned = answer.split("REASONS:")[1]; String codes = reasoned.split("\\.")[0].strip(); Map<String, Object> result = new LinkedHashMap<>(); result.put("applicant_id", applicantId); result.put("decision", answer.split("DECISION:")[1].split("\\.")[0].strip().toLowerCase(Locale.ROOT)); result.put("reasons", codes.equals("none") ? List.of() : List.of(codes.split(", "))); result.put("explanation", reasoned.split("\\.", 2)[1].strip()); result.put("received_at", receivedAt); return result; } static void recordOutcome(Map<String, Object> result) { Map<String, Object> record = new LinkedHashMap<>(); record.put("retention_deadline", Instant.now().plus(Duration.ofDays(760)).toString()); record.put("output_flags", Map.of("is_adverse_action", "decline".equals(result.get("decision")), "is_automated_decision", true)); if (!((List<?>) result.get("reasons")).isEmpty()) { record.put("reason_codes", result.get("reasons")); } Bitexact.record("decision_record", record); } public static void main(String[] args) throws Exception { fetchApplicant = Bitexact.recordTool("fetch_applicant", fetchApplicant::apply)::apply; pullCreditReport = Bitexact.recordTool("pull_credit_report", pullCreditReport::apply)::apply; checkPolicy = Bitexact.recordTool("check_policy", checkPolicy::apply)::apply; model = Bitexact::sendJson; HttpClient client = HttpClient.newHttpClient(); Bitexact.run("loan-APP-7731-java", new Bitexact.Options(false, null, Bitexact.Identity.builder() .workflow("loan-adjudication").workflowInstance("APP-7731") .environment("demo").principal("svc:underwriter").build()), () -> { Map<String, Object> result = decide(client, "APP-7731"); recordOutcome(result); System.out.println(JsonText.pretty(result)); return result; }); Bitexact.shutdown(Duration.ofSeconds(10)); }}bench/story/langs/java/plain/StoryAgent.java wrapped as bench/story/langs/java/wrapped/StoryAgent.java, run as loan-APP-7731-java and verified when the footage was made.
Any language, through the recording proxy
// A loan-decision agent: one model call, three tools, a clock read, a rule// and a judgment. The model is whatever OPENAI_BASE_URL points at. // The credit bureau's mirror, as of its last nightly sync.const BUREAU_MIRROR = [ { applicant_id: "APP-7690", score: 702, as_of: "2026-10-30" }, { applicant_id: "APP-7713", score: 512, as_of: "2026-10-30" }, { applicant_id: "APP-7790", score: 688, as_of: "2026-10-30" },];const UNDERWRITER = "You are an underwriter. Apply the policy exactly. Answer as: " + "DECISION: <APPROVE|DECLINE|REFER>. REASONS: <codes or none>. " + "<one sentence>"; const number = (applicantId) => Number(applicantId.split("-")[1]); /** The application, as the lending portal holds it. */function fetchApplicant(applicantId) { return { applicant_id: applicantId, product: "personal-24", annual_income: 148000, monthly_debt: 900, requested: 25000, years_employed: 9 };} /** The applicant's report from the bureau mirror, matched by number. */function pullCreditReport(applicantId) { const distance = (report) => Math.abs(number(report.applicant_id) - number(applicantId)); return BUREAU_MIRROR.reduce((best, report) => distance(report) < distance(best) ? report : best);} /** The underwriting thresholds for a product. */function checkPolicy(product) { return { product, min_score: 620, max_dti: 0.43, version: "4.2" };} async function decide(applicantId) { const receivedAt = Date.now() / 1000; const applicant = fetchApplicant(applicantId); const report = pullCreditReport(applicantId); const policy = checkPolicy(applicant.product); const dti = Math.round(applicant.monthly_debt * 12 / applicant.annual_income * 1000) / 1000; const reply = await fetch(`${process.env.OPENAI_BASE_URL}/chat/completions`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${process.env.OPENAI_API_KEY}`, "x-bitexact-run-id": "loan-APP-7731-proxy", }, body: JSON.stringify({ model: "underwriter-v1", messages: [{ role: "system", content: UNDERWRITER }, { role: "user", content: JSON.stringify( { application: applicant, credit_report: report, policy, dti }) }], }), }); const answer = (await reply.json()).choices[0].message.content; const reasoned = answer.split("REASONS:")[1]; const codes = reasoned.split(".")[0].trim(); return { applicant_id: applicantId, decision: answer.split("DECISION:")[1].split(".")[0].trim().toLowerCase(), reasons: codes === "none" ? [] : codes.split(", "), explanation: reasoned.slice(reasoned.indexOf(".") + 1).trim(), received_at: receivedAt };} const result = await decide("APP-7731");console.log(JSON.stringify(result, null, 2));bench/story/langs/proxy/story_agent.mjs wrapped as bench/story/langs/proxy/story_agent_proxy.mjs, run as loan-APP-7731-proxy and verified when the footage was made. The recording proxy records the model calls, not the tools.
The record
Run it, and the record starts here.
Three imports. A decorator per tool. Each tab: the same agent in TypeScript, Go, Java, or through the proxy.
Three imports. A decorator per tool.
One run around the agent. Everything inside becomes one record.
One run around the agent.
Nothing else changes.
Nothing else changes.
In detail How the recorder runs in your deployment
How the recorder runs in your deployment
In your process through the SDK, or as a recording proxy any OpenAI-compatible client points at. The commands, as the CLI prints them.
$ bitexact quickstart
BitExact quickstart
1) Zero-code - point your OpenAI/Anthropic client at the recorder
export OPENAI_BASE_URL=http://localhost:8787/v1
# Send a header X-BitExact-Run-Id: <your-run-id> on each request so calls
# group into one run. Proxy-only runs capture http traffic and stay
# UNSEALED until you close them: bitexact seal <your-run-id>
# or over HTTP, with the secret in the file BITEXACT_SEAL_SECRET_FILE names:
# curl -X POST -H "Authorization: Bearer <secret>" \
# http://localhost:8787/bitexact/runs/<your-run-id>/seal
2) Make it an exhibit (recommended) - wrap the run with the SDK
# from your platform's release bundle (installing.md); the import is import bitexact
pip install --no-index --find-links wheels --require-hashes -r requirements.lock
pip install --no-index --no-deps bitexact_sdk-*.whl
import bitexact
from bitexact.adapters import wrap_openai
from openai import OpenAI
client = OpenAI()
with bitexact.run("my-run") as r: # a run id records once: use a new one per run
client = wrap_openai(client, r)
@bitexact.record_tool # wrap your real tools
def get_balance(account_id): ...
# ... run your agent as usual; the run seals on exit ...
3) See it verify
bitexact view # open the inspector, click the run, press Verify
No agent handy? Seed a real, verifiable sample run: bitexact seed$ bitexact serve --help
usage: bitexact serve [-h] [--host HOST] [--port PORT] [--tls-cert TLS_CERT]
[--tls-key TLS_KEY] [--tls-client-ca TLS_CLIENT_CA]
options:
-h, --help show this help message and exit
--host HOST
--port PORT
--tls-cert TLS_CERT TLS certificate for the listener
--tls-key TLS_KEY TLS private key
--tls-client-ca TLS_CLIENT_CA
require client certificates signed by this CA (mTLS)
exit codes:
0 OK - the evidence verifies, or the command did what it was asked
1 operational error - a usage error, a missing run or file, input that
cannot be read, or a license refusal: no verdict was reached$ bitexact seal --help
usage: bitexact seal [-h] run_id
positional arguments:
run_id
options:
-h, --help show this help message and exit
exit codes:
0 OK - the evidence verifies, or the command did what it was asked
1 operational error - a usage error, a missing run or file, input that
cannot be read, or a license refusal: no verdict was reached4 · Into BitExact: Every call becomes a step.
Your agent
"""A loan-decision agent: one model call, three tools, a clock read, a ruleand a judgment. The model is whatever OPENAI_BASE_URL points at."""import jsonimport timefrom datetime import datetime, timedelta, timezone import bitexactfrom bitexact.adapters import wrap_openaifrom openai import OpenAI # The credit bureau's mirror, as of its last nightly sync.BUREAU_MIRROR = [ {"applicant_id": "APP-7690", "score": 702, "as_of": "2026-10-30"}, {"applicant_id": "APP-7713", "score": 512, "as_of": "2026-10-30"}, {"applicant_id": "APP-7790", "score": 688, "as_of": "2026-10-30"},]UNDERWRITER = ("You are an underwriter. Apply the policy exactly. Answer as: " "DECISION: <APPROVE|DECLINE|REFER>. REASONS: <codes or none>. " "<one sentence>") @bitexact.record_tooldef fetch_applicant(applicant_id): """The application, as the lending portal holds it.""" return {"applicant_id": applicant_id, "product": "personal-24", "annual_income": 148000, "monthly_debt": 900, "requested": 25000, "years_employed": 9} @bitexact.record_tooldef pull_credit_report(applicant_id): """The applicant's report from the bureau mirror, matched by number.""" number = int(applicant_id.split("-")[1]) return min(BUREAU_MIRROR, key=lambda report: abs( int(report["applicant_id"].split("-")[1]) - number)) @bitexact.record_tooldef check_policy(product): """The underwriting thresholds for a product.""" return {"product": product, "min_score": 620, "max_dti": 0.43, "version": "4.2"} def decide(client, applicant_id): received_at = time.time() applicant = fetch_applicant(applicant_id) report = pull_credit_report(applicant_id) policy = check_policy(applicant["product"]) dti = round(applicant["monthly_debt"] * 12 / applicant["annual_income"], 3) reply = client.chat.completions.create( model="underwriter-v1", messages=[{"role": "system", "content": UNDERWRITER}, {"role": "user", "content": json.dumps( {"application": applicant, "credit_report": report, "policy": policy, "dti": dti})}]) answer = reply.choices[0].message.content codes = answer.split("REASONS:")[1].split(".")[0].strip() return {"applicant_id": applicant_id, "decision": answer.split("DECISION:")[1].split(".")[0].strip().lower(), "reasons": [] if codes == "none" else codes.split(", "), "explanation": answer.split("REASONS:")[1].split(".", 1)[1].strip(), "received_at": received_at} def record_outcome(r, result): r.record_decision(retention_deadline=(datetime.now(timezone.utc) + timedelta(days=760)).isoformat(), output_flags={"is_adverse_action": result["decision"] == "decline", "is_automated_decision": True}, reason_codes=result["reasons"] or None) def main(): client = OpenAI() with bitexact.run("loan-APP-7731", workflow="loan-adjudication", workflow_instance="APP-7731", environment="demo", principal="svc:underwriter") as r: client = wrap_openai(client, r) result = decide(client, "APP-7731") record_outcome(r, result) print(json.dumps(result, indent=2)) return result if __name__ == "__main__": main()- 1identity
c3ccefb66465 - 2time.time
955976b4eab5captured1794198590.739323 - 3fetch_applicant
9740b9aae88a - 4pull_credit_report
9e50dc6c1854 - 5check_policy
a939fc8a11d4 - 6model call
2fd2eee56cac - 7decision record
cb264202ba88 - 8run end
9da0102d4e36
The record
Every call, recorded as it happens. Each step hash-linked to the one before.
Every call, recorded as it happens.
The clock, captured so it replays.
The clock, captured so it replays.
The model's answer, as it came back.
The model's answer, as it came back.
Sealed and signed by your recorder key.
Sealed and signed by your recorder key.
5 · Investigate: Find where it went wrong.
Your agent
"""A loan-decision agent: one model call, three tools, a clock read, a ruleand a judgment. The model is whatever OPENAI_BASE_URL points at."""import jsonimport timefrom datetime import datetime, timedelta, timezone import bitexactfrom bitexact.adapters import wrap_openaifrom openai import OpenAI # The credit bureau's mirror, as of its last nightly sync.BUREAU_MIRROR = [ {"applicant_id": "APP-7690", "score": 702, "as_of": "2026-10-30"}, {"applicant_id": "APP-7713", "score": 512, "as_of": "2026-10-30"}, {"applicant_id": "APP-7790", "score": 688, "as_of": "2026-10-30"},]UNDERWRITER = ("You are an underwriter. Apply the policy exactly. Answer as: " "DECISION: <APPROVE|DECLINE|REFER>. REASONS: <codes or none>. " "<one sentence>") @bitexact.record_tooldef fetch_applicant(applicant_id): """The application, as the lending portal holds it.""" return {"applicant_id": applicant_id, "product": "personal-24", "annual_income": 148000, "monthly_debt": 900, "requested": 25000, "years_employed": 9} @bitexact.record_tooldef pull_credit_report(applicant_id): """The applicant's report from the bureau mirror, matched by number.""" number = int(applicant_id.split("-")[1]) return min(BUREAU_MIRROR, key=lambda report: abs( int(report["applicant_id"].split("-")[1]) - number)) @bitexact.record_tooldef check_policy(product): """The underwriting thresholds for a product.""" return {"product": product, "min_score": 620, "max_dti": 0.43, "version": "4.2"} def decide(client, applicant_id): received_at = time.time() applicant = fetch_applicant(applicant_id) report = pull_credit_report(applicant_id) policy = check_policy(applicant["product"]) dti = round(applicant["monthly_debt"] * 12 / applicant["annual_income"], 3) reply = client.chat.completions.create( model="underwriter-v1", messages=[{"role": "system", "content": UNDERWRITER}, {"role": "user", "content": json.dumps( {"application": applicant, "credit_report": report, "policy": policy, "dti": dti})}]) answer = reply.choices[0].message.content codes = answer.split("REASONS:")[1].split(".")[0].strip() return {"applicant_id": applicant_id, "decision": answer.split("DECISION:")[1].split(".")[0].strip().lower(), "reasons": [] if codes == "none" else codes.split(", "), "explanation": answer.split("REASONS:")[1].split(".", 1)[1].strip(), "received_at": received_at} def record_outcome(r, result): r.record_decision(retention_deadline=(datetime.now(timezone.utc) + timedelta(days=760)).isoformat(), output_flags={"is_adverse_action": result["decision"] == "decline", "is_automated_decision": True}, reason_codes=result["reasons"] or None) def main(): client = OpenAI() with bitexact.run("loan-APP-7731", workflow="loan-adjudication", workflow_instance="APP-7731", environment="demo", principal="svc:underwriter") as r: client = wrap_openai(client, r) result = decide(client, "APP-7731") record_outcome(r, result) print(json.dumps(result, indent=2)) return result if __name__ == "__main__": main()- 1identity
c3ccefb66465 - 2time.time
955976b4eab5 - 3fetch_applicant
9740b9aae88a - 4pull_credit_report
9e50dc6c1854 - 5check_policy
a939fc8a11d4 - 6model call
2fd2eee56cac - 7decision record
cb264202ba88 - 8run end
9da0102d4e36
asked APP-7731
got APP-7713 · score 512
The record
Step 4 misled it.
Step 4 misled it.
The wrong applicant's file.
The wrong applicant's file.
The nearest number, not the applicant. No file yet, so the tool returned the closest one.
The nearest number, not the applicant.
6 · Workflow: A second agent writes the letter.
Your agent
"""The notice writer: a second agent in the loan workflow. It drafts theadverse-action notice for a declined application."""import jsonfrom datetime import datetime, timedelta, timezone import bitexactfrom bitexact.adapters import wrap_openaifrom openai import OpenAI NOTICE_WRITER = ("You draft adverse-action notices. Fill every section of the " "template from the decision; add nothing.") @bitexact.record_tooldef notice_template(kind): """The firm's current template for a notice of this kind.""" return {"kind": kind, "version": "2026-07", "sections": ["decision", "principal_reasons", "rights"]} def write_notice(client, decision): template = notice_template("adverse-action") reply = client.chat.completions.create( model="notice-writer-v1", messages=[{"role": "system", "content": NOTICE_WRITER}, {"role": "user", "content": json.dumps( {"template": template, "decision": decision})}]) return reply.choices[0].message.content def main(decision): client = OpenAI() with bitexact.run("notice-APP-7731", workflow="loan-adjudication", workflow_instance="APP-7731", environment="demo", principal="svc:notice-writer") as r: client = wrap_openai(client, r) notice = write_notice(client, decision) r.record_decision(retention_deadline=(datetime.now(timezone.utc) + timedelta(days=760)).isoformat(), output_flags={"is_communication": True}) print(notice) return notice- 1identity
c3ccefb66465 - 2time.time
955976b4eab5 - 3fetch_applicant
9740b9aae88a - 4pull_credit_report
9e50dc6c1854 - 5check_policy
a939fc8a11d4 - 6model call
2fd2eee56cac - 7decision record
cb264202ba88 - 8run end
9da0102d4e36
- 1identity
536e7dfbaa5d - 2notice_template
b4f87346c527 - 3model call
e62c867f1391 - 4decision record
5b10da9f542c - 5run end
e858779e166b
The record
The inspector's page for workflow loan-adjudicationIt drafts the decline letter. The notice writer turns that wrong decline into an adverse-action notice.
It drafts the decline letter.
Its own run, its own record.
Its own run, its own record.
One workflow, one place.
One workflow, one place.
In detail Multi-agent workflows
Multi-agent workflows
Each agent records its own run; the workflow gathers them.
"""The notice writer: a second agent in the loan workflow. It drafts theadverse-action notice for a declined application."""import jsonfrom datetime import datetime, timedelta, timezone import bitexactfrom bitexact.adapters import wrap_openaifrom openai import OpenAI NOTICE_WRITER = ("You draft adverse-action notices. Fill every section of the " "template from the decision; add nothing.") @bitexact.record_tooldef notice_template(kind): """The firm's current template for a notice of this kind.""" return {"kind": kind, "version": "2026-07", "sections": ["decision", "principal_reasons", "rights"]} def write_notice(client, decision): template = notice_template("adverse-action") reply = client.chat.completions.create( model="notice-writer-v1", messages=[{"role": "system", "content": NOTICE_WRITER}, {"role": "user", "content": json.dumps( {"template": template, "decision": decision})}]) return reply.choices[0].message.content def main(decision): client = OpenAI() with bitexact.run("notice-APP-7731", workflow="loan-adjudication", workflow_instance="APP-7731", environment="demo", principal="svc:notice-writer") as r: client = wrap_openai(client, r) notice = write_notice(client, decision) r.record_decision(retention_deadline=(datetime.now(timezone.utc) + timedelta(days=760)).isoformat(), output_flags={"is_communication": True}) print(notice) return notice$ bitexact workflow --help
usage: bitexact workflow [-h] [--by BY] [--owner OWNER]
[--description DESCRIPTION]
[--criticality {tier-1,tier-2,tier-3}]
[--version VERSION] [--code CODE]
[--policy-version POLICY_VERSION]
[--regress-suite REGRESS_SUITE] [--notes NOTES]
[--to TO] [--from FROM_ENV] [--gate GATE]
[--schedule SCHEDULE] [--reason REASON]
[--regime {sec-17a-4-audit-trail}]
{create,register-version,promote,set-retention,set-records-regime,deprecate,show,verify}
workflow_id
positional arguments:
{create,register-version,promote,set-retention,set-records-regime,deprecate,show,verify}
workflow_id
options:
-h, --help show this help message and exit
--by BY the principal performing the act (default: OS user)
--owner OWNER create: the accountable owner/team
--description DESCRIPTION
--criticality {tier-1,tier-2,tier-3}
create: tier-1 (critical) / tier-2 / tier-3 (standard)
--version VERSION the workflow_version label
--code CODE register-version: git sha / build id
--policy-version POLICY_VERSION
--regress-suite REGRESS_SUITE
register-version: reference to the signed regress
suite proving the version's behavioral delta
--notes NOTES
--to TO promote: the target environment
--from FROM_ENV promote: the source environment
--gate GATE promote: reference to the qualification/regress report
that gated it
--schedule SCHEDULE set-retention: CATEGORY=DURATION[,...], where DURATION
is <n>y (years), <n>m (calendar months) or <n>d
(days), e.g. adverse=7y,standard=3y
--reason REASON deprecate: why the workflow is withdrawn
--regime {sec-17a-4-audit-trail}
set-records-regime: the records regime the workflow
declares for its runs
exit codes:
0 OK - the evidence verifies, or the command did what it was asked
1 operational error - a usage error, a missing run or file, input that
cannot be read, or a license refusal: no verdict was reached
2 FAIL - the evidence does not verify, or a check it requires cannot run
on this install (a library is missing)
2 when `verify` finds the ledger does not verify$ bitexact evidence-pack --help
usage: bitexact evidence-pack [-h] [--workflow WORKFLOW] [--case CASE]
[--runs RUNS] [--environment ENVIRONMENT]
[--workflow-version WORKFLOW_VERSION]
[--since ISO8601] [--until ISO8601]
[--subject KEY] [--principal PRINCIPAL]
[--agent AGENT_ID] [--on-behalf-of PARTY]
[--interaction {interactive,autonomous}]
[--save NAME] [--pack NAME] [--list-packs]
[-o OUTPUT] [--sign] [--key KEY]
[--preparer PREPARER] [--verifier VERIFIER]
[--no-view] [--by BY] [--force]
[run_id]
positional arguments:
run_id a run, or --workflow for a workflow-scoped pack
options:
-h, --help show this help message and exit
--workflow WORKFLOW pack every recorded run of a workflow plus its
governance ledger and an inventory workpaper
--case CASE pack every pinned run of a case plus its chain-of-
custody ledger and a case workpaper
--runs RUNS comma-separated run ids to pack as one selection (an
auditor's list); combines with the filters below,
which then narrow it
--environment ENVIRONMENT
narrow the selection to one environment
--workflow-version WORKFLOW_VERSION
narrow the selection to one workflow version
--since ISO8601 only runs started at or after this instant (inclusive)
--until ISO8601 only runs started before this instant (exclusive, so
adjacent windows tile a period without counting a run
twice)
--subject KEY only the decisions about this subject: the business
key (case, application, customer id) a run committed
as its workflow_instance
--principal PRINCIPAL
only the runs whose agent acted as this identity, as
the run's identity committed it
--agent AGENT_ID only the runs of this agent, as the run's identity
committed its agent_id
--on-behalf-of PARTY only the runs done on behalf of this party, as the
run's current identity states it
--interaction {interactive,autonomous}
only the runs with a person in the session
(interactive) or none (autonomous)
--save NAME also save this selection to the pack library under
NAME, to issue again with --pack
--pack NAME issue a saved pack: its selection, resolved against
the store now
--list-packs list the saved packs and exit
-o, --output OUTPUT pack directory (default <run>.evidence-pack)
--sign sign each run bundle and the graph attestation with
--key
--key KEY path to an ed25519 private key file
--preparer PREPARER preparer name/org for the workpaper cover (default:
the bound principal)
--verifier VERIFIER path to bitexact_verifier.py to vendor into the pack,
pinned by its own sha256 (for air-gapped auditors)
--no-view omit the reading view (data/view.html), for a relying
party that forbids opening HTML; the pack verifies
without it
--by BY the principal exporting, recorded on the custody chain
with role operator (default: OS user)
--force replace an existing bag directory whole (only a bag)
exit codes:
0 OK - the evidence verifies, or the command did what it was asked
1 operational error - a usage error, a missing run or file, input that
cannot be read, or a license refusal: no verdict was reached
2 FAIL - the evidence does not verify, or a check it requires cannot run
on this install (a library is missing)7 · Evidence: Hand it to the auditor.
The evidence pack
- bag-info.txt
- bagit.txt
- data/capture-policy-b2-ce93a52dbf018155afb7a641931ab9a2ccdee0618363591499c85e095411382e.json
- data/governance/loan-adjudication.jsonl
- data/ledger-heads.json
- data/runs/loan-APP-7731.bundle.json
- data/runs/notice-APP-7731.bundle.json
- data/selection.json
- data/verification/VERIFIER.txt
- data/verification/bitexact_verifier.py
- data/view.html
- data/workpaper.md
- manifest-sha256.txt
- tagmanifest-sha256.txt
The auditor's machine
$ python data/verification/bitexact_verifier.py … .OK: evidence pack - 2 runs verified, 1 ledger chain-verified and anchored at the head the pinned recorder key signed (data/governance/loan-adjudication.jsonl at 2 events), bag checksums verified, signed, recorder-attested, signatures: Ed25519 + ML-DSA-65 (post-quantum), post-quantum key pinned, view matches the bundlesstanding when recorded: 2 runs ok (stated by the producer, not proof)captured via sdk-python 11, proxy 2, unlabeled 2 (attribution, not proof)not checked against the runs (the producer's own statements, sealed against later change only): data/capture-policy-b2-ce93a52dbf018155afb7a641931ab9a2ccdee0618363591499c85e095411382e.json, data/selection.json, data/verification/VERIFIER.txt, data/verification/bitexact_verifier.py, data/workpaper.md$ echo $?0Show command
$ python data/verification/bitexact_verifier.py --trust-file ../trust.json --expect-witness witness.auditor.example=f306da622a44850dff9040b2063299c03f54e528801ec8c376fe9b144545f097 .The whole workflow, one sealed pack. Every run, the ledger and the verifier itself.
The whole workflow, one sealed pack.
On the auditor's machine. No license. No network. No BitExact server.
On the auditor's machine.
Exit 0.
Exit 0.
In detail The whole pack, checked: 2 runs, exit 0
The whole pack, checked: 2 runs, exit 0
Every run verifies, and so does the workflow's governance ledger: “1 ledger chain-verified and anchored at the head the pinned recorder key signed”. A changed entry, or one cut from the ledger's end, would show. The lines under the verdict say what it does not prove: what the producer stated, and the files sealed with the pack but not checked against the runs.
$ python data/verification/bitexact_verifier.py … .OK: evidence pack - 2 runs verified, 1 ledger chain-verified and anchored at the head the pinned recorder key signed (data/governance/loan-adjudication.jsonl at 2 events), bag checksums verified, signed, recorder-attested, signatures: Ed25519 + ML-DSA-65 (post-quantum), post-quantum key pinned, view matches the bundlesstanding when recorded: 2 runs ok (stated by the producer, not proof)captured via sdk-python 11, proxy 2, unlabeled 2 (attribution, not proof)not checked against the runs (the producer's own statements, sealed against later change only): data/capture-policy-b2-ce93a52dbf018155afb7a641931ab9a2ccdee0618363591499c85e095411382e.json, data/selection.json, data/verification/VERIFIER.txt, data/verification/bitexact_verifier.py, data/workpaper.md$ echo $?0Show command
$ python data/verification/bitexact_verifier.py --trust-file ../trust.json --expect-witness witness.auditor.example=f306da622a44850dff9040b2063299c03f54e528801ec8c376fe9b144545f097 .In detail How the auditor checks it
How the auditor checks it
The pack carries its own verifier and a workpaper that says how to run it.
The pack's workpaper
# Evidence pack - selected runs
## A. Request
| Filter | Value |
|---|---|
| Workflow | loan-adjudication |
| Workflow version | any |
| Environment | any |
| Agent | any |
| Subject (business key) | any |
| Acting principal | any |
| On behalf of (as the run's current identity states it) | any |
| Person in session | any |
| Started on or after | any |
| Started before | any |
| Named runs | none - filters only |
The window is half-open: a run started exactly at `started_before` belongs to the next period, not this one, so adjacent packs tile a year without counting a run twice.
## B. Scope
**2 runs** are in this pack, each as an independently-verifiable `aer-bundle` under `data/runs/`. `data/selection.json` records the request verbatim beside the result, so a pack whose window genuinely held two runs is distinguishable from one that dropped three.
## D. Runs
| Run | Workflow | Version | Environment | Steps | Sealed | Verifies |
|---|---|---|---|---|---|---|
| `notice-APP-7731` | loan-adjudication | - | demo | 6 | yes | yes |
| `loan-APP-7731` | loan-adjudication | - | demo | 9 | yes | yes |
## E. Governance
The append-only, hash-chained governance ledger of every workflow this selection touches is under `data/governance/`. Each re-walks as a chain: an edited or removed event fails.
- `loan-adjudication` -> `data/governance/loan-adjudication.jsonl`
### Executions refused at open
None recorded for this scope.
## F. Assertions
| Property provided | Requirement it maps to | Honest limit |
|---|---|---|
| A set of runs selected by a stated request, with the request recorded verbatim beside the run ids it matched (data/selection.json). | FINRA 4511(a) / SEA 17a-4(f)(2)(iv) download and transfer for examinations; examination population and sample requests. | The selection is a query over recorded runs - not proof that they are every run the agent performed. |
| Which runs match is read from each run's own hash-chained record, never from an index cell; the time window is half-open, so adjacent packs tile a period without counting a run twice. | Population completeness and period cut-off for testing. | A run's start is the time its first entry records; without an external anchor, step order is proven and wall-clock time is not. |
| Each selected run verifies independently, trusting nothing of ours. | DORA / books-and-records integrity; EU AI Act Art. 12 record-keeping. | Proves integrity of what was captured, never that the capture is the complete set of calls. |
| The append-only, hash-chained governance ledger of every workflow the selection touches. | SR 11-7 (superseded 17 April 2026 by SR 26-2) change control and ownership. | Proves the governance history was not edited, not that the recorded acts were correct or authorized. |
## G. Independent verification
`pip install cryptography asn1crypto` - the verifier's only dependencies - then:
```
# from the pack's root - the bag's checksums, every run, and the governance ledgers, trusting nothing of ours:
python data/verification/bitexact_verifier.py .
# then pin the recorder key you confirmed out of band:
python data/verification/bitexact_verifier.py . --expect-recorder-key <64-hex public key>
```
Exit 0: everything verified. Exit 3: everything verified, and a run declares capture gaps (counted on the OK line). Exit 2: the FAIL line names what failed. Exit 1: an `error:` line names an input the verifier could not use - no verdict. The governance ledgers re-walks as a hash chain - an edited or removed event fails - and must end exactly at the head the recorder key signed in `data/ledger-heads.json`, so dropping its newest events fails too. That holds against whoever handed you the pack only with the recorder key pinned: the OK line says which.
`view.html` shows the runs as readable steps in any browser, offline. It is a reading aid, not a verification result: the verifier renders it again from the bundles and fails the pack if it differs.
## H. Limitations
This pack proves the integrity and reconstructability of the runs it contains. It does not assert that they are every run the agent performed: BitExact records what routes through the proxy or a wrapped client, and a call made out of band leaves no entry. The selection is a query over recorded runs, not over reality.
### Capture controls
#### Run `notice-APP-7731`
- **Capture policy:** the run opened under `b2:ce93a52dbf018155afb7a641931ab9a2ccdee0618363591499c85e095411382e`; its document is `capture-policy-b2-ce93a52dbf018155afb7a641931ab9a2ccdee0618363591499c85e095411382e.json` in this pack, below.
- **Capture outcomes:** 0 fields a capture control touched.
#### Run `loan-APP-7731`
- **Capture policy:** the run opened under `b2:ce93a52dbf018155afb7a641931ab9a2ccdee0618363591499c85e095411382e`; its document is `capture-policy-b2-ce93a52dbf018155afb7a641931ab9a2ccdee0618363591499c85e095411382e.json` in this pack, below.
- **Capture outcomes:** 0 fields a capture control touched.
Capture policy document `capture-policy-b2-ce93a52dbf018155afb7a641931ab9a2ccdee0618363591499c85e095411382e.json`, as this pack carries it:
```json
{
"detectors": {
"pan": {
"keep_last4": true
},
"iban": {},
"credentials": {}
},
"fields": [
{
"kind": "*",
"path": "*",
"mode": "scrub"
}
],
"budget_ms": 1000
}
```
## I. Conclusion & sign-off
BitExact supplies the verifiable facts above and this space; the conclusion is the examiner's to write.
| Role | Name | Date | Conclusion |
|---|---|---|---|
| Preparer | | | (facts as recorded) |
| Reviewer | | | |
| Auditor | | | |The pack, file by file
- bag-info.txt
- bagit.txt
- data/capture-policy-b2-ce93a52dbf018155afb7a641931ab9a2ccdee0618363591499c85e095411382e.json
- data/governance/loan-adjudication.jsonl
- data/ledger-heads.json
- data/runs/loan-APP-7731.bundle.json
- data/runs/notice-APP-7731.bundle.json
- data/selection.json
- data/verification/VERIFIER.txt
- data/verification/bitexact_verifier.py
- data/view.html
- data/workpaper.md
- manifest-sha256.txt
- tagmanifest-sha256.txt
8 · Tamper-evident: Change one byte. The verifier refuses it.
Someone's edit
{ "step": 4, "kind": "tool_call", "data": { "tool": "pull_credit_report", "args": [ "APP-7731" ], "result": { "applicant_id": "APP-7713", "score": 812, "as_of": "2026-10-30" } }, "hash": "9e50dc6c1854db15320cfb825aa890308e59f108c8fb2a9e762c97afafd97ec5"}- 1identity
c3ccefb66465 - 2time.time
955976b4eab5 - 3fetch_applicant
9740b9aae88a - 4pull_credit_report
9e50dc6c1854score512812 - 5check_policy
a939fc8a11d4 - 6model call
2fd2eee56cac - 7decision record
cb264202ba88 - 8run end
9da0102d4e36
The open verifier
$ python bitexact_verifier.py … loan-APP-7731.tampered.bundle.jsonFAIL: step 4: data hash mismatch at path result.score - value tampered or corrupted$ echo $?2Show command
$ python bitexact_verifier.py --trust-file trust.json --expect-witness witness.auditor.example=f306da622a44850dff9040b2063299c03f54e528801ec8c376fe9b144545f097 loan-APP-7731.tampered.bundle.jsonChange one character. Score 512 becomes 812, in a copy.
Change one character.
The chain breaks there. Nothing after it can be shown to be what was recorded.
The chain breaks there.
Refused.
Refused.
A stopping point
You found the mistake. Now fix it.
We don't leave you with a bad record. Replay it, fork it, test your fix.
Branch 1 of 2 · Forking · 4 scenes: Replay to where it went wrong. Fork it. Test the fix.
Your agent
"""A loan-decision agent: one model call, three tools, a clock read, a ruleand a judgment. The model is whatever OPENAI_BASE_URL points at."""import jsonimport timefrom datetime import datetime, timedelta, timezone import bitexactfrom bitexact.adapters import wrap_openaifrom openai import OpenAI # The credit bureau's mirror, as of its last nightly sync.BUREAU_MIRROR = [ {"applicant_id": "APP-7690", "score": 702, "as_of": "2026-10-30"}, {"applicant_id": "APP-7713", "score": 512, "as_of": "2026-10-30"}, {"applicant_id": "APP-7790", "score": 688, "as_of": "2026-10-30"},]UNDERWRITER = ("You are an underwriter. Apply the policy exactly. Answer as: " "DECISION: <APPROVE|DECLINE|REFER>. REASONS: <codes or none>. " "<one sentence>") @bitexact.record_tooldef fetch_applicant(applicant_id): """The application, as the lending portal holds it.""" return {"applicant_id": applicant_id, "product": "personal-24", "annual_income": 148000, "monthly_debt": 900, "requested": 25000, "years_employed": 9} @bitexact.record_tooldef pull_credit_report(applicant_id): """The applicant's report from the bureau mirror, matched by number.""" number = int(applicant_id.split("-")[1]) return min(BUREAU_MIRROR, key=lambda report: abs( int(report["applicant_id"].split("-")[1]) - number)) @bitexact.record_tooldef check_policy(product): """The underwriting thresholds for a product.""" return {"product": product, "min_score": 620, "max_dti": 0.43, "version": "4.2"} def decide(client, applicant_id): received_at = time.time() applicant = fetch_applicant(applicant_id) report = pull_credit_report(applicant_id) policy = check_policy(applicant["product"]) dti = round(applicant["monthly_debt"] * 12 / applicant["annual_income"], 3) reply = client.chat.completions.create( model="underwriter-v1", messages=[{"role": "system", "content": UNDERWRITER}, {"role": "user", "content": json.dumps( {"application": applicant, "credit_report": report, "policy": policy, "dti": dti})}]) answer = reply.choices[0].message.content codes = answer.split("REASONS:")[1].split(".")[0].strip() return {"applicant_id": applicant_id, "decision": answer.split("DECISION:")[1].split(".")[0].strip().lower(), "reasons": [] if codes == "none" else codes.split(", "), "explanation": answer.split("REASONS:")[1].split(".", 1)[1].strip(), "received_at": received_at} def record_outcome(r, result): r.record_decision(retention_deadline=(datetime.now(timezone.utc) + timedelta(days=760)).isoformat(), output_flags={"is_adverse_action": result["decision"] == "decline", "is_automated_decision": True}, reason_codes=result["reasons"] or None) def main(): client = OpenAI() with bitexact.run("loan-APP-7731", workflow="loan-adjudication", workflow_instance="APP-7731", environment="demo", principal="svc:underwriter") as r: client = wrap_openai(client, r) result = decide(client, "APP-7731") record_outcome(r, result) print(json.dumps(result, indent=2)) return result if __name__ == "__main__": main()- 1identity
c3ccefb66465 - 2time.time
955976b4eab5 - 3fetch_applicant
9740b9aae88a - 4fork here
The record it replays from
The inspector's step timeline for loan-APP-7731: steps 0 to 3 are served from itReplayed from the record. Same tools, same clock read, same answers.
Replayed from the record.
Fork here, at step 4. From here, the tool and the model run live.
Fork here, at step 4.
In detail How replay works
How replay works
Replay re-runs your agent against the recorded answers, offline. Any divergence halts at the exact step with a diff.
$ bitexact regress --help
usage: bitexact regress [-h] [--workflow WORKFLOW] [--version REGRESS_VERSION]
[--environment REGRESS_ENVIRONMENT] --entry ENTRY
[--intended INTENDED] [--accept-scrubbed]
[--git-sha GIT_SHA] [--label LABEL] [--sign]
[--key KEY] [-o OUTPUT]
[run_ids ...]
positional arguments:
run_ids explicit run ids, or select by workflow below
options:
-h, --help show this help message and exit
--workflow WORKFLOW regress a whole workflow's corpus instead of explicit
run ids (verifiable version-drift)
--version REGRESS_VERSION
restrict --workflow to this workflow_version
--environment REGRESS_ENVIRONMENT
restrict --workflow to this environment
--entry ENTRY MODULE:FUNCTION taking the active run
--intended INTENDED KIND:PATH change declarations; flips confined to them
are intended, not divergence (repeatable)
--accept-scrubbed serve a value the capture policy scrubbed, as scrubbed
(default: the step is withheld)
--git-sha GIT_SHA code identity for the report
--label LABEL human label for the report
--sign sign the suite report with --key
--key KEY path to an ed25519 private key file
-o, --output OUTPUT
exit codes:
0 OK - the evidence verifies, or the command did what it was asked
1 operational error - a usage error, a missing run or file, input that
cannot be read, or a license refusal: no verdict was reached
1 also when the gate does not pass (a finding, not a failed verification)$ bitexact verify --help
usage: bitexact verify [-h] [--provenance] [--expect-recorder-key HEX]
[--expect-recorder-pq-key HEX|FILE] [--trust-file FILE]
[--require-pq] [--allow-gaps CAUSES]
[--open STEP:PATH | --open-withheld STEP]
[--candidate-file FILE] [--by BY]
run_id
positional arguments:
run_id
options:
-h, --help show this help message and exit
--provenance also cross-check the whole fork ancestry against the
source manifests
--expect-recorder-key HEX
pin this recorder public key in place of the license's
recorder_keys: every checkpoint must be signed by it
and a sealed run must carry one, or verification
FAILS. With --provenance every run in the fork lineage
must carry a head-covering checkpoint signed by it -
without it a fork's descent is only structural (the
source's public hash), forgeable by a store writer
--expect-recorder-pq-key HEX|FILE
the post-quantum (ML-DSA) half of the key --expect-
recorder-key pins: every checkpoint and worm anchor
that key signed must carry a post-quantum signature by
it, so a forger holding only the Ed25519 key fails
--trust-file FILE the open verifier's trust file, read the same way:
every checkpoint must be signed by one of its
recorder_keys, and an entry's pq_public_key demands
that post-quantum half on everything the key signed.
One Ed25519 key listed with two PQ keys allows a
rotation between them. In place of the license's
recorder_keys and of --expect-recorder-key
--require-pq fail a run any of whose checkpoints or worm anchors
carries no post-quantum signature (it pins no key: a
forger who recovers the Ed25519 key can add an ML-DSA
key of their own - pin it to refuse that)
--allow-gaps CAUSES exit 0 rather than 3 when every capture gap the run
declares has one of these causes (comma-separated) or
with 'all'; the gaps are still listed
--open STEP:PATH once the run verifies, test --candidate-file (the
value, as JSON) against this hash-only field with the
capture key: prints matches or does not match (exit
2). Recorded on the run's custody chain first -
testing a candidate is guessing one
--open-withheld STEP once the run verifies, test --candidate-file's exact
bytes against the content the gap at this step
withheld, with the capture key; recorded on the
custody chain like --open
--candidate-file FILE
the candidate --open or --open-withheld tests
--by BY with --open or --open-withheld, the principal testing
the candidate, recorded on the custody chain
exit codes:
0 OK - the evidence verifies, or the command did what it was asked
1 operational error - a usage error, a missing run or file, input that
cannot be read, or a license refusal: no verdict was reached
2 FAIL - the evidence does not verify, or a check it requires cannot run
on this install (a library is missing)
3 verified, with declared capture gaps - an intact record of an
incomplete capture
3 unless --allow-gaps accepts every declared cause; with --open or --open-
withheld, 0 when the candidate matches and 2 when it does notForking · 2 of 4: Fork that step, again and again.
Every fork, one dot
decline: 19refer: 1
The record
report a04119de1f3f
20 forks of step 4.
20 forks of step 4.
19 of 20 decline. Not a fluke. Counts from the fork matrix's own hashed report.
19 of 20 decline. Not a fluke.
Forking · 3 of 4: Put the fix in at that step.
The fix, and the fork
"""A loan-decision agent: one model call, three tools, a clock read, a ruleand a judgment. The model is whatever OPENAI_BASE_URL points at. The candidate fix: the same agent with pull_credit_report fixed to match theapplicant exactly. A miss returns no report instead of the nearest number's.Nothing else differs from story_agent_bitexact.py."""import jsonimport timefrom datetime import datetime, timedelta, timezone import bitexactfrom bitexact.adapters import wrap_openaifrom openai import OpenAI # The credit bureau's mirror, as of its last nightly sync.BUREAU_MIRROR = [ {"applicant_id": "APP-7690", "score": 702, "as_of": "2026-10-30"}, {"applicant_id": "APP-7713", "score": 512, "as_of": "2026-10-30"}, {"applicant_id": "APP-7790", "score": 688, "as_of": "2026-10-30"},]UNDERWRITER = ("You are an underwriter. Apply the policy exactly. Answer as: " "DECISION: <APPROVE|DECLINE|REFER>. REASONS: <codes or none>. " "<one sentence>") @bitexact.record_tooldef fetch_applicant(applicant_id): """The application, as the lending portal holds it.""" return {"applicant_id": applicant_id, "product": "personal-24", "annual_income": 148000, "monthly_debt": 900, "requested": 25000, "years_employed": 9} @bitexact.record_tooldef pull_credit_report(applicant_id): """The applicant's report from the bureau mirror, matched exactly.""" return next((report for report in BUREAU_MIRROR if report["applicant_id"] == applicant_id), {"applicant_id": applicant_id, "found": False}) @bitexact.record_tooldef check_policy(product): """The underwriting thresholds for a product.""" return {"product": product, "min_score": 620, "max_dti": 0.43, "version": "4.2"} def decide(client, applicant_id): received_at = time.time() applicant = fetch_applicant(applicant_id) report = pull_credit_report(applicant_id) policy = check_policy(applicant["product"]) dti = round(applicant["monthly_debt"] * 12 / applicant["annual_income"], 3) reply = client.chat.completions.create( model="underwriter-v1", messages=[{"role": "system", "content": UNDERWRITER}, {"role": "user", "content": json.dumps( {"application": applicant, "credit_report": report, "policy": policy, "dti": dti})}]) answer = reply.choices[0].message.content codes = answer.split("REASONS:")[1].split(".")[0].strip() return {"applicant_id": applicant_id, "decision": answer.split("DECISION:")[1].split(".")[0].strip().lower(), "reasons": [] if codes == "none" else codes.split(", "), "explanation": answer.split("REASONS:")[1].split(".", 1)[1].strip(), "received_at": received_at} def record_outcome(r, result): r.record_decision(retention_deadline=(datetime.now(timezone.utc) + timedelta(days=760)).isoformat(), output_flags={"is_adverse_action": result["decision"] == "decline", "is_automated_decision": True}, reason_codes=result["reasons"] or None) def main(): client = OpenAI() with bitexact.run("loan-APP-7731", workflow="loan-adjudication", workflow_instance="APP-7731", environment="demo", principal="svc:underwriter") as r: client = wrap_openai(client, r) result = decide(client, "APP-7731") record_outcome(r, result) print(json.dumps(result, indent=2)) return result if __name__ == "__main__": main()with bitexact.fork(SOURCE, at_step=strange_step, run_id=FORK) as f: client = wrap_openai(OpenAI(), f) forked = candidate.decide(client, "APP-7731") candidate.record_outcome(f, forked)- 1identity
c3ccefb66465 - 2time.time
955976b4eab5 - 3fetch_applicant
9740b9aae88a
- 5pull_credit_report
5a0a1dc8b935 - 6check_policy
52c613408187 - 7model call
fda33f113bb4 - 8decision record
ccbe38b2fa22 - 9run end
9003ae1ba403
The fork's own run
The inspector's step timeline for loan-APP-7731-candidatedecision decline refer
The fix: no file, no guess. A miss now returns no report instead of the nearest one.
The fix: no file, no guess.
Forked at step 4, run live.
Forked at step 4, run live.
Decline became refer. With no credit file, the case goes to a person. That's the right answer.
Decline became refer.
In detail How a fork works
How a fork works
Replay the run up to a step and let the agent carry on live from there, into a new run of its own; a replacement answer can be served at a recorded call.
with bitexact.fork(SOURCE, at_step=strange_step, run_id=FORK) as f: client = wrap_openai(OpenAI(), f) forked = candidate.decide(client, "APP-7731") candidate.record_outcome(f, forked)$ bitexact fork-matrix --help
usage: bitexact fork-matrix [-h] --at-step AT_STEP --n N --entry ENTRY
[--model MODEL] [--run-prefix RUN_PREFIX]
[--accept-scrubbed] [-o OUTPUT]
source_run
positional arguments:
source_run
options:
-h, --help show this help message and exit
--at-step AT_STEP step index or KIND:N (e.g. http_call:2)
--n N number of live forks to run
--entry ENTRY MODULE:FUNCTION taking the active run, returning the
fork's outcome
--model MODEL declared model dimension for this arm
--run-prefix RUN_PREFIX
prefix for the fork run ids
--accept-scrubbed serve the prefix's scrubbed values as scrubbed
(default: a fork past one is refused)
-o, --output OUTPUT also write the report to a path
exit codes:
0 OK - the evidence verifies, or the command did what it was asked
1 operational error - a usage error, a missing run or file, input that
cannot be read, or a license refusal: no verdict was reached
1 also when a fork erroredForking · 4 of 4: Old against new, side by side.
Every fork, one dot
decline: 19refer: 1
refer: 20
The record
reports a04119de1f3f and 7ea56eee0732
19 → 0 declines
20 more forks, with the fix. Same step, same replayed history.
20 more forks, with the fix.
0 of 20 decline now. Compared in BitExact: rates, the difference with its 95% interval, Fisher's p-value.
0 of 20 decline now.
Know your fix fixes the failure, at the step where it failed.
Up to 1,000 forks from the inspector; as many as you like from code.
A stopping point
If you ever had to give logs to somebody.
A regulator, an auditor, a court. What would it take?
Branch 2 of 2 · Logs · 5 scenes: A health agent, and the day someone asks.
Patient triage is high-risk under the EU AI Act. Its records can be asked for. The EU AI Act mapping.
A health agent, as its author writes it
"""A triage agent: reads what the clinic system holds for a visit andrecommends one next step. Each visit is its own run under one workflow."""import jsonfrom datetime import datetime, timedelta, timezone import bitexactfrom bitexact.adapters import wrap_openaifrom openai import OpenAI TRIAGE = ("You are a triage assistant. Recommend one next step - SELF_CARE, " "PRIMARY_CARE within 48 hours, or URGENT_CARE today - and give the " "reason in one sentence.") # What the clinic holds per patient. Free-text notes carry identifiers. (Synthetic.)VISITS = { "PT-48213": {"age_band": "40-49", "symptoms": ["cough", "fever"], "days": 3, "note": "MRN-4821736. Reach at r.okafor@example.org. SSN 123-45-6789 on file."}, "PT-50977": {"age_band": "60-69", "symptoms": ["chest_pain", "breathless"], "days": 1, "note": "MRN-5097704. Daughter is contact: l.brandt@example.org."}, "PT-51140": {"age_band": "20-29", "symptoms": ["sore_throat"], "days": 2, "note": "MRN-5114029. Prefers email: dev.sato@example.org."}, "PT-52388": {"age_band": "50-59", "symptoms": ["back_pain"], "days": 12, "note": "MRN-5238815. SSN 234-56-7890 given at intake."}, "PT-54702": {"age_band": "70-79", "symptoms": ["dizziness", "chest_pain"], "days": 1, "note": "MRN-5470263. Lives alone."},}# Each visit: patient, agent, environment, case.FLEET = [ ("PT-48213", "svc:triage-north", "demo-north", "case-1001"), ("PT-50977", "svc:triage-north", "demo-north", "case-1002"), ("PT-51140", "svc:triage-south", "demo-south", "case-1003"), ("PT-52388", "svc:triage-south", "demo-south", "case-1004"), ("PT-51140", "svc:triage-south", "demo-south", "case-1003"), ("PT-54702", "svc:triage-north", "demo-north", "case-1005"),] # The patient ID is kept hash-only: never stored, but still matchable.@bitexact.record_tool(custody={"args": "hash-only"})def patient_context(patient_id): """The visit's clinical context and intake note.""" return VISITS[patient_id] def recommend(client, patient_id): context = patient_context(patient_id) reply = client.chat.completions.create( model="triage-v1", messages=[{"role": "system", "content": TRIAGE}, {"role": "user", "content": json.dumps(context)}]) return reply.choices[0].message.content def main(patient_id, visit, *, principal, environment, subject): client = OpenAI() with bitexact.run(f"triage-visit-{visit:03d}", workflow="patient-triage", principal=principal, environment=environment, workflow_instance=subject) as r: client = wrap_openai(client, r) advice = recommend(client, patient_id) # Kept ten years. r.record_decision(retention_deadline=(datetime.now(timezone.utc) + timedelta(days=3653)).isoformat()) return adviceThe record
Nobody has asked yet.
A triage agent. Reads a patient's intake note, recommends a priority.
A triage agent.
Real notes carry SSNs and record numbers.
Real notes carry SSNs and record numbers.
You may not be worried today.
You may not be worried today.
Logs · 2 of 5: Then many agents, all on BitExact.
The fleet
"""A triage agent: reads what the clinic system holds for a visit andrecommends one next step. Each visit is its own run under one workflow."""import jsonfrom datetime import datetime, timedelta, timezone import bitexactfrom bitexact.adapters import wrap_openaifrom openai import OpenAI TRIAGE = ("You are a triage assistant. Recommend one next step - SELF_CARE, " "PRIMARY_CARE within 48 hours, or URGENT_CARE today - and give the " "reason in one sentence.") # What the clinic holds per patient. Free-text notes carry identifiers. (Synthetic.)VISITS = { "PT-48213": {"age_band": "40-49", "symptoms": ["cough", "fever"], "days": 3, "note": "MRN-4821736. Reach at r.okafor@example.org. SSN 123-45-6789 on file."}, "PT-50977": {"age_band": "60-69", "symptoms": ["chest_pain", "breathless"], "days": 1, "note": "MRN-5097704. Daughter is contact: l.brandt@example.org."}, "PT-51140": {"age_band": "20-29", "symptoms": ["sore_throat"], "days": 2, "note": "MRN-5114029. Prefers email: dev.sato@example.org."}, "PT-52388": {"age_band": "50-59", "symptoms": ["back_pain"], "days": 12, "note": "MRN-5238815. SSN 234-56-7890 given at intake."}, "PT-54702": {"age_band": "70-79", "symptoms": ["dizziness", "chest_pain"], "days": 1, "note": "MRN-5470263. Lives alone."},}# Each visit: patient, agent, environment, case.FLEET = [ ("PT-48213", "svc:triage-north", "demo-north", "case-1001"), ("PT-50977", "svc:triage-north", "demo-north", "case-1002"), ("PT-51140", "svc:triage-south", "demo-south", "case-1003"), ("PT-52388", "svc:triage-south", "demo-south", "case-1004"), ("PT-51140", "svc:triage-south", "demo-south", "case-1003"), ("PT-54702", "svc:triage-north", "demo-north", "case-1005"),] # The patient ID is kept hash-only: never stored, but still matchable.@bitexact.record_tool(custody={"args": "hash-only"})def patient_context(patient_id): """The visit's clinical context and intake note.""" return VISITS[patient_id] def recommend(client, patient_id): context = patient_context(patient_id) reply = client.chat.completions.create( model="triage-v1", messages=[{"role": "system", "content": TRIAGE}, {"role": "user", "content": json.dumps(context)}]) return reply.choices[0].message.content def main(patient_id, visit, *, principal, environment, subject): client = OpenAI() with bitexact.run(f"triage-visit-{visit:03d}", workflow="patient-triage", principal=principal, environment=environment, workflow_instance=subject) as r: client = wrap_openai(client, r) advice = recommend(client, patient_id) # Kept ten years. r.record_decision(retention_deadline=(datetime.now(timezone.utc) + timedelta(days=3653)).isoformat()) return advice- 1triage-visit-001
8e49b501b4ad - 2triage-visit-002
d91bcb7b9234 - 3triage-visit-003
271154ce059d - 4triage-visit-004
568bf27b46a9 - 5triage-visit-005
0d77b4861771 - 6triage-visit-006
184053bb2a4e
The record
The inspector's page for workflow patient-triage: 6 recorded visitsEvery visit, a signed run. Six visits here, by two agent identities. A year is the same, only longer.
Every visit, a signed run.
One day a regulator asks for a year of records. Where would you get them? How would you prove they're real?
One day a regulator asks for a year of records.
Logs · 3 of 5: Picture it without BitExact.
An illustration
Who knows where the logs are?
app.log vendor-export.csv app.log.3 (rotated)
Some tools barely log.
09:14 09:15 ...... 11:40
Can you prove nobody changed them?
09:16:44 triage SELF_CARE
Hand over too much, and you give more than was asked.
patient=PT-00000 symptoms=...
The record
No record to hand over.
Scattered, and full of gaps.
Scattered, and full of gaps.
Unprovable, or too much.
Unprovable, or too much.
Three of your best people. Three months. Pulled off other work to find, check and cut the logs.
Three of your best people. Three months.
Logs · 4 of 5: With BitExact, it's already done.
Your capture policy
{
"detectors": {
"pan": {"keep_last4": true},
"iban": {},
"credentials": {},
"us_ssn": {"require_context": true},
"email": {},
"custom": [{"name": "mrn", "pattern": "\\bMRN-\\d{7}\\b"}]
},
"fields": [{"kind": "*", "path": "*", "mode": "scrub"}],
"budget_ms": 1000
}Before
MRN-4821736. Reach at r.okafor@example.org. SSN 123-45-6789 on file.As recorded
[MRN]. Reach at [EMAIL]. SSN [SSN] on file.scrubbed: email, mrn, us_ssn
The record
$ python data/verification/bitexact_verifier.py … .OK: evidence pack - 6 runs verified, 1 ledger chain-verified and anchored at the head the pinned recorder key signed (data/governance/patient-triage.jsonl at 2 events), bag checksums verified, signed, recorder-attested, signatures: Ed25519 + ML-DSA-65 (post-quantum), post-quantum key pinned, 12 fields scrubbed before capture (email 8, mrn 12, us_ssn 4), 6 fields hash-only (never held; 6 can be opened with key fc3a..), view matches the bundlesScrubbed and hash-only fields prove that a value was presented and which rule removed it. They do not prove what the value was, they cannot be replayed, and hash-only custody is not HIPAA de-identification.standing when recorded: 6 runs ok (stated by the producer, not proof)captured via sdk-python 24, proxy 6, unlabeled 6 (attribution, not proof)not checked against the runs (the producer's own statements, sealed against later change only): data/capture-policy-b2-59d03c7f30ba8385115c081ed83f9550485119d2a2605da72b2432c9485f6425.json, data/selection.json, data/verification/VERIFIER.txt, data/verification/bitexact_verifier.py, data/workpaper.md$ echo $?0Show command
$ python data/verification/bitexact_verifier.py --trust-file ../trust.json --expect-witness witness.auditor.example=f306da622a44850dff9040b2063299c03f54e528801ec8c376fe9b144545f097 .Your capture policy, written once.
Your capture policy, written once.
Never stored. 12 fields scrubbed before anything is written.
Never stored.
One pack. Checked offline.
One pack. Checked offline.
Logs · 5 of 5: Slice by day, by workflow, down to one agent.
The request
- Every recommendation the agent made
bitexact evidence-pack --workflow patient-triage6 of 6 runs - Only the visits before a cut-off time
bitexact evidence-pack --workflow patient-triage --until 2026-11-09T04:29:53.232233+00:003 of 6 runs - Only the visits from that time on
bitexact evidence-pack --workflow patient-triage --since 2026-11-09T04:29:53.232233+00:003 of 6 runs - Only what one agent identity did
bitexact evidence-pack --workflow patient-triage --principal svc:triage-south3 of 6 runs - Only one case's visits
bitexact evidence-pack --workflow patient-triage --subject case-10032 of 6 runs - Only one deployment's runs
bitexact evidence-pack --workflow patient-triage --environment demo-north3 of 6 runs
One sealed pack per request
- By workflow001002003004005006exit 0
- Before a cut-off001002003004005006exit 0
- From the cut-off on001002003004005006exit 0
- By agent001002003004005006exit 0
- By subject001002003004005006exit 0
- By environment001002003004005006exit 0
Everything in the workflow.
Everything in the workflow.
By time. Cut at any date; a year is cut the same way.
By time.
Down to one agent.
Down to one agent.
What else can BitExact do?
How you deploy it
It runs inside your network. Nothing phones home.
Recorder, store and keys on your infrastructure. License checked offline.
- In process
The SDK
Python, TypeScript, Go and Java. The few lines from the story.
- Many hosts
A collector per host
Many hosts, one recorder. Only the recorder holds the signing key.
- Any language
The recording proxy
No code change: point your client's base URL at it.
- First sealed run in under 30 minutes.
- Needs a TPM 2.0 on the recorder host.
- The proxy is a service: if it's down, calls through it fail. Use the SDK where that matters.
In detail Deploying it: recorder, collector, keys and license
Deploying it: recorder, collector, keys and license
A recorder key signs the evidence; the license is bound to it and checked offline; agents on many hosts ship to a collector. As the CLI prints them.
$ bitexact keygen --help
usage: bitexact keygen [-h] -o OUTPUT [--force]
[--suite {hybrid,ed25519,witness-ml-dsa-44}]
[--pq-alg {ML-DSA-65,ML-DSA-87}]
options:
-h, --help show this help message and exit
-o, --output OUTPUT where to write the ed25519 key; the post-quantum half,
its ML-DSA seed as 64 hex characters, goes to
<name>.pq.key
--force overwrite an existing key file
--suite {hybrid,ed25519,witness-ml-dsa-44}
hybrid (default); ed25519 alone - classical-only, not
resistant to a quantum forger; or witness-ml-dsa-44, a
witness key that cosigns with C2SP ML-DSA-44
cosignatures
--pq-alg {ML-DSA-65,ML-DSA-87}
the post-quantum parameter set (default
$BITEXACT_PQ_ALG, else ML-DSA-65). The key file holds
only the 32-byte seed, so the signer takes the set
from $BITEXACT_PQ_ALG too: set it to the same value
where the key signs
exit codes:
0 OK - the evidence verifies, or the command did what it was asked
1 operational error - a usage error, a missing run or file, input that
cannot be read, or a license refusal: no verdict was reached$ bitexact license --help
usage: bitexact license [-h] {request,install,usage} ...
positional arguments:
{request,install,usage}
request write a renewal request, signed by the recorder key
and carrying a TPM quote, to send to BitExact
install install the renewed license BitExact sent back over
the license BITEXACT_LICENSE names
usage count the production agent identities seen recording
in the last 90 days, beside the licensed band
options:
-h, --help show this help message and exit
exit codes:
0 OK - the evidence verifies, or the command did what it was asked
1 operational error - a usage error, a missing run or file, input that
cannot be read, or a license refusal: no verdict was reached$ bitexact recorder --help
usage: bitexact recorder [-h] [--host HOST] [--port PORT]
[--tls-cert TLS_CERT] [--tls-key TLS_KEY]
[--tls-client-ca TLS_CLIENT_CA] [--tls-crl TLS_CRL]
[--insecure-loopback] [--health-port PORT]
[--health-host HEALTH_HOST]
options:
-h, --help show this help message and exit
--host HOST
--port PORT
--tls-cert TLS_CERT the recorder's TLS certificate
--tls-key TLS_KEY its private key
--tls-client-ca TLS_CLIENT_CA
the CA whose client certificates identify collectors
(required)
--tls-crl TLS_CRL the CA's revocation list (`bitexact pki crl`): a
revoked host is refused, even mid-session
--insecure-loopback development only: plaintext on loopback
--health-port PORT serve /livez, /readyz and /metrics over plain HTTP on
this port, for orchestrator probes and Prometheus
--health-host HEALTH_HOST
address the health port binds (default 0.0.0.0, so the
kubelet can reach it)
exit codes:
0 OK - the evidence verifies, or the command did what it was asked
1 operational error - a usage error, a missing run or file, input that
cannot be read, or a license refusal: no verdict was reached$ bitexact collector --help
usage: bitexact collector [-h] --spool SPOOL --recorder HOST:PORT
[--tls-cert TLS_CERT] [--tls-key TLS_KEY]
[--tls-ca TLS_CA] [--intake-port INTAKE_PORT]
[--intake-host HOST] [--intake-exposed]
[--max-spool-bytes MAX_SPOOL_BYTES]
[--intake-secret-file FILE]
[--drain-seconds DRAIN_S] [--insecure-loopback]
options:
-h, --help show this help message and exit
--spool SPOOL directory for the encrypted spool, host key and intake
secret
--recorder HOST:PORT
--tls-cert TLS_CERT this host's client certificate
--tls-key TLS_KEY its private key
--tls-ca TLS_CA the CA that issued the recorder's certificate
--intake-port INTAKE_PORT
--intake-host HOST the address the intake listens on (default 127.0.0.1).
Another address is for a collector in a container,
whose loopback agents outside it cannot reach: publish
the port to the host's loopback only, and say so with
--intake-exposed
--intake-exposed the --intake-host address is a container's, its port
published to the host's loopback alone. Without it a
non-loopback intake refuses to start
--max-spool-bytes MAX_SPOOL_BYTES
--intake-secret-file FILE
the host secret agents authenticate with, placed by
you where agents running as other users are granted
read access (at least 32 characters); default: created
owner-only in the spool
--drain-seconds DRAIN_S
on SIGTERM, keep forwarding for up to this long until
the spool is empty (default 30)
--insecure-loopback development only: plaintext to a loopback recorder
exit codes:
0 OK - the evidence verifies, or the command did what it was asked
1 operational error - a usage error, a missing run or file, input that
cannot be read, or a license refusal: no verdict was reached$ bitexact view --help
usage: bitexact view [-h] [--host HOST] [--port PORT] [--tls-cert TLS_CERT]
[--tls-key TLS_KEY]
options:
-h, --help show this help message and exit
--host HOST
--port PORT
--tls-cert TLS_CERT TLS certificate for the listener
--tls-key TLS_KEY TLS private key
exit codes:
0 OK - the evidence verifies, or the command did what it was asked
1 operational error - a usage error, a missing run or file, input that
cannot be read, or a license refusal: no verdict was reachedAnd the rest, in brief.
Evidence
A changed byte breaks the chain
Post-quantum signatures
Hybrid Ed25519 with ML-DSA-65 and ML-DSA-87.
Anchored outside your store
RFC 3161 timestamps, WORM storage and a transparency log.
Yours
On your premises
Your keys. Nothing phones home.
Privacy
Scrubbed before it is written
Under the shipped default and the PCI preset, or your own policy. See it
Redaction, two‑person control
Scoped disclosure
Hand over exactly what was asked
Cut by a period, a workflow, a workflow version, an agent identity, a subject, an environment, a case, hand-picked runs and the party acted for. See it
Investigate
Replay
Fork and compare
Fits your stack
SDKs
Python, TypeScript, Go and Java.
Integrations
- OpenAI client
- Anthropic client
- Temporal
- LangChain and LangGraph
- LlamaIndex
- OpenAI Agents SDK
- Claude Agent SDK
- Google ADK
- MCP servers, via the MCP proxy
- OpenTelemetry ingest
- Your governance layer's decisions
- Any OpenAI- or Anthropic-compatible client, via the recording proxy
Independent proof
The open verifier
Apache-2.0, offline. bitexact.org
Honest limits
What it is not
Not a governance layer. Not a test harness.
What it proves
Integrity and declared gaps. Never that every call was captured.
Not de-identification
Hash-only custody is not HIPAA de-identification.
Your agents, as they are. Recorded.
A pilot runs BitExact on your real traffic, inside your network.