Deterministic First: Where AI Authority Ends

Where AI authority ends: a reference architecture for retrieval-backed, human-approved workflows, with three boundaries, a state contract, and a tested approval gate that rejects a recipient swap.

Deterministic First

A reference architecture for retrieval-backed, human-approved workflows with LangGraph.

My application uses AI to write emails. It does not use AI to send them.

I call the approach deterministic-first, and it reduces to one sentence:

The model proposes. The application authorizes. Code executes.

My previous article mapped the infrastructure for building AI applications. This one is about where a model's authority should end, using one internal application as the case study.

"Deterministic" here means explicit control flow: the path from "approved" to "sent" is ordinary code you can read and test. It does not mean the system is correct, that mail is delivered, or that it is free of security bugs.

What the application does

An internal tool on Cloud Run, with WorkOS staff sign-in and a four-node LangGraph workflow, backed by a database checkpointer.

Staff request
     ↓
Retrieve similar sent emails            ← context boundary
     ↓
Draft subject and body            (model)
     ↓
Persist workflow state (checkpoint)
     ↓
Human review / revision / approval      ← approval boundary
     ↓
Persist approved version
     ↓
─── model-directed work ends ───
     ↓
Authorize and execute send (code)       ← execution boundary

The model has discretion in two places: choosing what to say, and weighing the retrieved examples. It has no discretion over who receives the message, whether it is approved, or whether it is sent.

Every step in the graph is predetermined by code, so "AI-assisted workflow" is the accurate description. If the model chose its own retrieval actions or next steps, that would be bounded autonomy, which is a legitimate design with different controls. Anthropic draws this line between workflows, which follow predefined code paths, and agents, which direct their own process and tool use (Building effective agents).

Three boundaries

1. Context boundary: what may the model see?

The workflow retrieves similar previously sent emails and puts them in the prompt. That is retrieval-backed adaptation from prior sent emails. It supplies information at inference time; it does not change model weights.

That makes the retrieval policy the real design surface:

  • Which sent messages are eligible for reuse?
  • How are mailbox and tenant permissions enforced at retrieval time?
  • Can an obsolete or incorrect example be removed?
  • Does a human edit affect what is retrieved next time?
  • What evaluation shows the adaptation actually helps?

Retrieved emails are also untrusted input. A past message can contain instructions, and an internal tool with no chat box still processes them.

2. Approval boundary: what exactly was approved?

"Human in the loop" is incomplete until you say what the human approved. Approval attaches to an immutable version: tenant, sender, recipients (including CC and BCC), subject, body, and attachments. The approval stores a digest of that content, and send recomputes it.

This follows established transaction-authorization guidance: authorization belongs to the exact transaction being executed, and a change must not silently inherit an earlier approval (OWASP Transaction Authorization Cheat Sheet). That supports the principle; it does not certify this implementation.

3. Execution boundary: what can the sender do, and as whom?

The sender is code, not a tool the model can call. It re-checks authorization at send time, verifies the approval digest, claims the approval before calling the provider, and records the attempt.

Authority matrix

Deny by default. Each row is a permission granted explicitly, not an inference from the tool list.

ActorRetrieveDraftApproveSendHold credentials
Modelwithin tenant, via workflowyesnonono
Reviewer (staff)n/aedityes, own tenantnono
Backend policyenforces tenant scopen/averifies approververifies senderno
Sender (code)n/an/an/ayes, approved digest onlyyes

An allowlisted tool name is not enough authorization on its own. Each invocation should be checked against the actor, the resource, the arguments, and the workflow state (OWASP Authorization Cheat Sheet).

Three records, not one

A checkpoint, an approval, and a send are different things that can share a database without becoming the same record.

RecordAnswersNot a substitute for
CheckpointWhere is the workflow, and what is its state?Approval, or exactly-once send
ApprovalWhich artifact did which person authorize?A record that anything was sent
Send recordWhich execution attempt happened, and what came back?Approval

LangGraph's persistence supports saved graph state and human-in-the-loop pauses (LangGraph persistence). One implementation detail matters here: resuming from an interrupt re-runs the node that contains it from the start. Any side effect placed before the interrupt in that node can execute twice. Keep side effects out of nodes that interrupt, and put them behind an idempotent gate.

The gate

States: approved → sending → sent | unknown, and unknown → sent | rejected only through a person. The full reference implementation is in the appendix. It is a self-contained sketch, not an excerpt from the production app, and it keeps state in memory.

Properties worth checking in the code rather than trusting in prose:

  1. Approval is bound to content. Change a recipient and the digest changes.
  2. The claim is synchronous. state = "sending" is set before any await, so a concurrent request cannot slip through.
  3. Uncertainty is a state. A timeout becomes unknown, and nothing resends until a person resolves it.
  4. Authorization is rechecked at send time, not only at approval time.

Failure cases, run

