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
75 changes: 75 additions & 0 deletions packages/pctr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,80 @@ non-zero when a change would **loosen** anything, so it works as a CI gate on po
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.

## Measured trust, not declared trust: `pctr attest`

Everything downstream of a trust score is rigorous about it — thresholds, decay, route
admissibility, execution authority. None of that means much while the score itself is a
number somebody typed into `pctr.json`.

```bash
pctr attest # measure every agent from evidence
pctr attest planner # one agent, in detail
```

```
MEASURED TRUST: PLANNER

Measured trust 0.8251 (Good)
Declared in manifest 0.98
Drift -0.1549 — overstated
Evidence 8 receipt(s) from 1 issuer(s)
Oldest evidence 937s

Issuers

pctr.effect-boundary score 0.825115 weight 1 (capped)

Clears MEDIUM bar (0.6) YES
Threshold proof sha256:8568aa97ed1e48b2b8feafd…
```

**The rule that governs all of it: absent evidence is not trust.** An agent with no
attestations does not inherit its declared score — it comes back `UNPROVEN`, counts as
zero, and a protected consequence will not route through it (`TRUST_UNPROVEN`). The
failure mode this exists to prevent is a typed-in `0.99` silently authorising a payment.

Evidence comes from two places:

- **PCTR's own execution receipts**, scored on the scale in
[`protocol/scoring-semantics.md`](../../protocol/scoring-semantics.md) §3.2: a clean
execution is 0.95, reaching for authority it lacks is 0.15, replaying an authority is
0.20, waiting on a human approval is 0.75 — that last one matters, because an agent
blocked on a human is not an agent misbehaving.
- **Attestors you configure**, a module or command per agent, returning TTP attestations
or behavioural receipts. Each attestation is verified with TTP's own
`verify_attestation`, so a stale one, or one about a different subject, contributes
nothing. A failing attestor yields *no* evidence — never favourable evidence.

```json
{ "attestors": { "planner": ["./attestors/workload-identity.mjs"], "*": [{ "command": "./attest.sh" }] } }
```

Aggregation is the normative algorithm in
[`protocol/aggregation-spec.md`](../../protocol/aggregation-spec.md) — time decay,
negative-signal amplification, per-issuer weight capping — and `pctr attest` emits a TTP
`TrustThresholdProof` naming the evidence it rests on.

### Known divergences in the aggregation vectors

The spec ships nine test vectors; all nine run in `tests/aggregation.test.mjs`. Three do
not match the algorithm the document itself defines, and are asserted as **known
divergences** rather than skipped:

| Vector | Expects | The written formula yields | Why |
| --- | --- | --- | --- |
| `agg-003` | 0.4 | **0.5** | Superseded by `agg-003-corrected` (identical receipts, expects 0.5). Its own `_explanation` field works the arithmetic, catches itself mid-sentence — *"wait let me recalculate"* — and concludes 0.5, while `expected` still says 0.4. |
| `agg-006` | 0.5 | **0.5799** | Expecting 0.5 requires *both* issuers capped at 0.40. B's uncapped fraction is 0.29, and step 5 says `min(fraction, max_issuer_weight)` — a cap, not a floor. |
| `agg-008` | 0.917 | **0.918** | Off by 0.0010, a hair outside the vectors' own ±0.001 tolerance; consistent with the expected value being computed from rounded intermediate weights. |

There is also a substantive point behind `agg-006`. Step 5 caps a dominant issuer at 0.40
and then **re-normalizes**, so when the other issuers carry little weight the capped
issuer still ends up with most of the vote — 50 perfect receipts from one issuer against
two bad ones from two others still yields ~0.90, with the "capped" issuer holding 87% of
the weight. The cap only bites when the rest of the field is comparable. That is pinned
by a test so it cannot be mistaken for an implementation bug, but the spec is what needs
the decision.

## Execution authority

