From aed6ee6cfa15127beddf4c3f6a0d83909399ba45 Mon Sep 17 00:00:00 2001 From: blocksifrdev Date: Tue, 15 Sep 2026 11:50:22 -0400 Subject: [PATCH] Draw the graph, route on history, and test policy against history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three places the doctrine was still ahead of the code. 1. The graph was ASCII. It is supposed to be the visual model of execution authority, and it is what a developer sees in the first sixty seconds. pctr graph --svg draws principal -> agents -> tools -> actions: agents carry framework, live trust and a clock mark when evidence is stale, actions are coloured by severity and marked when they are a protected consequence, and naming an action draws its selected route in green with everything else dimmed. Standalone SVG — no fonts, no scripts, no network — so it drops into a README. Every graph carries an aria-label describing the route in words. I rendered it to PNG and looked at it: the first two passes had tofu boxes where the brand fonts have no arrow or clock glyph, so those are drawn as paths now, and the legend no longer overlaps itself. 2. Routing ignored what happened. resolveRoute now takes accumulated history and prefers routes that work. The constraint that matters: history only reorders routes that already passed every admissibility check. A flawless record buys no authority — there is a test that gives an unauthorized agent twenty clean receipts and asserts it still cannot route. 3. No way to test a policy against reality. pctr whatif re-decides every recorded receipt under a proposed policy, executing nothing, and exits non-zero when a change would loosen anything, so it gates policy edits in CI. Writing its test surfaced something worth stating: raising an approval threshold cannot unlock a CRITICAL consequence, because severity comes from what the action can cause, not from the rule that reads it. That is now its own test. 167 tests (11 new). Co-Authored-By: Claude Opus 5 --- assets/pctr-graph-route.svg | 122 +++++++++++++++++++ packages/pctr/README.md | 63 ++++++++++ packages/pctr/bin/pctr.mjs | 50 +++++++- packages/pctr/src/graph_svg.mjs | 174 +++++++++++++++++++++++++++ packages/pctr/src/history.mjs | 105 ++++++++++++++++ packages/pctr/src/index.mjs | 2 + packages/pctr/src/router.mjs | 7 +- packages/pctr/tests/history.test.mjs | 140 +++++++++++++++++++++ 8 files changed, 658 insertions(+), 5 deletions(-) create mode 100644 assets/pctr-graph-route.svg create mode 100644 packages/pctr/src/graph_svg.mjs create mode 100644 packages/pctr/src/history.mjs create mode 100644 packages/pctr/tests/history.test.mjs 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[^"]*"/); +});