An AI agent can send messages, move money, and change records. A prompt transcript does not prove that authorization ran first. This runnable validator checks a narrower contract: every denied or escalated decision record declares pre_execution and occupies the checked chain position, session order is intact, and later edits break the SHA-256 hash chain. It does not prove that a tool completed or the final state is correct.

What the artifact does

The validator accepts JSONL audit records and checks:

  • mandatory identity, session, action, outcome, and phase fields;
  • controlled action, outcome, and phase values;
  • parent links and one session across the chain;
  • non-decreasing timestamps;
  • a SHA-256 link to the canonical previous record;
  • pre_execution phase for denied or escalated decisions.

With no input file, it builds a clean two-record fixture, validates it, changes the first record after the second hash is set, and validates again. The executed artifact returned:

json
{
  "clean_errors": [],
  "tampered_errors": ["1: broken hash chain"],
  "phase_cases": {
    "denied_pre_execution": [],
    "denied_post_execution": ["0: denied decision must be pre_execution"],
    "escalated_pre_execution": [],
    "escalated_concurrent": ["0: escalated decision must be pre_execution"]
  }
}

It exited 0. The clean chain validates; changing the earlier record breaks the chain; denied and escalated decisions pass only when they declare pre_execution. These checks validate declared fields and chain order, not an external side-effect clock.

Design decisions

Record authorization before action. A post-action trace shows that something happened. It cannot prove that a policy gate controlled the side effect. In my test, the fixture starts with a decision event whose record_phase is pre_execution; the validator checks the declaration and chain order, not when an external side effect occurred.

Keep the vocabulary small. The artifact accepts tool_call, tool_response, decision, delegation, escalation, error, and lifecycle. Outcomes are success, failure, timeout, denied, or escalated.

Link records, not raw secrets. Each record points to its parent and carries the SHA-256 digest of the canonical prior record. Real systems should hash or securely reference sensitive payloads rather than copying credentials, personal data, or full prompts into an analytics store.

Separate operational traces from audit evidence. OpenTelemetry’s GenAI agent conventions, verified September 19, 2026, define development-status spans for agents, workflows, plans, and tool execution. Those spans are a good operational base. Authorization phase and tamper evidence remain explicit controls in this artifact.

The field vocabulary also draws from the September 2026 IETF Agent Audit Trail draft. It is an Internet-Draft, not an adopted standard. Its useful ideas here are pre-execution records, independent recording, controlled action types, and chained hashes.

Run it

Download the complete AI agent audit-log validator. Save it as ai-agent-audit-log.mjs, then run node ai-agent-audit-log.mjs. To validate a file, run node ai-agent-audit-log.mjs audit.jsonl.

The complete downloadable source is reproduced below for inspection:

