Skip to content
BitExactproduct

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.

The BitExact inspector's verdict for run loan-APP-7731: Intact, sealed and signed.

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.

  1. You build an agent. Plain Python, the way you already write one. Nothing of BitExact in it.

    You build an agent.

  2. It calls a model and three tools. The application, the credit report, the lending policy.

    It calls a model and three tools.

  3. A clock read and a rule.

    A clock read and a rule.

  4. 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 $?0

The record

No record. If anyone asks why, there is nothing to show them.

  1. Run it.

    Run it.

  2. A strong applicant. Declined. Income 148k, debt-to-income 7.3%, 9 years employed. Turned down on a 512 score.

    A strong applicant. Declined.

  3. 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()
18lines added0of the agent's 68 changed

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);
20lines added0of the agent's 72 changed

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		})}
27lines added0of the agent's 165 changed

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));    }}
26lines added0of the agent's 93 changed

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));
1lines added0of the agent's 65 changed

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.

  1. 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.

  2. One run around the agent. Everything inside becomes one record.

    One run around the agent.

  3. 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 reached

4 · 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()
  1. 0run meta7c08ec9b4e12
  2. 1identityc3ccefb66465
  3. 2time.time955976b4eab5captured 1794198590.739323
  4. 3fetch_applicant9740b9aae88a
  5. 4pull_credit_report9e50dc6c1854
  6. 5check_policya939fc8a11d4
  7. 6model call2fd2eee56cac
  8. 7decision recordcb264202ba88
  9. 8run end9da0102d4e36

The record

The inspector's step timeline for loan-APP-7731
The inspector's view of step 6: the model's response as it came back
The inspector's verdict for loan-APP-7731
  1. Every call, recorded as it happens. Each step hash-linked to the one before.

    Every call, recorded as it happens.

  2. The clock, captured so it replays.

    The clock, captured so it replays.

  3. The model's answer, as it came back.

    The model's answer, as it came back.

  4. 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()
  1. 0run meta7c08ec9b4e12
  2. 1identityc3ccefb66465
  3. 2time.time955976b4eab5
  4. 3fetch_applicant9740b9aae88a
  5. 4pull_credit_report9e50dc6c1854
  6. 5check_policya939fc8a11d4
  7. 6model call2fd2eee56cac
  8. 7decision recordcb264202ba88
  9. 8run end9da0102d4e36

asked APP-7731

got APP-7713 · score 512

The record

The inspector's summary of step 4, pull_credit_report
The inspector's view of step 4's result: the credit file of APP-7713, score 512
  1. Step 4 misled it.

    Step 4 misled it.

  2. The wrong applicant's file.

    The wrong applicant's file.

  3. 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
  1. 0run meta7c08ec9b4e12
  2. 1identityc3ccefb66465
  3. 2time.time955976b4eab5
  4. 3fetch_applicant9740b9aae88a
  5. 4pull_credit_report9e50dc6c1854
  6. 5check_policya939fc8a11d4
  7. 6model call2fd2eee56cac
  8. 7decision recordcb264202ba88
  9. 8run end9da0102d4e36
  1. 0run meta3b24423a6453
  2. 1identity536e7dfbaa5d
  3. 2notice_templateb4f87346c527
  4. 3model calle62c867f1391
  5. 4decision record5b10da9f542c
  6. 5run ende858779e166b

The record

The inspector's page for workflow loan-adjudication
  1. It drafts the decline letter. The notice writer turns that wrong decline into an adverse-action notice.

    It drafts the decline letter.

  2. Its own run, its own record.

    Its own run, its own record.

  3. 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
The notice writer run
The Runs screen

$ 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 $?0
Show command
$ python data/verification/bitexact_verifier.py --trust-file ../trust.json --expect-witness witness.auditor.example=f306da622a44850dff9040b2063299c03f54e528801ec8c376fe9b144545f097 .
  1. The whole workflow, one sealed pack. Every run, the ledger and the verifier itself.

    The whole workflow, one sealed pack.

  2. On the auditor's machine. No license. No network. No BitExact server.

    On the auditor's machine.

  3. 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 $?0
Show 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"}
  1. 0run meta7c08ec9b4e12
  2. 1identityc3ccefb66465
  3. 2time.time955976b4eab5
  4. 3fetch_applicant9740b9aae88a
  5. 4pull_credit_report9e50dc6c1854score 512 812
  6. 5check_policya939fc8a11d4
  7. 6model call2fd2eee56cac
  8. 7decision recordcb264202ba88
  9. 8run end9da0102d4e36

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 $?2
Show command
$ python bitexact_verifier.py --trust-file trust.json --expect-witness witness.auditor.example=f306da622a44850dff9040b2063299c03f54e528801ec8c376fe9b144545f097 loan-APP-7731.tampered.bundle.json
  1. Change one character. Score 512 becomes 812, in a copy.

    Change one character.

  2. The chain breaks there. Nothing after it can be shown to be what was recorded.

    The chain breaks there.

  3. 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.

Close branch

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()
  1. 0run meta7c08ec9b4e12
  2. 1identityc3ccefb66465
  3. 2time.time955976b4eab5
  4. 3fetch_applicant9740b9aae88a
  5. 4fork here

The record it replays from

The inspector's step timeline for loan-APP-7731: steps 0 to 3 are served from it
  1. Replayed from the record. Same tools, same clock read, same answers.

    Replayed from the record.

  2. 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 not