A valid identity is not enough. A valid credential is not enough. A valid route is not
Expand Down Expand Up @@ -369,6 +443,7 @@ pctr replay [run] Agent Time Machine
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 attest [agent] Measure trust from evidence instead of the manifest
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
Expand Down
38 changes: 36 additions & 2 deletions packages/pctr/bin/pctr.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ 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 { attestGraph, attestAgent, proveThreshold } from '../src/attest.mjs';
import { renderGraphSvg } from '../src/graph_svg.mjs';
import * as r from '../src/render.mjs';

Expand All @@ -26,7 +27,7 @@ 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',
'svg', 'policy', 'limit']);
'svg', 'policy', 'limit', 'severity']);
const positional = (() => {
const out = [];
for (let i = 1; i < argv.length; i++) {
Expand Down Expand Up @@ -73,6 +74,15 @@ function verifyOptions() {
// What already happened, for the parts that decide what happens next.
const loadHistory = () => summarizeHistory(store.listReceipts());

// Measured trust, keyed by agent, for the router. Only agents with configured attestors
// or observed receipts produce a measurement; the rest fall back to the manifest.
async function loadMeasurements(graph, severity) {
const attestors = graph.manifest.attestors;
if (!attestors) return undefined;
const { measurements } = await attestGraph(graph, { receipts: store.listReceipts(), attestors, severity });
return Object.fromEntries(measurements.map((m) => [m.agentId, m]));
}

function loadGraph() {
const manifest = store.readManifest();
if (!manifest) {
Expand Down Expand Up @@ -160,7 +170,10 @@ 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, history: loadHistory() });
const result = resolveRoute(graph, action, {
target: flag('target'), severity, history: loadHistory(),
measurements: await loadMeasurements(graph, severity)
});
return out(r.renderRoute(result), result);
}

Expand Down Expand Up @@ -349,6 +362,25 @@ async function main() {
return result.loosens ? 1 : 0;
}

case 'attest': {
const graph = loadGraph();
const receipts = store.listReceipts();
const attestors = graph.manifest.attestors ?? {};
const severity = String(flag('severity') !== true && flag('severity') || 'MEDIUM').toUpperCase();

if (positional[0]) {
const agent = graph.nodes.get(positional[0]);
if (!agent || agent.type !== 'agent') return fail(`Unknown agent: ${positional[0]}`);
const measurement = await attestAgent(agent, { receipts, attestors: attestors[agent.id] ?? attestors['*'] ?? [], severity });
const proof = proveThreshold(measurement, severity);
return out(r.renderAttestation(measurement, proof, severity), { measurement, proof });
}

const result = await attestGraph(graph, { receipts, attestors, severity });
out(r.renderAttestGraph(result, severity), result);
return result.unproven.length ? 1 : 0;
}

case 'doctor': {
const manifest = store.readManifest();
const checks = [];
Expand Down Expand Up @@ -455,6 +487,7 @@ Usage
pctr keys Show your signing key id and public key
pctr serve Run the effect boundary as its own process
pctr decide <action> How should a trust change be answered right now?
pctr attest [agent] Measure trust from evidence instead of the manifest
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
Expand All @@ -473,6 +506,7 @@ Options
--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
--severity <level> attest: the consequence level to measure against
--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
102 changes: 102 additions & 0 deletions packages/pctr/src/aggregate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// TTP TRUST SCORE AGGREGATION — the normative algorithm.
//
// Implements protocol/aggregation-spec.md v1.0 step for step. That document is marked
// normative and carries test vectors; tests/aggregation.test.mjs runs every one of them,
// so this file is not free to drift from the spec.
//
// The two properties that matter, both deliberate:
// - Negative signals weigh more (default 1.5x). An agent behaving well most of the time
// must not be able to average away a few dangerous actions.
// - No single issuer may contribute more than a fraction of the score (default 0.40),
// so one chatty or captured issuer cannot decide an agent's trust alone.

export const DEFAULT_PARAMS = {
receipt_window_s: 300,
max_issuer_weight: 0.40,
negative_weight_multiplier: 1.5,
decay_half_life_s: 120
};

export const INSUFFICIENT_TRUST_DATA = 'INSUFFICIENT_TRUST_DATA';

/**
* @param receipts [{ receipt_id, issuer_id, score, timestamp }] — valid, deduplicated
* @param current_time_ms evaluation time
* @returns { score, contributing_receipts, contributing_issuers, oldest_receipt_age_s }
* or { error: 'INSUFFICIENT_TRUST_DATA' } when nothing is in the window.
*/
export function aggregateTrust(receipts = [], current_time_ms = Date.now(), params = {}) {
const { receipt_window_s, max_issuer_weight, negative_weight_multiplier, decay_half_life_s } =
{ ...DEFAULT_PARAMS, ...params };

// Step 1 — filter to the receipt window.
const windowReceipts = receipts.filter(
(r) => current_time_ms - r.timestamp <= receipt_window_s * 1000
);
if (!windowReceipts.length) {
return { error: INSUFFICIENT_TRUST_DATA, score: null, contributing_receipts: 0, contributing_issuers: 0 };
}

// Steps 2 and 3 — time decay, then negative signal amplification.
const weighted = windowReceipts.map((r) => {
const age_s = (current_time_ms - r.timestamp) / 1000;
const decay_weight = Math.exp((-Math.LN2 * age_s) / decay_half_life_s);
const negative = r.score < 0.5;
return {
...r, age_s,
adjusted_score: r.score,
signal_weight: negative ? decay_weight * negative_weight_multiplier : decay_weight
};
});

// Step 4 — per-issuer weighted score.
const byIssuer = new Map();
for (const r of weighted) {
const entry = byIssuer.get(r.issuer_id) ?? { weightedSum: 0, totalWeight: 0 };
entry.weightedSum += r.adjusted_score * r.signal_weight;
entry.totalWeight += r.signal_weight;
byIssuer.set(r.issuer_id, entry);
}
const issuers = [...byIssuer].map(([issuer_id, e]) => ({
issuer_id,
issuer_score: e.weightedSum / e.totalWeight,
issuer_raw_weight: e.totalWeight
}));

// Step 5 — cap each issuer's fraction, then re-normalize.
const totalRawWeight = issuers.reduce((sum, i) => sum + i.issuer_raw_weight, 0);
const capped = issuers.map((i) => ({
...i,
capped_fraction: Math.min(i.issuer_raw_weight / totalRawWeight, max_issuer_weight)
}));
const normalizationFactor = capped.reduce((sum, i) => sum + i.capped_fraction, 0);

// Step 6 — combine, and clamp for floating point.
const rawScore = capped.reduce(
(sum, i) => sum + i.issuer_score * (i.capped_fraction / normalizationFactor), 0
);

return {
score: Math.max(0, Math.min(1, rawScore)),
contributing_receipts: windowReceipts.length,
contributing_issuers: issuers.length,
oldest_receipt_age_s: Math.round(Math.max(...weighted.map((r) => r.age_s))),
issuers: capped.map((i) => ({
issuer_id: i.issuer_id,
issuer_score: Number(i.issuer_score.toFixed(6)),
weight: Number((i.capped_fraction / normalizationFactor).toFixed(6)),
capped: i.issuer_raw_weight / totalRawWeight > max_issuer_weight
}))
};
}

// The scoring scale from protocol/scoring-semantics.md, so a score can be read in words.
export function scoreLabel(score) {
if (score == null) return 'Unknown';
if (score >= 0.90) return 'Excellent';
if (score >= 0.70) return 'Good';
if (score >= 0.50) return 'Marginal';
if (score >= 0.30) return 'Poor';
if (score >= 0.10) return 'Bad';
return 'Critical';
}
Loading
Loading