# Dual Agent Auditor — a fail closed verification layer for automated DB writes

A sanitised reference implementation. Drop it into Claude Code, point it at your own
schema, adapt the prompts. Nothing here is tied to any specific business; the schema
and field names below are generic on purpose.

## The problem

When an automated step writes a record to your database, how do you know it wrote the
*right* one? You can log the action. But the same bug that causes a wrong write logs it
just as confidently. You end up with a confident wrong record, and every downstream
decision inherits the error.

## The pattern

Two agents, separated by capability:

1. **Matcher** — proposes a record (a reconciliation, a classification, a link between
   two rows). It can be cheap and eager.
2. **Auditor** — a second, independent agent that *only reads*. It has no write tools.
   It looks at the source data and the proposed record and returns a confidence verdict.

A write only happens when the auditor confirms. Otherwise the proposal stays in a
`pending` state for a human to glance at. The auditor **fails closed**: uncertain is
treated exactly like a definite mismatch. Ghost records can never accumulate silently —
the worst case is a pending item, never a confirmed but wrong one.

```
source rows ──> [matcher] ──proposal──> [auditor: read-only] ──┐
                                                               │ confirm?
                                          pending <── no/unsure ┘
                                          write   <── yes (>= threshold)
```

## Minimal reference implementation

Generic schema — rename to fit yours:

```sql
CREATE TABLE records   (id INTEGER PRIMARY KEY, key TEXT, payload TEXT, status TEXT DEFAULT 'pending');
CREATE TABLE proposals (id INTEGER PRIMARY KEY, record_id INTEGER, proposed_key TEXT, source_ref TEXT);
CREATE TABLE audit_log (id INTEGER PRIMARY KEY, proposal_id INTEGER, verdict TEXT, confidence REAL, note TEXT, ts TEXT);
```

```python
# dual_agent_auditor.py  — reference only, swap in your own LLM client + schema.
import sqlite3, json

CONFIDENCE_THRESHOLD = 0.85   # tune per task; higher = more conservative

def matcher_propose(conn, source_ref):
    """Cheap, eager. Produce a candidate record from a source reference.
    Replace this body with your own retrieval / LLM match logic."""
    # ... your matching logic returns a dict like:
    return {"proposed_key": "abc-123", "source_ref": source_ref}

def auditor_verify(source_row, proposal):
    """READ ONLY. No DB handle, no write tools. Returns (verdict, confidence, note).
    In production this is a separate agent/model call whose system prompt says:
    'You verify. Default to refuted when uncertain. Return JSON {match, confidence, note}.'
    The stub below shows the contract; replace with your model call."""
    # result = llm_json(system=AUDITOR_PROMPT, user=render(source_row, proposal))
    result = {"match": False, "confidence": 0.0, "note": "stub"}   # <- replace
    verdict = "confirm" if (result["match"] and result["confidence"] >= CONFIDENCE_THRESHOLD) else "reject"
    return verdict, result["confidence"], result["note"]

def run(conn, source_ref, source_row):
    p = matcher_propose(conn, source_ref)
    verdict, conf, note = auditor_verify(source_row, p)
    cur = conn.cursor()
    cur.execute("INSERT INTO proposals(proposed_key, source_ref) VALUES (?,?)",
                (p["proposed_key"], p["source_ref"]))
    pid = cur.lastrowid
    cur.execute("INSERT INTO audit_log(proposal_id, verdict, confidence, note, ts) "
                "VALUES (?,?,?,?,datetime('now'))", (pid, verdict, conf, note))
    if verdict == "confirm":
        cur.execute("UPDATE records SET key=?, status='confirmed' WHERE id IS NOT NULL AND status='pending'",
                    (p["proposed_key"],))   # scope this to the real record id in your code
    # else: leave status='pending' for a human. Fail closed: do nothing on uncertainty.
    conn.commit()
    return verdict
```

## Why it works

- **Capability separation, not just a second prompt.** The auditor literally cannot
  write, so a hallucinated "looks good" can't turn into a bad row.
- **Fail closed.** Uncertain == reject. The error budget is spent on human review, not
  on silent corruption.
- **Everything is logged** (`audit_log`) whether it confirms or rejects, so you can tune
  the threshold from real data instead of guessing.

## Adapt it

- Set `CONFIDENCE_THRESHOLD` per task — reconciliation can be strict, low stakes tagging
  looser.
- Give the auditor a *different* lens than the matcher (different prompt, even a
  different model) so it catches failure modes the matcher is blind to.
- For high stakes writes, run N auditors and require a majority to confirm.

Built for my own multi entity stack; this is the generalised version. Take it, rip it
apart, tell me what breaks.
