# Approval lifecycle lab

An original, dependency-free Node.js experiment from AgentApprovals. Copy the single JavaScript block into `approval-lifecycle.test.mjs`, then run `node --test approval-lifecycle.test.mjs`. Tested with Node.js v24.15.0 on 2026-10-09. No API key, network request, email delivery or package installation is used.

The executor below is a mock counter. This is an in-memory teaching model, not a production authorization service. The clock, reviewer identity, resource version, policy, and executor are trusted dependencies supplied by the test harness. A real service must obtain those from authenticated, authoritative systems, persist transitions atomically, and handle crash recovery and downstream idempotency. Do not expose these methods directly as unauthenticated HTTP endpoints.

The fixed schema intentionally supports only one synthetic message operation with string fields. It rejects additional fields rather than silently dropping a new attachment or destination. Expiry is checked at the decision and at execution admission; it cannot recall an operation already in flight. Changed arguments permanently invalidate a pending or approved proposal. Errors after an attempt produce `unknown`, with no automatic retry or new proposal justified by that state.

```js
import test from 'node:test';
import assert from 'node:assert/strict';
import {createHash} from 'node:crypto';

const fields = ['tenant', 'actor', 'tool', 'from', 'to', 'body', 'resourceVersion', 'policyVersion'];
function snapshot(action) {
  if (!action || Object.getPrototypeOf(action) !== Object.prototype ||
      Reflect.ownKeys(action).length !== fields.length ||
      fields.some(k => {
        const d = Object.getOwnPropertyDescriptor(action, k);
        return !d || !('value' in d) || typeof d.value !== 'string' || d.value.length === 0;
      })) {
    throw new Error('invalid-action');
  }
  return Object.freeze(Object.fromEntries(fields.map(k => [k, action[k]])));
}
const fingerprint = action => createHash('sha256').update(JSON.stringify(snapshot(action))).digest('hex');

function approvalLab({clock, policy, mayReview, executeMock}) {
  const records = new Map();
  function get(id) {
    if (!records.has(id)) throw new Error('unknown-proposal');
    return records.get(id);
  }
  function event(r, type) { r.events.push({type, at: clock()}); }
  function invalidate(r, reason) {
    r.state = reason;
    event(r, reason);
    throw new Error(reason);
  }
  function current(r) {
    if (clock() >= r.expiresAt) invalidate(r, 'expired');
    if (!policy(r.action)) invalidate(r, 'policy-denied');
    if (r.reviewer && !mayReview(r.reviewer, r.action)) invalidate(r, 'reviewer-revoked');
  }
  return {
    propose(id, action, ttlMs) {
      if (typeof id !== 'string' || !id || records.has(id)) throw new Error('invalid-or-duplicate-id');
      if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) throw new Error('invalid-ttl');
      const stored = snapshot(action);
      if (!policy(stored)) throw new Error('policy-denied');
      const r = {action: stored, hash: fingerprint(stored), expiresAt: clock() + ttlMs,
        state: 'pending', reviewer: null, events: []};
      records.set(id, r);
      event(r, 'proposed');
    },
    decide(id, reviewer, approve) {
      const r = get(id);
      if (r.state !== 'pending') throw new Error(r.state);
      if (typeof approve !== 'boolean') throw new Error('invalid-decision');
      if (!mayReview(reviewer, r.action)) throw new Error('unauthorized-reviewer');
      current(r);
      r.reviewer = reviewer;
      r.state = approve ? 'approved' : 'denied';
      event(r, r.state);
    },
    async run(id, candidate) {
      const r = get(id);
      let same = false;
      try { same = fingerprint(candidate) === r.hash; } catch { /* malformed is a mismatch */ }
      if (!same) {
        if (['pending', 'approved'].includes(r.state)) invalidate(r, 'changed');
        throw new Error('changed');
      }
      // A completed repeat returns the saved receipt; it never executes again.
      if (r.state === 'succeeded') return r.receipt;
      if (r.state !== 'approved') throw new Error(r.state);
      current(r);
      // Claim synchronously, before the first await: one admitted call in this process.
      r.state = 'running';
      event(r, 'attempted');
      try {
        const receipt = await executeMock(r.action, id);
        if (typeof receipt !== 'string' || !receipt) throw new Error('invalid-receipt');
        r.receipt = receipt;
        r.state = 'succeeded';
        event(r, 'succeeded');
        return receipt;
      } catch {
        r.state = 'unknown';
        event(r, 'unknown');
        throw new Error('unknown-outcome');
      }
    },
    inspect(id) { return structuredClone(get(id)); }
  };
}

const action = () => ({tenant:'demo', actor:'support-agent', tool:'mock.send',
  from:'support@example.test', to:'reader@example.test', body:'Delivery is tomorrow.',
  resourceVersion:'case-v1', policyVersion:'policy-v1'});
function fixture(effect) {
  let now = 1000, allowed = true, reviewerAllowed = true, calls = 0;
  const lab = approvalLab({clock: () => now,
    policy: a => allowed && a.tenant === 'demo' && a.actor === 'support-agent' &&
      a.tool === 'mock.send' && a.resourceVersion === 'case-v1' && a.policyVersion === 'policy-v1',
    mayReview: who => reviewerAllowed && who === 'reviewer-1',
    executeMock: async (a, id) => { calls++; return effect ? effect(a, id) : 'mock-receipt-1'; }});
  lab.propose('p1', action(), 100);
  return {lab, approve: () => lab.decide('p1','reviewer-1',true), calls: () => calls,
    advance: ms => { now += ms; }, revokePolicy: () => { allowed = false; },
    revokeReviewer: () => { reviewerAllowed = false; }};
}

test('pending proposal cannot execute', async () => {
  const f=fixture(); await assert.rejects(f.lab.run('p1',action()),/pending/); assert.equal(f.calls(),0);
});
test('approved unchanged action executes and records distinct events', async () => {
  const f=fixture(); f.approve(); assert.equal(await f.lab.run('p1',action()),'mock-receipt-1');
  assert.deepEqual(f.lab.inspect('p1').events.map(e=>e.type),['proposed','approved','attempted','succeeded']);
  assert.equal(f.calls(),1);
});
test('expiry blocks a late decision', () => {
  const f=fixture(); f.advance(100); assert.throws(f.approve,/expired/); assert.equal(f.calls(),0);
});
test('expiry at the exact boundary blocks execution', async () => {
  const f=fixture(); f.approve(); f.advance(100);
  await assert.rejects(f.lab.run('p1',action()),/expired/); assert.equal(f.calls(),0);
});
test('one millisecond before expiry permits admission', async () => {
  const f=fixture(); f.approve(); f.advance(99); await f.lab.run('p1',action()); assert.equal(f.calls(),1);
});
for (const field of fields) test(`changed ${field} invalidates approval permanently`, async () => {
  const f=fixture(); f.approve();
  await assert.rejects(f.lab.run('p1',{...action(),[field]:'different'}),/changed/);
  await assert.rejects(f.lab.run('p1',action()),/changed/); assert.equal(f.calls(),0);
});
test('an unexpected argument is rejected rather than discarded', async () => {
  const extra={...action(),attachment:'secret.txt'};
  const hidden=Object.defineProperty(action(),'attachment',{value:'secret.txt'});
  const symbol={...action(),[Symbol('attachment')]:'secret.txt'};
  const accessor=Object.defineProperty(action(),'body',{get(){throw new Error('must not run');}});
  for (const candidate of [extra,hidden,symbol,accessor]) {
    const f=fixture(); f.approve();
    await assert.rejects(f.lab.run('p1',candidate),/changed/); assert.equal(f.calls(),0);
  }
});
test('property order alone does not change the action', async () => {
  const f=fixture(); f.approve();
  await f.lab.run('p1',Object.fromEntries(Object.entries(action()).reverse())); assert.equal(f.calls(),1);
});
test('unauthorized reviewer cannot approve', () => {
  const f=fixture(); assert.throws(()=>f.lab.decide('p1','stranger',true),/unauthorized-reviewer/);
  assert.equal(f.lab.inspect('p1').state,'pending'); assert.equal(f.calls(),0);
});
test('a denial cannot be changed by another decision or execution', async () => {
  const f=fixture(); f.lab.decide('p1','reviewer-1',false); assert.throws(f.approve,/denied/);
  await assert.rejects(f.lab.run('p1',action()),/denied/); assert.equal(f.calls(),0);
});
test('changed policy blocks a previously approved action', async () => {
  const f=fixture(); f.approve(); f.revokePolicy();
  await assert.rejects(f.lab.run('p1',action()),/policy-denied/); assert.equal(f.calls(),0);
});
test('revoked reviewer authority blocks execution', async () => {
  const f=fixture(); f.approve(); f.revokeReviewer();
  await assert.rejects(f.lab.run('p1',action()),/reviewer-revoked/); assert.equal(f.calls(),0);
});
test('a completed retry returns the receipt without another attempt', async () => {
  const f=fixture(); f.approve(); const receipt=await f.lab.run('p1',action()); f.advance(1000);
  assert.equal(await f.lab.run('p1',action()),receipt); assert.equal(f.calls(),1);
});
test('overlapping calls admit only one mock execution', async () => {
  let release; const barrier=new Promise(resolve=>{release=resolve;});
  const f=fixture(async()=>{await barrier; return 'mock-receipt-2';}); f.approve();
  const first=f.lab.run('p1',action());
  await assert.rejects(f.lab.run('p1',action()),/running/);
  release(); await first; assert.equal(f.calls(),1);
});
test('an effect followed by a timeout is unknown and cannot be retried', async () => {
  let effects=0; const f=fixture(async()=>{effects++; throw new Error('timeout after effect');}); f.approve();
  await assert.rejects(f.lab.run('p1',action()),/unknown-outcome/);
  await assert.rejects(f.lab.run('p1',action()),/unknown/);
  assert.equal(f.lab.inspect('p1').state,'unknown'); assert.equal(f.calls(),1); assert.equal(effects,1);
});
test('copies returned to callers cannot rewrite stored approval', async () => {
  const f=fixture(); const copy=f.lab.inspect('p1'); copy.state='approved'; copy.action.to='other@example.test';
  await assert.rejects(f.lab.run('p1',action()),/pending/); f.approve(); await f.lab.run('p1',action());
  assert.equal(f.lab.inspect('p1').action.to,'reader@example.test');
});
test('proposal identifiers cannot be reused', () => {
  const f=fixture(); assert.throws(()=>f.lab.propose('p1',action(),100),/duplicate-id/);
});
```

Expected outcome: all 24 tests pass. The results concern this exact mock and one JavaScript process. They do not prove durable deduplication, multi-worker safety, authenticated reviewer identity, real email delivery, or a vendor's approval implementation. A receipt here means only that the mock returned its configured string.

The `unknown` state intentionally has no recovery transition. Before allowing a new attempt in a real service, reconcile the original operation with the destination using a stable logical operation identifier. Changing a proposal ID is not a safe way to retry an uncertain effect.

Source article: https://agentapprovals.ai/guides/approval-expiry-and-changes/
