Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions assets/pctr-graph-route.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
63 changes: 63 additions & 0 deletions packages/pctr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -309,6 +370,8 @@ pctr explain <event> Why was this allowed, denied, or rerouted?
pctr receipt [id] Show an execution receipt
pctr decide <action> How should a trust change be answered right now?
pctr learn What the accumulated evidence says to change
pctr whatif --policy <j> 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
Expand Down
50 changes: 48 additions & 2 deletions packages/pctr/bin/pctr.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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++) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()],
Expand All @@ -144,7 +160,7 @@ async function main() {
if (!action) return fail('Pass an action: pctr route <action>');
// 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);
}

Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -413,6 +456,7 @@ Usage
pctr serve Run the effect boundary as its own process
pctr decide <action> How should a trust change be answered right now?
pctr learn What the accumulated evidence says to change
pctr whatif --policy <j> Replay real history against a policy change
pctr doctor Check your setup

Options
Expand All @@ -427,6 +471,8 @@ Options
--boundary <url> protect: verify authority at a remote effect boundary
--port <n> 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 <json> whatif: the policy change to test against history
--probe <file|command> preview: measure the real consequence with this probe
--key <public-key.pem> verify: check signatures against this public key
--export <file> keys: write the public key to a file
Expand Down
Loading
Loading