Prove an AI Agent's Decision Was Never Altered (Loan Underwriting)

Give a loan-review agent a hard governance envelope, then hand a regulator a tamper-evident, hash-chained record of every decision it made — all in SQL, inside one database.

All recipes· agents· 15 minutesintermediatesql
Instance: localhost:8080

Opens your running SynapCores (Prove an AI Agent's Decision Was Never Altered (Loan Underwriting) will be staged for a preview — nothing runs until you click Run). No instance yet? Install free in ~30s.

Share

Objective

"Your model declined this application. Prove the decision record hasn't been edited since."

That question ends most AI-in-production conversations in regulated lending. The agent ran somewhere, the log went somewhere else, and the log is a mutable row in a table an operator can UPDATE. Under ECOA/FCRA you owe an adverse-action reason that provably matches what the system actually decided.

This recipe builds the answer as database mechanics, not policy:

  1. Guard rails — a governance envelope declared in DDL: the agent may never write, may never exceed N reasoning steps, and has a hard daily token budget.
  2. Traceability — every run is recorded automatically in _system_agent_runs: what triggered it, when, what it decided, what it cost.
  3. Tamper-evidence — those runs are sha256 hash-chained. Editing one in storage breaks its own hash and every downstream link.
  4. Durability — the trail is mirrored into a CREATE IMMUTABLE TABLE ledger the engine physically refuses to UPDATE or DELETE.

Requires: SynapCores v1.9.1+ (the decision-lineage columns on _system_agent_runs). Every statement below was validated end-to-end on a stock v1.11.0-ce container — with no AI configuration at all, since CE ships a tool-capable default model (qwen2.5-coder:7b) and auto-pulls it on first boot.

Step 1: The application table

CREATE TABLE IF NOT EXISTS recipe_loan_applications (
  app_id           INTEGER PRIMARY KEY,
  applicant        TEXT,
  amount_usd       DOUBLE,
  credit_score     INTEGER,
  dti_ratio        DOUBLE,
  employment_years DOUBLE,
  submitted_at     TIMESTAMP
);

Step 2: Create the agent — the guardrails ARE the DDL

Everything after WITH is the governance envelope. It is stored with the agent, enforced by the engine on every run, and readable back as data (Step 5) — not a prompt the model can talk its way around.

CREATE AGENT recipe_loan_reviewer
  PERSONA 'aidb-assistant'
  TASK 'Review the loan application in the activation row. Using execute_query on recipe_loan_applications, state a recommendation of APPROVE, REFER or DECLINE and the two facts that drove it. Answer in under 60 words.'
  ON INSERT INTO recipe_loan_applications
  WITH (
    max_iterations        = 3,          -- hard cap on ReAct reasoning steps
    allow_writes          = FALSE,      -- the agent CANNOT mutate any table
    timeout_seconds       = 120,        -- per-run wall clock
    budget_tokens_per_day = 200000,     -- spend ceiling
    on_budget_exhausted   = 'pause'     -- go quiet, don't fail open
  );

Three of those deserve emphasis:

  • allow_writes = FALSE is the default and the only switch. There is no other route by which an agent's database tools may mutate a row. A recommender that cannot write cannot quietly approve a loan.
  • ON INSERT INTO is AFTER-semantics. The agent observes committed rows; it cannot abort or alter your INSERT.
  • on_budget_exhausted = 'pause' fails closed. A runaway agent stops making decisions rather than making cheap ones.

Step 3: Submit applications — note how fast the write returns

INSERT INTO recipe_loan_applications VALUES
 (9001,'Dana Whitfield',  45000.00, 712, 0.31,  6.0, '2026-07-20 09:15:00'),
 (9002,'Marcus Ellery',  180000.00, 588, 0.54,  1.5, '2026-07-20 10:02:00'),
 (9003,'Priya Raghavan',  22000.00, 780, 0.18, 11.0, '2026-07-20 11:40:00');

This returns in milliseconds. Event activation enqueues a job — the LLM loop never runs inside your transaction. Your write path keeps its latency profile no matter how slow the model is.

The three agent runs now execute in the background. Give them a moment (a local 7B model takes roughly 30–60 seconds per run; a hosted model is faster) before Step 4.

Step 4: Read the decision trail

SELECT agent_name, activation, started_at, status, tokens_in, tokens_out, output
FROM   _system_agent_runs
ORDER  BY started_at;

One row per decision, written by the engine — not by your application code, and not opt-in:

agent_name activation status output
recipe_loan_reviewer INSERT ON recipe_loan_applications success APPROVE — Credit Score: 712 (above threshold); DTI Ratio: 0.31 (within range)
recipe_loan_reviewer INSERT ON recipe_loan_applications success DECLINE — Credit Score: 588 (below threshold); DTI Ratio: 0.54 (above range)
recipe_loan_reviewer INSERT ON recipe_loan_applications success APPROVE — Credit Score: 780 (above threshold); DTI Ratio: 0.18 (below range)

activation is the provenance an auditor asks for first: what caused this decision to be made at all?

Step 5: Read back the guardrails as evidence

A regulator doesn't want your word that the agent couldn't write. They want the configuration.

DESCRIBE AGENT recipe_loan_reviewer;
Persona               | aidb-assistant
Task                  | Review the loan application in the activation row...
Bindings              | INSERT ON recipe_loan_applications
Enabled               | true
max_iterations        | 3
allow_writes          | false
timeout_seconds       | 120
budget_tokens_per_day | 200000
on_budget_exhausted   | pause
memory                | persistent
max_retries           | 1
Created At            | 2026-07-24T00:14:18Z

The governance envelope is a queryable database object with creation and modification timestamps. That is an auditable control, not a screenshot of a config file.

Step 6: The hash chain — this is what makes it tamper-evident

SELECT agent_name, prev_hash, entry_hash, verified
FROM   _system_agent_runs
ORDER  BY started_at;
recipe_loan_reviewer   prev 000000000000…  ->  entry f80c31a854e0…   verified: true
recipe_loan_reviewer   prev f80c31a854e0…  ->  entry 4d05e1b280fa…   verified: true
recipe_loan_reviewer   prev 4d05e1b280fa…  ->  entry 24948964514b…   verified: true

Each run's entry_hash is sha256(prev_hash ‖ the run's immutable content) — run id, agent, activation, timestamps, status, iterations, tokens, output. The first run links to a genesis value of 64 zeros. Every run after it links to the one before.

