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 @@ + + + + Execution authority for payments.transfer + principal -> agents -> tools -> actions · the selected route is drawn in green + + + + + + + + + + + + + + + + + user:local + + + + + + + planner + openai-agents · trust 0.9785 + + + + + + finance-agent-a + langgraph · trust 0.7079 · STALE + + + + + + + finance-agent-d + claude-agents · trust 0.9678 + + + + + + support-agent + crewai · trust 0.8163 + + + + + + admin-agent + claude-agents · trust 0.9479 + + + + + + payments + mcp + + + + + + crm + mcp + + + + + + database + mcp + + + + + + payments.transfer + money moved + ! + + + + + customers.read + data read + + + + + + customers.delete + data deleted + ! + + + + + customers.update + data written + ! + + agent + tool + critical + high + medium + selected route + + + = stale evidence + ! = protected consequence + + 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 +``` + +![the execution authority graph](../../assets/pctr-graph-route.svg) + +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 ` + + + ${esc(title)} + principal -> agents -> tools -> actions${highlight ? ' · the selected route is drawn in green' : ''} +${edges.join('\n')} +${nodes.join('\n')} +${legend(width, height)} + + +`; +} + +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, /^\n$/); + assert.match(svg, /role="img"/); + assert.match(svg, /aria-label="[^"]+"/, 'a picture must be readable by something that cannot see it'); + assert.ok(svg.includes('fast') && svg.includes('payments.transfer'), 'agents and actions are drawn'); + assert.ok(!/[→⏱]/.test(svg), 'no glyphs that render as tofu in the brand fonts'); +}); + +test('naming an action draws its selected route and says so to a screen reader', () => { + const graph = buildGraph(manifest()); + const svg = renderGraphSvg(graph, { action: 'payments.transfer' }); + assert.match(svg, /Execution authority for payments.transfer/); + assert.match(svg, /aria-label="[^"]*selected route runs[^"]*"/); + assert.ok(svg.includes('#00E676'), 'the selected route is drawn in the route colour'); +}); + +test('an unreachable action says so rather than drawing a route that does not exist', () => { + const m = manifest(); + m.agents.forEach((a) => { a.authority = []; }); + const svg = renderGraphSvg(buildGraph(m), { action: 'payments.transfer' }); + assert.match(svg, /aria-label="[^"]*No admissible route[^"]*"/); +});