Forking · 2 of 4: Fork that step, again and again.

Close branch

Every fork, one dot

20 forks of the agent as it is, at step 4

decline: 19refer: 1

The record

underwriter-v1, baseline agent: 20 forks at step 4
  1. decline19 of 20
  2. refer1 of 20

report a04119de1f3f

  1. 20 forks of step 4.

    20 forks of step 4.

  2. 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.

Close branch

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)
  1. 0run meta7c08ec9b4e12
  2. 1identityc3ccefb66465
  3. 2time.time955976b4eab5
  4. 3fetch_applicant9740b9aae88a
  1. 5pull_credit_report5a0a1dc8b935
  2. 6check_policy52c613408187
  3. 7model callfda33f113bb4
  4. 8decision recordccbe38b2fa22
  5. 9run end9003ae1ba403

The fork's own run

The inspector's step timeline for loan-APP-7731-candidate

decision decline refer

The inspector comparing loan-APP-7731-candidate with its source: the decision went from decline to refer
  1. The fix: no file, no guess. A miss now returns no report instead of the nearest one.

    The fix: no file, no guess.

  2. Forked at step 4, run live.

    Forked at step 4, run live.

  3. 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)
The fork run

$ 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 errored

Forking · 4 of 4: Old against new, side by side.

Close branch

Every fork, one dot

20 forks, as it is

decline: 19refer: 1

20 forks, with the fix

refer: 20

The record

underwriter-v1, baseline agent, as it is
  1. decline19 of 20
  2. refer1 of 20
underwriter-v1, candidate agent, with the fix
  1. refer20 of 20

reports a04119de1f3f and 7ea56eee0732

19 → 0 declines

The inspector comparing the two fork matrices at step 4: each outcome's count and rate per arm, the difference with its 95% interval, and Fisher's exact p-value
  1. 20 more forks, with the fix. Same step, same replayed history.

    20 more forks, with the fix.

  2. 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.

Close branch

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 advice

The record

Nobody has asked yet.

  1. A triage agent. Reads a patient's intake note, recommends a priority.

    A triage agent.

  2. Real notes carry SSNs and record numbers.

    Real notes carry SSNs and record numbers.

  3. You may not be worried today.

    You may not be worried today.

Logs · 2 of 5: Then many agents, all on BitExact.

Close branch

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
  1. 1triage-visit-0018e49b501b4ad
  2. 2triage-visit-002d91bcb7b9234
  3. 3triage-visit-003271154ce059d
  4. 4triage-visit-004568bf27b46a9
  5. 5triage-visit-0050d77b4861771
  6. 6triage-visit-006184053bb2a4e

The record

The inspector's page for workflow patient-triage: 6 recorded visits
  1. Every visit, a signed run. Six visits here, by two agent identities. A year is the same, only longer.

    Every visit, a signed run.

  2. 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.

Close branch

An illustration

Illustration, not real output

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.

  1. Scattered, and full of gaps.

    Scattered, and full of gaps.

  2. Unprovable, or too much.

    Unprovable, or too much.

  3. 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.

Close branch

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

The inspector's integrity view of visit triage-visit-001: the scrubbed and hash-only fields, as recorded
$ 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 $?0
Show command
$ python data/verification/bitexact_verifier.py --trust-file ../trust.json --expect-witness witness.auditor.example=f306da622a44850dff9040b2063299c03f54e528801ec8c376fe9b144545f097 .
  1. Your capture policy, written once.

    Your capture policy, written once.

  2. Never stored. 12 fields scrubbed before anything is written.

    Never stored.

  3. One pack. Checked offline.

    One pack. Checked offline.

Logs · 5 of 5: Slice by day, by workflow, down to one agent.

Close branch

The request

  1. Every recommendation the agent madebitexact evidence-pack --workflow patient-triage6 of 6 runs
  2. Only the visits before a cut-off timebitexact evidence-pack --workflow patient-triage --until 2026-11-09T04:29:53.232233+00:003 of 6 runs
  3. Only the visits from that time onbitexact evidence-pack --workflow patient-triage --since 2026-11-09T04:29:53.232233+00:003 of 6 runs
  4. Only what one agent identity didbitexact evidence-pack --workflow patient-triage --principal svc:triage-south3 of 6 runs
  5. Only one case's visitsbitexact evidence-pack --workflow patient-triage --subject case-10032 of 6 runs
  6. Only one deployment's runsbitexact evidence-pack --workflow patient-triage --environment demo-north3 of 6 runs

One sealed pack per request

  1. By workflow001002003004005006exit 0
  2. Before a cut-off001002003004005006exit 0
  3. From the cut-off on001002003004005006exit 0
  4. By agent001002003004005006exit 0
  5. By subject001002003004005006exit 0
  6. By environment001002003004005006exit 0
  1. Everything in the workflow.

    Everything in the workflow.

  2. By time. Cut at any date; a year is cut the same way.

    By time.

  3. Down to one agent.

    Down to one agent.

Start recording before anyone asks.

Exactly what was asked, and nothing more.

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.

  1. In process

    The SDK

    Python, TypeScript, Go and Java. The few lines from the story.

  2. Many hosts

    A collector per host

    Many hosts, one recorder. Only the recorder holds the signing key.

  3. Any language

    The recording proxy

    No code change: point your client's base URL at it.

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 reached

And the rest, in brief.

Evidence

  • A changed byte breaks the chain

    See it

  • 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

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

    How the recorder runs

Independent proof

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.