The tests are in the appendix. Run with node --test (Node's built-in runner):

✔ approved draft is sent exactly as approved
✔ unauthorized user cannot approve or send
✔ cross-tenant approval is refused
✔ changing the recipient after approval is rejected
✔ adding a BCC after approval is rejected
✔ sequential retry after success does not send twice
✔ concurrent sends reach the transport once
✔ transport timeout does not trigger an automatic resend
✔ unknown outcome is resolved by a person, not by retrying
ℹ tests 9
ℹ pass 9
ℹ fail 0

Swapping alice@example.com for mallory@example.com after approval fails with REJECTED: content changed after approval, and the transport is never called.

One note from building this: my first version of the gate passed its sequential retry test and still sent twice under two concurrent requests. The test list above includes the concurrency case because that bug was real.

What I verified, and what I did not

StatusWhat
VerifiedThe gate above against a fake transport: nine tests covering approval binding, send-time authorization, tenant refusal, idempotent and concurrent retries, unknown-outcome handling.
Described design, not verified hereThe LangGraph wiring, the database checkpointer, retrieval over prior sent emails, and the real Gmail sender.
Not doneRetrieval and draft-quality evaluations, durable approval storage with transactional claiming, an audit record spanning model, prompt, and reviewer edits.

The nine tests exercise application logic. They do not evaluate whether retrieved examples are relevant or whether drafts are accurate.

Two kinds of tests

Deterministic tests cover application invariants: authorization, approval binding, tenant isolation, duplicate execution, credentials never entering model-visible data. These are the nine above, plus the ones that need the real app.

Model evaluations cover variable behavior. Define acceptance evaluations before implementation and expand them with observed failures:

  • Does retrieval return relevant, permitted examples?
  • Do drafts preserve the supplied facts and avoid inventing commitments?
  • Is missing information handled instead of papered over?
  • Do drafts resist instructions embedded in retrieved or source text?

Passing evaluations is a release criterion, not a complete definition of quality. Agent-evaluation guidance treats this as broader than any single set of deterministic checks (Demystifying evals for AI agents).

Reproducibility means preserving what happened

Do not depend on regenerating a draft later. Persist:

  • Retrieved evidence and which examples were used.
  • Draft versions, model, and prompt version.
  • Human edits and what the reviewer saw.
  • The approval and its digest.
  • The send record and provider response.

Recording an execution and recreating an identical model output are different goals. The first is achievable; the second is not guaranteed.

Credentials: two different things

There are two layers, and expiry on one is not proof of the other:

  • A provider access token (for example a Google OAuth access token) has a lifetime and a scope. Expiry limits how long it is useful; it does not make it single-use.
  • A transaction-specific authorization is your own approval record: this digest, this approver, this attempt.

A gmail.send scope means "may send email", which is broader than "may send this approved email". The application enforces the narrower rule. The model never needs the credential the backend needs.

What this design does not solve

  • A bad draft that gets approved. Review and restricted authority address different failures.
  • Prompt injection. The design contains its effect to the draft; it does not make the draft trustworthy (OWASP Top 10 for LLM Applications).
  • Execution isolation. This workflow runs no generated code or untrusted connectors. If yours does, sandboxing is a separate requirement, and it is an extension point here, not a component this application needs.
  • Delivery. The provider accepting a message is not the recipient reading it.

What you can reuse

  1. Three boundaries: context, approval, execution.
  2. An authority matrix with deny by default.
  3. Three records: checkpoint, approval, send.
  4. A state machine where unknown is a first-class state.
  5. Two test tracks: invariants in code, behavior in evaluations.

And five questions for any AI-assisted action:

  1. Can the model authorize the action?
  2. Can approved content change after approval?
  3. Who holds the credentials?
  4. What happens on a retry, a concurrent request, or a timeout?
  5. Can you reconstruct what was sent and why?

If you cannot answer one, that is where the boundary is not yet explicit.

The model proposes. The application authorizes. Code executes.

Appendix: reference implementation

approval-gate.mjs

import { createHash, randomUUID } from "node:crypto";

// Canonical form of everything a human approves. Any change => different digest.
export function digest(draft) {
  const canon = JSON.stringify({
    tenant: draft.tenant,
    from: draft.from,
    to: [...draft.to].sort(),
    cc: [...(draft.cc ?? [])].sort(),
    bcc: [...(draft.bcc ?? [])].sort(),
    subject: draft.subject,
    body: draft.body,
    attachments: (draft.attachments ?? []).map((a) => a.sha256).sort(),
  });
  return createHash("sha256").update(canon).digest("hex");
}

// approved -> sending -> sent | unknown ; unknown -> (human) sent | rejected
export function createGate({ transport, canApprove, canSend }) {
  const approvals = new Map();
  const sendLog = [];

  return {
    approve(user, draft) {
      if (!canApprove(user, draft)) throw new Error("FORBIDDEN: user may not approve");
      const id = randomUUID();
      approvals.set(id, { digest: digest(draft), approver: user.id, state: "approved" });
      return id;
    },
    async send(user, approvalId, draft) {
      if (!canSend(user, draft)) throw new Error("FORBIDDEN: user may not send");
      const a = approvals.get(approvalId);
      if (!a) throw new Error("REJECTED: no such approval");
      if (a.digest !== digest(draft)) throw new Error("REJECTED: content changed after approval");
      if (a.state === "sent") return { status: "already-sent" };
      if (a.state === "sending") throw new Error("IN_PROGRESS: send already running");
      if (a.state === "unknown") throw new Error("HOLD: previous outcome unknown, needs manual check");
      if (a.state === "rejected") throw new Error("REJECTED: approval was voided");
      a.state = "sending"; // claimed synchronously, before any await
      try {
        await transport(draft);
        a.state = "sent";
        sendLog.push({ approvalId, approver: a.approver, sender: user.id, digest: a.digest, at: Date.now() });
        return { status: "sent" };
      } catch {
        a.state = "unknown"; // a timeout is NOT permission to send again
        throw new Error("HOLD: send outcome unknown");
      }
    },
    // A person checks the provider's sent folder, then records what happened.
    resolveUnknown(user, approvalId, outcome) {
      const a = approvals.get(approvalId);
      if (!a || a.state !== "unknown") throw new Error("REJECTED: nothing to resolve");
      if (!canSend(user, null)) throw new Error("FORBIDDEN: user may not resolve");
      a.state = outcome === "was-sent" ? "sent" : "rejected";
    },
    sendLog,
  };
}

approval-gate.test.mjs

import test from "node:test";
import assert from "node:assert/strict";
import { createGate } from "./approval-gate.mjs";

const draft = () => ({ tenant: "acme", from: "ops@example.com", to: ["alice@example.com"], subject: "Renewal", body: "Hi Alice, ..." });
const staff = { id: "staff-1" };
const setup = (transport = async () => {}) => {
  const sent = [];
  const gate = createGate({
    transport: async (d) => { await transport(d); sent.push(d); },
    canApprove: (u, d) => u.id === "staff-1" && (!d || d.tenant === "acme"),
    canSend: (u) => u.id === "staff-1",
  });
  return { gate, sent };
};

test("approved draft is sent exactly as approved", async () => {
  const { gate, sent } = setup();
  const d = draft(); const id = gate.approve(staff, d);
  assert.deepEqual(await gate.send(staff, id, d), { status: "sent" });
  assert.equal(sent.length, 1);
});

test("unauthorized user cannot approve or send", async () => {
  const { gate } = setup();
  assert.throws(() => gate.approve({ id: "intern" }, draft()), /FORBIDDEN/);
  const d = draft(); const id = gate.approve(staff, d);
  await assert.rejects(gate.send({ id: "intern" }, id, d), /FORBIDDEN/);
});

test("cross-tenant approval is refused", () => {
  const { gate } = setup();
  assert.throws(() => gate.approve(staff, { ...draft(), tenant: "globex" }), /FORBIDDEN/);
});

test("changing the recipient after approval is rejected", async () => {
  const { gate, sent } = setup();
  const d = draft(); const id = gate.approve(staff, d);
  await assert.rejects(gate.send(staff, id, { ...d, to: ["mallory@example.com"] }), /content changed after approval/);
  assert.equal(sent.length, 0);
});

test("adding a BCC after approval is rejected", async () => {
  const { gate, sent } = setup();
  const d = draft(); const id = gate.approve(staff, d);
  await assert.rejects(gate.send(staff, id, { ...d, bcc: ["mallory@example.com"] }), /content changed/);
  assert.equal(sent.length, 0);
});

test("sequential retry after success does not send twice", async () => {
  const { gate, sent } = setup();
  const d = draft(); const id = gate.approve(staff, d);
  await gate.send(staff, id, d);
  assert.deepEqual(await gate.send(staff, id, d), { status: "already-sent" });
  assert.equal(sent.length, 1);
});

test("concurrent sends reach the transport once", async () => {
  const { gate, sent } = setup(() => new Promise((r) => setTimeout(r, 20)));
  const d = draft(); const id = gate.approve(staff, d);
  const results = await Promise.allSettled([gate.send(staff, id, d), gate.send(staff, id, d)]);
  assert.equal(sent.length, 1);
  assert.equal(results.filter((r) => r.status === "rejected").length, 1);
});

test("transport timeout does not trigger an automatic resend", async () => {
  let calls = 0;
  const { gate } = setup(async () => { calls++; throw new Error("ETIMEDOUT"); });
  const d = draft(); const id = gate.approve(staff, d);
  await assert.rejects(gate.send(staff, id, d), /outcome unknown/);
  await assert.rejects(gate.send(staff, id, d), /previous outcome unknown/);
  assert.equal(calls, 1);
});

test("unknown outcome is resolved by a person, not by retrying", async () => {
  const { gate, sent } = setup(async () => { throw new Error("ETIMEDOUT"); });
  const d = draft(); const id = gate.approve(staff, d);
  await assert.rejects(gate.send(staff, id, d), /outcome unknown/);
  gate.resolveUnknown(staff, id, "was-sent");
  assert.deepEqual(await gate.send(staff, id, d), { status: "already-sent" });
  assert.equal(sent.length, 0);
});