verified is not a stored flag. The engine recomputes the entire chain on read and reports whether each row still hashes to what it claims. To forge one decision undetectably you would have to rewrite the whole tail of the chain and the stored ledger head.

The chain is per tenant, not per agent — every agent's runs share one ledger. You cannot excise one agent's history without breaking everything recorded after it.

Step 7: The compliance control

One number, and the only acceptable value is zero:

SELECT COUNT(*) AS unverified_runs
FROM   _system_agent_runs
WHERE  verified = false;
unverified_runs
---------------
0

Run it on a schedule. Anything above zero means a decision record no longer matches its hash — investigate before you answer a single regulator question. (verified reads NULL for runs recorded before v1.9.1, which were never chained — honestly "not attestable" rather than a false green.)

Step 8: Pin the trail into an immutable ledger

The hash chain detects tampering. An immutable table prevents it, and survives independently of the agent runtime's own retention window.

CREATE IMMUTABLE TABLE recipe_decision_ledger (
  run_id      TEXT PRIMARY KEY,
  agent_name  TEXT,
  activation  TEXT,
  decided_at  TEXT,
  status      TEXT,
  entry_hash  TEXT,
  decision    TEXT
);

INSERT INTO recipe_decision_ledger
  (run_id, agent_name, activation, decided_at, status, entry_hash, decision)
SELECT run_id, agent_name, activation, started_at, status, entry_hash, output
FROM   _system_agent_runs;

Now try to cover one up:

UPDATE recipe_decision_ledger SET decision = 'APPROVE' WHERE status = 'success';
DELETE FROM recipe_decision_ledger WHERE status = 'success';
Error: Cannot UPDATE immutable table 'recipe_decision_ledger'. Immutable tables are append-only.
Error: Cannot DELETE from immutable table 'recipe_decision_ledger'. Immutable tables are append-only.

Not a permission you can grant yourself. Append-only is a property of the storage engine.

Step 9: The kill switch

Governance you can't exercise isn't governance. Stop the agent without destroying its history:

ALTER AGENT recipe_loan_reviewer DISABLE;

SHOW AGENTS;
name                  | persona        | bindings                           | state    | tokens_today
recipe_loan_reviewer  | aidb-assistant | INSERT ON recipe_loan_applications | disabled | 450

Now submit another application:

INSERT INTO recipe_loan_applications VALUES
 (9004,'Test Applicant', 99000.00, 640, 0.45, 2.0, '2026-07-21 08:00:00');

The row lands. No run is recorded — the binding is torn down, so no decision is made. Meanwhile tokens_today still shows the 450 tokens already spent: disabling an agent doesn't erase its accounting.

Even a full DROP AGENT retains the run history — the audit outlives the agent by design.

Cleanup

DROP AGENT IF EXISTS recipe_loan_reviewer;
DROP TABLE IF EXISTS recipe_loan_applications;
DROP TABLE IF EXISTS recipe_decision_ledger;

Expected Outcomes

  • Step 3 returns in milliseconds — the agent runs off the write path.
  • Step 4 shows one auto-written row per decision, with its triggering event.
  • Step 6 shows a linked hash chain starting from 64 zeros, every row verified = true.
  • Step 7 returns 0.
  • Step 8 rejects both UPDATE and DELETE at the storage engine.
  • Step 9 shows a disabled agent that stops deciding but keeps its history and its token accounting.

Use it from your agent

  • REST / SDK — every statement here runs through POST /v1/query/execute. Your compliance dashboard is the Step-7 query on a timer; your adverse-action export is the Step-4 query filtered to a date range.
  • MCP — point any MCP client at ws://<your-instance>/mcp?token=<jwt> and the query tool reaches the same trail. An investigator's assistant can read the audit without a bespoke API.
  • Why in-database matters here — a stitched stack puts the decision in one system, the log in a second, and the tamper-evidence in a third (if it exists at all). Here the decision, its provenance, its governance envelope, its cost, and its cryptographic proof are rows in the same database, reachable by one SELECT. And it runs entirely on your own infrastructure — a hosted agent platform cannot offer "provable and it never leaves your building."

Key Concepts Learned

  • CREATE AGENT … WITH (…) makes the governance envelope a database object, enforced by the engine and readable back with DESCRIBE AGENT — the difference between a guardrail and a polite instruction in a prompt.
  • allow_writes = FALSE is the single switch that separates a recommender from an actor, and it is the default.
  • _system_agent_runs records every run automatically — you cannot forget to instrument an agent.
  • prev_hash / entry_hash / verified make the audit tamper-evident; CREATE IMMUTABLE TABLE makes it tamper-resistant. Use both: detection plus prevention.
  • Event activation is AFTER-semantics — agents observe committed rows and never sit inside your write path.

Related recipes

Tags

ai-agentcreate-agentaudit-trailimmutablegovernanceguardrailscompliancefintechlendingtraceability

Run this on your own machine

Install SynapCores Community Edition free, paste the SQL or Cypher above into the bundled web UI, and watch it run.

Download Free CE