diff --git a/assets/pctr-graph-route.svg b/assets/pctr-graph-route.svg
new file mode 100644
index 0000000..daaeda9
--- /dev/null
+++ b/assets/pctr-graph-route.svg
@@ -0,0 +1,122 @@
+
diff --git a/packages/pctr/README.md b/packages/pctr/README.md
index e7e7fcd..73db53e 100644
--- a/packages/pctr/README.md
+++ b/packages/pctr/README.md
@@ -235,6 +235,67 @@ Checks run most-restrictive first, so a revoked credential is never answered wit
reroute. `reconcile()` refuses to answer a change with a weaker response than it
warranted — that is how authority expands by accident.
+## The graph as a picture
+
+```bash
+pctr graph --svg # the whole map
+pctr graph --svg route.svg payments.transfer # with the selected route drawn
+```
+
+
+
+Columns run principal → agents → tools → actions. Agents carry their framework, current
+trust and a clock mark when their evidence is stale; actions are coloured by severity and
+marked `!` when they are a protected consequence; naming an action draws its selected
+route in green and dims everything else. The SVG is standalone — no fonts, no scripts, no
+network — so it drops straight into a README or a ticket.
+
+Every graph carries an `aria-label` describing the route in words, because a picture that
+only works for people who can see it is not documentation.
+
+## Routing learns from what actually happened
+
+`resolveRoute` accepts the history PCTR has accumulated, and prefers routes that work:
+
+```js
+import { summarizeHistory, resolveRoute } from '@blocksifr/pctr';
+const history = summarizeHistory(receipts);
+resolveRoute(graph, 'payments.transfer', { history });
+```
+
+A route with a record of failing loses to an equally trustworthy one that doesn't — but
+**history only ever reorders routes that already passed every admissibility check.** A
+flawless record buys an agent no authority it lacks; there's a test asserting exactly
+that. Optimization happens after admissibility, never instead of it.
+
+## Testing a policy against real history: `pctr whatif`
+
+Once `learn` starts proposing policy changes, the next question is what that change would
+have done to executions that already happened.
+
+```bash
+pctr whatif --policy '{"approvalThresholds":{"amount":25000}}'
+```
+
+```
+WHAT IF THIS POLICY HAD BEEN IN FORCE
+
+Executions replayed 10
+Decided the same 5
+Would tighten 3
+Would loosen 1
+
+1 execution(s) that were refused would now proceed
+
+ customers.update {"recordsAffected":4000} → would now proceed
+ CONSTRAIN: 4,000 records is above the batch limit of 25; bound it and the consequence is recoverable
+```
+
+Nothing executes; each recorded receipt is re-decided under the proposed policy. It exits
+non-zero when a change would **loosen** anything, so it works as a CI gate on policy
+edits. Note that raising an approval threshold cannot unlock a CRITICAL consequence — the
+severity comes from what the action can cause, not from the rule that reads it.
+
## Execution authority
A valid identity is not enough. A valid credential is not enough. A valid route is not
@@ -309,6 +370,8 @@ pctr explain Why was this allowed, denied, or rerouted?
pctr receipt [id] Show an execution receipt
pctr decide How should a trust change be answered right now?
pctr learn What the accumulated evidence says to change
+pctr whatif --policy Replay real history against a policy change
+pctr graph --svg [file] Draw the execution authority graph
pctr verify [id] Verify receipt signatures and the receipt chain
pctr keys Show your signing key id and public key
pctr serve Run the effect boundary as its own process
diff --git a/packages/pctr/bin/pctr.mjs b/packages/pctr/bin/pctr.mjs
index a506fac..06f32c6 100755
--- a/packages/pctr/bin/pctr.mjs
+++ b/packages/pctr/bin/pctr.mjs
@@ -15,6 +15,8 @@ import * as store from '../src/store.mjs';
import { renderReport, badgeUrl } from '../src/report.mjs';
import { learn, applyProposal } from '../src/learn.mjs';
import { respondToChange } from '../src/decisions.mjs';
+import { summarizeHistory, whatIf } from '../src/history.mjs';
+import { renderGraphSvg } from '../src/graph_svg.mjs';
import * as r from '../src/render.mjs';
const VERSION = '0.1.0';
@@ -23,7 +25,8 @@ const argv = process.argv.slice(2);
const command = argv[0];
// Flags that take a value, so their value is never mistaken for a positional argument.
const VALUE_FLAGS = new Set(['amount', 'records', 'recordsAffected', 'batch', 'params', 'approve',
- 'target', 'agent', 'objective', 'compare', 'fork', 'key', 'export', 'run', 'probe', 'boundary', 'port', 'share']);
+ 'target', 'agent', 'objective', 'compare', 'fork', 'key', 'export', 'run', 'probe', 'boundary', 'port', 'share',
+ 'svg', 'policy', 'limit']);
const positional = (() => {
const out = [];
for (let i = 1; i < argv.length; i++) {
@@ -67,6 +70,9 @@ function verifyOptions() {
};
}
+// What already happened, for the parts that decide what happens next.
+const loadHistory = () => summarizeHistory(store.listReceipts());
+
function loadGraph() {
const manifest = store.readManifest();
if (!manifest) {
@@ -122,6 +128,16 @@ async function main() {
case 'graph': {
const graph = loadGraph();
+ if (flag('svg')) {
+ const file = flag('svg') !== true ? String(flag('svg')) : 'pctr-graph.svg';
+ const svg = renderGraphSvg(graph, { action: positional[0] ?? null });
+ fs.writeFileSync(file, svg);
+ return out([r.heading('graph written'), r.field('File', file),
+ r.field('Size', `${(svg.length / 1024).toFixed(1)} KB`),
+ positional[0] ? r.field('Route shown', positional[0]) : '',
+ '', r.dim('Open it in a browser, or drop it straight into a README.')].filter(Boolean).join('\n'),
+ { file, bytes: svg.length });
+ }
return out(r.renderGraph(graph), {
principal: graph.principal,
nodes: [...graph.nodes.values()],
@@ -144,7 +160,7 @@ async function main() {
if (!action) return fail('Pass an action: pctr route ');
// Material parameters can change the consequence, and the consequence sets the bar.
const severity = previewConsequence(graph, action, params()).severity;
- const result = resolveRoute(graph, action, { target: flag('target'), severity });
+ const result = resolveRoute(graph, action, { target: flag('target'), severity, history: loadHistory() });
return out(r.renderRoute(result), result);
}
@@ -306,6 +322,33 @@ async function main() {
return decision.proceeds ? 0 : 1;
}
+ case 'whatif': {
+ const graph = loadGraph();
+ const raw = flag('policy');
+ if (!raw || raw === true) return fail(`Pass a policy change: pctr whatif --policy '{"approvalThresholds":{"amount":25000}}'`);
+ let policy;
+ try { policy = JSON.parse(String(raw)); } catch { return fail('--policy must be valid JSON'); }
+
+ const receipts = store.listReceipts();
+ if (!receipts.length) return fail('No history yet. Run pctr protect a few times first.');
+ const result = whatIf(graph, receipts, { policy, limit: Number(flag('limit')) || 100 });
+
+ if (asJson) return out(null, result);
+ const lines = [r.heading('what if this policy had been in force'),
+ r.field('Executions replayed', String(result.considered)),
+ r.field('Decided the same', String(result.unchanged)),
+ r.field('Would tighten', result.tightens ? r.yellow(String(result.tightens)) : '0'),
+ r.field('Would loosen', result.loosens ? r.red(String(result.loosens)) : '0'),
+ '', result.loosens ? r.red(result.verdict) : r.dim(result.verdict), ''];
+ for (const change of result.changes.slice(0, 12)) {
+ const arrow = change.direction === 'LOOSENS' ? r.red('→ would now proceed') : r.yellow('→ would now be stopped');
+ lines.push(` ${change.action} ${JSON.stringify(change.params)} ${arrow}`);
+ lines.push(` ${r.dim(`${change.response}: ${change.reason}`)}`);
+ }
+ out(lines.join('\n'), result);
+ return result.loosens ? 1 : 0;
+ }
+
case 'doctor': {
const manifest = store.readManifest();
const checks = [];
@@ -413,6 +456,7 @@ Usage
pctr serve Run the effect boundary as its own process
pctr decide How should a trust change be answered right now?
pctr learn What the accumulated evidence says to change
+ pctr whatif --policy Replay real history against a policy change
pctr doctor Check your setup
Options
@@ -427,6 +471,8 @@ Options
--boundary protect: verify authority at a remote effect boundary
--port serve: port for the effect boundary (default 8787)
--apply learn: write the proposed changes into pctr.json
+ --svg [file] graph: write the graph as SVG (add an action to show its route)
+ --policy whatif: the policy change to test against history
--probe preview: measure the real consequence with this probe
--key verify: check signatures against this public key
--export keys: write the public key to a file
diff --git a/packages/pctr/src/graph_svg.mjs b/packages/pctr/src/graph_svg.mjs
new file mode 100644
index 0000000..b071606
--- /dev/null
+++ b/packages/pctr/src/graph_svg.mjs
@@ -0,0 +1,174 @@
+import { outgoing, pathsToAction, protectedActions } from './graph.mjs';
+import { agentTrustNow } from './trust.mjs';
+import { resolveRoute } from './router.mjs';
+
+// THE GRAPH AS A PICTURE.
+//
+// This is the visual model of execution authority, not a decorative dashboard. It has to
+// answer, at a glance: who participates, who delegated to whom, what tools they reach,
+// what consequences those tools can cause, which route was selected, whose evidence is
+// stale, and where authority is required. Anything that does not answer one of those
+// questions does not belong in the drawing.
+
+const THEME = {
+ bg: '#0A0A0F', panel: '#12121A', border: '#1F1F2B', text: '#C9D1D9', dim: '#6E7681',
+ principal: '#00D4FF', agent: '#00B8A9', tool: '#8892A0', route: '#00E676',
+ CRITICAL: '#FF5470', HIGH: '#FFB300', MEDIUM: '#00D4FF', LOW: '#6E7681'
+};
+
+const COL = { principal: 40, agent: 250, tool: 500, action: 730 };
+const NODE_W = { principal: 170, agent: 200, tool: 180, action: 240 };
+const ROW_H = 64;
+const TOP = 96;
+
+const esc = (s) => String(s).replace(/&/g, '&').replace(//g, '>');
+const truncate = (s, n) => (String(s).length > n ? `${String(s).slice(0, n - 1)}…` : String(s));
+
+export function renderGraphSvg(graph, { action = null, at = new Date().toISOString() } = {}) {
+ const agents = [...graph.nodes.values()].filter((n) => n.type === 'agent');
+ const tools = [...graph.nodes.values()].filter((n) => n.type === 'tool');
+ const actions = [...graph.nodes.values()].filter((n) => n.type === 'action');
+
+ // If an action is named, draw its selected route in the foreground and dim the rest.
+ const highlight = action ? highlightFor(graph, action) : null;
+
+ const place = (items, column) => {
+ const map = new Map();
+ items.forEach((node, i) => map.set(node.id, { x: COL[column], y: TOP + i * ROW_H, w: NODE_W[column], node }));
+ return map;
+ };
+ const positions = new Map([
+ ...place([{ id: graph.principal, type: 'principal' }], 'principal'),
+ ...place(agents, 'agent'), ...place(tools, 'tool'), ...place(actions, 'action')
+ ]);
+
+ const rows = Math.max(1, agents.length, tools.length, actions.length);
+ const width = COL.action + NODE_W.action + 40;
+ const height = TOP + rows * ROW_H + 70;
+
+ const edges = [];
+ for (const edge of graph.edges) {
+ const from = positions.get(edge.from);
+ const to = positions.get(edge.to);
+ if (!from || !to || edge.kind === 'causes') continue;
+ const onRoute = highlight?.edges.has(`${edge.from}->${edge.to}`);
+ edges.push(edgePath(from, to, edge.kind, onRoute, Boolean(highlight)));
+ }
+
+ const nodes = [];
+ nodes.push(nodeBox(positions.get(graph.principal), 'principal', graph.principal, null, highlight));
+ for (const agent of agents) {
+ const trust = agentTrustNow(agent, { severity: 'HIGH', at });
+ nodes.push(nodeBox(positions.get(agent.id), 'agent', agent.id,
+ `${agent.framework ?? 'agent'} · trust ${trust.trust}${trust.evidenceStale ? ' · STALE' : ''}`,
+ highlight, { stale: trust.evidenceStale }));
+ }
+ for (const tool of tools) {
+ nodes.push(nodeBox(positions.get(tool.id), 'tool', tool.id, tool.protocol ?? 'local', highlight));
+ }
+ for (const act of actions) {
+ nodes.push(nodeBox(positions.get(act.id), 'action', act.id, act.label,
+ highlight, { severity: act.severity, protected: act.protected }));
+ }
+
+ const title = action
+ ? `Execution authority for ${action}`
+ : `Execution authority — ${agents.length} agents, ${tools.length} tools, ${protectedActions(graph).length} protected consequences`;
+
+ return `
+`;
+}
+
+function highlightFor(graph, action) {
+ const route = resolveRoute(graph, action);
+ const path = route.selected?.path ?? pathsToAction(graph, action)[0] ?? [];
+ const edges = new Set();
+ for (let i = 0; i < path.length - 1; i++) edges.add(`${path[i]}->${path[i + 1]}`);
+ return { nodes: new Set(path), edges, admissible: Boolean(route.selected), action };
+}
+
+function edgePath(from, to, kind, onRoute, dimOthers) {
+ const x1 = from.x + from.w;
+ const y1 = from.y + 18;
+ const x2 = to.x;
+ const y2 = to.y + 18;
+ const mid = x1 + (x2 - x1) / 2;
+ const stroke = onRoute ? THEME.route : THEME.border;
+ const opacity = onRoute ? 1 : dimOthers ? 0.25 : 0.7;
+ const dash = kind === 'uses' ? ' stroke-dasharray="4 3"' : '';
+ return ` `;
+}
+
+function nodeBox(pos, type, id, subtitle, highlight, opts = {}) {
+ if (!pos) return '';
+ const onRoute = highlight?.nodes.has(id);
+ const dimmed = highlight && !onRoute;
+ const accent = type === 'action'
+ ? (THEME[opts.severity] ?? THEME.dim)
+ : THEME[type] ?? THEME.dim;
+ const stroke = onRoute ? THEME.route : accent;
+ const opacity = dimmed ? 0.35 : 1;
+ const label = truncate(id.replace(/^spiffe:\/\//, ''), Math.floor(pos.w / 7.5));
+
+ // A protected consequence gets a marker that survives being printed in black and white.
+ const marker = opts.protected ? ` !` : '';
+ const staleMark = opts.stale
+ ? `
+ `
+ : '';
+
+ return `
+
+
+ ${esc(label)}
+${subtitle ? ` ${esc(truncate(subtitle, Math.floor(pos.w / 6)))}` : ''}
+${marker}${staleMark}
+ `;
+}
+
+function legend(width, height) {
+ const y = height - 30;
+ const items = [
+ [THEME.agent, 'agent'], [THEME.tool, 'tool'],
+ [THEME.CRITICAL, 'critical'], [THEME.HIGH, 'high'], [THEME.MEDIUM, 'medium'],
+ [THEME.route, 'selected route']
+ ];
+ let x = 28;
+ const parts = items.map(([color, label]) => {
+ const part = ` ${label}`;
+ x += 22 + label.length * 6;
+ return part;
+ });
+ parts.push(`
+
+ = stale evidence
+ ! = protected consequence`);
+ return parts.join('\n');
+}
+
+// The picture has to be readable by something that cannot see it.
+function describeForScreenReader(graph, action, highlight) {
+ const agents = [...graph.nodes.values()].filter((n) => n.type === 'agent').length;
+ const protectedCount = protectedActions(graph).length;
+ if (action && highlight) {
+ return highlight.admissible
+ ? `Execution authority graph for ${action}. The selected route runs ${[...highlight.nodes].join(' then ')}.`
+ : `Execution authority graph for ${action}. No admissible route reaches it.`;
+ }
+ return `Execution authority graph: ${agents} agents, ${protectedCount} protected consequences. Columns run principal, agents, tools, actions.`;
+}
+
+// Where an agent can reach, as text, for the cases a picture is the wrong medium.
+export function reachSummary(graph, agentId) {
+ const tools = outgoing(graph, agentId, 'uses');
+ return tools.flatMap((tool) => outgoing(graph, tool, 'performs').map((action) => ({ tool, action })));
+}
diff --git a/packages/pctr/src/history.mjs b/packages/pctr/src/history.mjs
new file mode 100644
index 0000000..702a6d7
--- /dev/null
+++ b/packages/pctr/src/history.mjs
@@ -0,0 +1,105 @@
+import { previewConsequence } from './twin.mjs';
+import { resolveRoute } from './router.mjs';
+import { respondToChange } from './decisions.mjs';
+
+// What actually happened, made usable by the parts that decide what happens next.
+//
+// Two rules govern everything here. History may influence which admissible route is
+// *preferred*; it may never make an inadmissible route admissible, and it may never
+// override a security constraint. Optimization happens after admissibility, not instead
+// of it.
+
+export function summarizeHistory(receipts = []) {
+ const byRoute = new Map();
+ const byAgent = new Map();
+ const byAction = new Map();
+
+ for (const receipt of receipts) {
+ const allowed = receipt.verifier?.decision === 'EXECUTION_ALLOWED';
+ const routeId = receipt.routeSelected?.routeId;
+ const action = receipt.requested?.action;
+
+ if (routeId) bump(byRoute, routeId, allowed);
+ if (action) bump(byAction, action, allowed);
+ for (const agent of receipt.routeSelected?.agents ?? []) bump(byAgent, agent, allowed);
+ }
+ return {
+ byRoute: Object.fromEntries(byRoute), byAgent: Object.fromEntries(byAgent),
+ byAction: Object.fromEntries(byAction), total: receipts.length
+ };
+}
+
+function bump(map, key, allowed) {
+ const entry = map.get(key) ?? { attempts: 0, failures: 0 };
+ entry.attempts++;
+ if (!allowed) entry.failures++;
+ map.set(key, entry);
+}
+
+// A route that keeps failing is a worse choice than one that doesn't, all else being
+// equal. This only ever reorders routes that already passed every admissibility check.
+export function failureRate(history, routeId) {
+ const entry = history?.byRoute?.[routeId];
+ if (!entry || entry.attempts < 2) return 0; // one failure is not a pattern
+ return entry.failures / entry.attempts;
+}
+
+export function rankByHistory(admissible, history) {
+ return [...admissible].sort((a, b) =>
+ b.effectiveTrust - a.effectiveTrust ||
+ failureRate(history, a.routeId) - failureRate(history, b.routeId) ||
+ a.latencyMs - b.latencyMs || a.costUnits - b.costUnits || a.hops - b.hops);
+}
+
+/**
+ * Counterfactual replay: what would a different policy have done to executions that
+ * already happened? Re-decides each recorded receipt under the proposed policy without
+ * executing anything.
+ */
+export function whatIf(graph, receipts, { policy = {}, limit = 100 } = {}) {
+ const proposed = { ...(graph.manifest.policy ?? {}), ...policy };
+ const considered = receipts.slice(-limit);
+ const changes = [];
+ let unchanged = 0;
+
+ for (const receipt of considered) {
+ const action = receipt.requested?.action;
+ if (!action || !graph.nodes.has(action)) continue;
+
+ const params = receipt.requested?.params ?? {};
+ const was = receipt.verifier?.decision ?? 'EXECUTION_DENIED';
+ const preview = previewConsequence(graph, action, params);
+ const route = resolveRoute(graph, action, { policy: proposed, severity: preview.severity });
+ const agents = (route.selected?.agents ?? []).map((id) => graph.nodes.get(id)).filter(Boolean);
+ const decision = respondToChange({
+ action, preview, currentRoute: route.selected, agents, policy: proposed
+ });
+
+ // An execution that was allowed and still needs no extra step stays allowed.
+ const wouldBe = decision.proceeds && decision.response !== 'CONSTRAIN'
+ ? 'EXECUTION_ALLOWED'
+ : decision.response === 'CONSTRAIN' ? 'EXECUTION_ALLOWED_WITH_BOUND' : 'EXECUTION_DENIED';
+
+ if (normalize(wouldBe) === normalize(was)) { unchanged++; continue; }
+ changes.push({
+ receiptId: receipt.receiptId, action, params,
+ was, wouldBe, response: decision.response, reason: decision.reason,
+ direction: normalize(was) === 'EXECUTION_ALLOWED' ? 'TIGHTENS' : 'LOOSENS'
+ });
+ }
+
+ const tightens = changes.filter((c) => c.direction === 'TIGHTENS');
+ const loosens = changes.filter((c) => c.direction === 'LOOSENS');
+ return {
+ considered: considered.length, unchanged,
+ changes, tightens: tightens.length, loosens: loosens.length,
+ // Loosening policy against real history is the number that deserves a second look.
+ verdict: loosens.length
+ ? `${loosens.length} execution(s) that were refused would now proceed`
+ : tightens.length
+ ? `${tightens.length} execution(s) that happened would now be stopped`
+ : 'no recorded execution would have been decided differently'
+ };
+}
+
+const normalize = (decision) => (decision === 'EXECUTION_ALLOWED_WITH_BOUND' ? 'EXECUTION_ALLOWED' : decision);
diff --git a/packages/pctr/src/index.mjs b/packages/pctr/src/index.mjs
index c0d3edd..837e9ad 100644
--- a/packages/pctr/src/index.mjs
+++ b/packages/pctr/src/index.mjs
@@ -15,6 +15,8 @@ export * from './boundary.mjs';
export * from './report.mjs';
export * from './decisions.mjs';
export * from './learn.mjs';
+export * from './history.mjs';
+export * from './graph_svg.mjs';
export * from './keys.mjs';
export * from './protect.mjs';
export { EVENTS, isCanonicalEvent } from './events.mjs';
diff --git a/packages/pctr/src/router.mjs b/packages/pctr/src/router.mjs
index e758bde..c92e512 100644
--- a/packages/pctr/src/router.mjs
+++ b/packages/pctr/src/router.mjs
@@ -1,6 +1,7 @@
import { pathsToAction } from './graph.mjs';
import { agentTrustNow, meetsThreshold, TRUST_REQUIRED, requiresHumanApproval } from './trust.mjs';
import { verify_trust_route } from './ttp.mjs';
+import { rankByHistory } from './history.mjs';
// Trust Routing is not shortest-path routing. Admissibility first; optimization only
// among routes that are already authorized, trustworthy and consequence-compatible.
@@ -81,9 +82,9 @@ export function resolveRoute(graph, actionId, options = {}) {
};
});
- // 5. optimize only what survived
- const admissible = candidates.filter((c) => c.admissible).sort((a, b) =>
- b.effectiveTrust - a.effectiveTrust || a.latencyMs - b.latencyMs || a.costUnits - b.costUnits || a.hops - b.hops);
+ // 5. optimize only what survived. History reorders routes that already passed every
+ // admissibility check; it can never make an inadmissible route admissible.
+ const admissible = rankByHistory(candidates.filter((c) => c.admissible), options.history);
const selected = admissible[0] ?? null;
return {
diff --git a/packages/pctr/tests/history.test.mjs b/packages/pctr/tests/history.test.mjs
new file mode 100644
index 0000000..067fc34
--- /dev/null
+++ b/packages/pctr/tests/history.test.mjs
@@ -0,0 +1,140 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { summarizeHistory, failureRate, rankByHistory, whatIf } from '../src/history.mjs';
+import { buildGraph } from '../src/graph.mjs';
+import { resolveRoute } from '../src/router.mjs';
+import { renderGraphSvg } from '../src/graph_svg.mjs';
+
+const manifest = () => ({
+ principal: 'user:test',
+ agents: [
+ { id: 'fast', trust: 0.97, evidenceAgeSeconds: 5, authority: ['payments.*'], latencyMs: 10, tools: ['payments'] },
+ { id: 'slow', trust: 0.97, evidenceAgeSeconds: 5, authority: ['payments.*'], latencyMs: 400, tools: ['payments'] }
+ ],
+ tools: [{ id: 'payments', protocol: 'mcp', actions: ['payments.transfer'] }],
+ actions: [{ id: 'payments.transfer', amount: 100 }],
+ policy: { requireApprovalAtOrAbove: 'CRITICAL', batchLimit: 25 }
+});
+
+const receipt = (over = {}) => ({
+ receiptId: `r-${Math.random().toString(36).slice(2, 8)}`,
+ requested: { action: 'payments.transfer', target: 't', params: { amount: 100 } },
+ routeSelected: { routeId: 'user:test -> fast -> payments -> payments.transfer', agents: ['fast'] },
+ verifier: { decision: 'EXECUTION_ALLOWED', failures: [] },
+ consequence: { class: 'MONEY_MOVED', severity: 'HIGH' },
+ issuedAt: '2026-09-15T00:00:00Z', ...over
+});
+
+test('history counts attempts and failures per route, agent and action', () => {
+ const history = summarizeHistory([
+ receipt(),
+ receipt({ verifier: { decision: 'EXECUTION_DENIED', failures: [{ code: 'APPROVAL_REQUIRED' }] } })
+ ]);
+ const route = history.byRoute['user:test -> fast -> payments -> payments.transfer'];
+ assert.equal(route.attempts, 2);
+ assert.equal(route.failures, 1);
+ assert.equal(history.byAgent.fast.attempts, 2);
+ assert.equal(history.total, 2);
+});
+
+test('a single failure is not treated as a pattern', () => {
+ const history = summarizeHistory([receipt({ verifier: { decision: 'EXECUTION_DENIED', failures: [] } })]);
+ assert.equal(failureRate(history, 'user:test -> fast -> payments -> payments.transfer'), 0);
+});
+
+test('history prefers the route that works, among routes already admissible', () => {
+ const graph = buildGraph(manifest());
+ const fastRoute = 'user:test -> fast -> payments -> payments.transfer';
+
+ // With no history the faster route wins on latency.
+ assert.equal(resolveRoute(graph, 'payments.transfer').selected.agents[0], 'fast');
+
+ // Given a record of the fast route failing, the slower one is preferred.
+ const history = summarizeHistory(Array.from({ length: 4 }, () =>
+ receipt({ verifier: { decision: 'EXECUTION_DENIED', failures: [{ code: 'BOUNDARY_UNREACHABLE' }] } })));
+ assert.ok(failureRate(history, fastRoute) > 0);
+ assert.equal(resolveRoute(graph, 'payments.transfer', { history }).selected.agents[0], 'slow');
+});
+
+test('history can never make an inadmissible route admissible', () => {
+ const m = manifest();
+ m.agents.forEach((a) => { a.authority = ['reports.read']; }); // neither may transfer
+ const graph = buildGraph(m);
+ const history = summarizeHistory(Array.from({ length: 20 }, () => receipt())); // a perfect record
+ const result = resolveRoute(graph, 'payments.transfer', { history });
+ assert.equal(result.selected, null, 'a flawless history does not buy authority');
+ assert.equal(result.admissible.length, 0);
+});
+
+test('what-if replays real history against a proposed policy', () => {
+ // A low approval threshold refused these $600 transfers. Note $18,000 would not work
+ // as an example: past $5,000 the consequence itself is CRITICAL, and CRITICAL requires
+ // approval whatever the threshold says — the amount changes the severity, not just the
+ // rule that reads it.
+ const m = manifest();
+ m.policy.approvalThresholds = { amount: 50 };
+ const graph = buildGraph(m);
+ const receipts = Array.from({ length: 3 }, () => receipt({
+ requested: { action: 'payments.transfer', target: 't', params: { amount: 600 } },
+ verifier: { decision: 'EXECUTION_DENIED', failures: [{ code: 'APPROVAL_REQUIRED' }] }
+ }));
+
+ const loosened = whatIf(graph, receipts, { policy: { approvalThresholds: { amount: 25000 } } });
+ assert.equal(loosened.considered, 3);
+ assert.ok(loosened.loosens >= 1, 'executions that were refused would now proceed');
+ assert.match(loosened.verdict, /would now proceed/);
+ assert.equal(loosened.changes[0].direction, 'LOOSENS');
+});
+
+test('raising an approval threshold cannot unlock a CRITICAL consequence', () => {
+ // The severity comes from what the action can cause, so no threshold edit reaches it.
+ const graph = buildGraph(manifest());
+ const receipts = [receipt({
+ requested: { action: 'payments.transfer', target: 't', params: { amount: 18000 } },
+ verifier: { decision: 'EXECUTION_DENIED', failures: [{ code: 'APPROVAL_REQUIRED' }] }
+ })];
+ const result = whatIf(graph, receipts, { policy: { approvalThresholds: { amount: 1000000 } } });
+ assert.equal(result.loosens, 0, 'a $18,000 transfer stays CRITICAL and still needs a human');
+});
+
+test('what-if reports when a policy change would have stopped what happened', () => {
+ const graph = buildGraph(manifest());
+ const receipts = Array.from({ length: 2 }, () => receipt()); // allowed, $100
+ const tightened = whatIf(graph, receipts, { policy: { escalateAtOrAbove: 'LOW', escalateTo: 'security' } });
+ assert.ok(tightened.tightens >= 1);
+ assert.match(tightened.verdict, /would now be stopped/);
+});
+
+test('what-if executes nothing and changes nothing', () => {
+ const graph = buildGraph(manifest());
+ const before = JSON.stringify(graph.manifest);
+ whatIf(graph, [receipt()], { policy: { batchLimit: 1 } });
+ assert.equal(JSON.stringify(graph.manifest), before);
+});
+
+test('the graph renders as SVG that answers the questions it must', () => {
+ const graph = buildGraph(manifest());
+ const svg = renderGraphSvg(graph);
+ assert.match(svg, /^