js
import { createHash, randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';

const required = [
  'record_id', 'timestamp', 'agent_id', 'agent_version', 'session_id',
  'action_type', 'action_detail', 'outcome', 'parent_record_id',
  'prev_hash', 'record_phase',
];
const actions = new Set(['tool_call', 'tool_response', 'decision', 'delegation', 'escalation', 'error', 'lifecycle']);
const outcomes = new Set(['success', 'failure', 'timeout', 'denied', 'escalated']);
const phases = new Set(['pre_execution', 'post_execution', 'concurrent']);

function canonical(value) {
  if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
  if (value && typeof value === 'object') {
    return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
  }
  return JSON.stringify(value);
}
function digest(record) {
  return createHash('sha256').update(canonical(record)).digest('hex');
}
function validate(records) {
  const errors = [];
  const ids = new Set();
  for (let i = 0; i < records.length; i++) {
    const r = records[i];
    for (const field of required) if (!(field in r)) errors.push(`${i}: missing ${field}`);
    if (ids.has(r.record_id)) errors.push(`${i}: duplicate record_id`);
    ids.add(r.record_id);
    if (!actions.has(r.action_type)) errors.push(`${i}: invalid action_type`);
    if (!outcomes.has(r.outcome)) errors.push(`${i}: invalid outcome`);
    if (!phases.has(r.record_phase)) errors.push(`${i}: invalid record_phase`);
    if (Number.isNaN(Date.parse(r.timestamp))) errors.push(`${i}: invalid timestamp`);
    if (i === 0) {
      if (r.parent_record_id !== null || r.prev_hash !== null) errors.push('0: genesis must have null parent and hash');
    } else {
      const prev = records[i - 1];
      if (r.session_id !== prev.session_id) errors.push(`${i}: session changed`);
      if (r.parent_record_id !== prev.record_id) errors.push(`${i}: broken parent link`);
      if (r.prev_hash !== digest(prev)) errors.push(`${i}: broken hash chain`);
      if (Date.parse(r.timestamp) < Date.parse(prev.timestamp)) errors.push(`${i}: timestamp moved backwards`);
    }
    if (r.action_type === 'decision' && ['denied', 'escalated'].includes(r.outcome) && r.record_phase !== 'pre_execution') {
      errors.push(`${i}: ${r.outcome} decision must be pre_execution`);
    }
  }
  return errors;
}

if (process.argv[2]) {
  const text = await readFile(process.argv[2], 'utf8');
  const records = text.trim().split('\n').filter(Boolean).map(JSON.parse);
  const errors = validate(records);
  console.log(JSON.stringify({ records: records.length, valid: errors.length === 0, errors }, null, 2));
  process.exitCode = errors.length ? 1 : 0;
} else {
  const session = randomUUID();
  const first = {
    record_id: randomUUID(), timestamp: '2026-09-19T05:00:00.000Z',
    agent_id: 'urn:agent:invoice-bot.example', agent_version: '1.0.0', session_id: session,
    action_type: 'decision', action_detail: { decision_type: 'approve', policy_ref: 'invoice-limit-v3' },
    outcome: 'success', parent_record_id: null, prev_hash: null, record_phase: 'pre_execution',
  };
  const second = {
    record_id: randomUUID(), timestamp: '2026-09-19T05:00:00.100Z',
    agent_id: first.agent_id, agent_version: first.agent_version, session_id: session,
    action_type: 'tool_call', action_detail: { tool_name: 'create_invoice', parameters_hash: 'demo' },
    outcome: 'success', parent_record_id: first.record_id, prev_hash: digest(first), record_phase: 'post_execution',
  };
  const clean = validate([first, second]);
  first.outcome = 'failure';
  const tampered = validate([first, second]);
  const decisionCase = (outcome, record_phase) => validate([{
    ...first, record_id: randomUUID(), session_id: randomUUID(),
    outcome, record_phase, parent_record_id: null, prev_hash: null,
  }]);
  const phase_cases = {
    denied_pre_execution: decisionCase('denied', 'pre_execution'),
    denied_post_execution: decisionCase('denied', 'post_execution'),
    escalated_pre_execution: decisionCase('escalated', 'pre_execution'),
    escalated_concurrent: decisionCase('escalated', 'concurrent'),
  };
  console.log(JSON.stringify({ clean_errors: clean, tampered_errors: tampered, phase_cases }, null, 2));
  const phasesPass = phase_cases.denied_pre_execution.length === 0 &&
    phase_cases.denied_post_execution.includes('0: denied decision must be pre_execution') &&
    phase_cases.escalated_pre_execution.length === 0 &&
    phase_cases.escalated_concurrent.includes('0: escalated decision must be pre_execution');
  process.exitCode = clean.length === 0 && tampered.includes('1: broken hash chain') && phasesPass ? 0 : 1;
}

The inline block is byte-identical to the artifact I executed.

Limits and when not to use it

This validator does not prove that create_invoice ran, that the invoice is correct, or that a human approved it. Add a linked tool_response or error record and verify the final system state separately. The agent evaluation framework covers that split.

The compact canonicalizer works for the JSON values in this example. For cross-system signatures, use a reviewed RFC 8785 implementation. A hash chain is only tamper-evident when the agent cannot rewrite the full store and recompute every hash. Put the recorder in a gateway, sidecar, or append-only system outside the agent’s write authority.

Do not use this artifact as a compliance certification. Retention, access controls, deletion rights, signatures, external time anchors, and regulatory mappings depend on the deployment.

Browse the Security & Governance topic for related controls. The Permission-Aware RAG article explains how to filter unauthorized context before retrieval. Track the runtime burden with cost per verified outcome.

Download and next step

Download the validator into your project and replace the fixture with records from one external write. Add a gateway test that refuses the write when the pre-execution authorization record is missing. Then add the post-execution tool outcome and final-state check this small validator deliberately leaves out.