@agentguard-run/burn 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +25 -6
  3. package/dist/src/cli.d.ts +1 -0
  4. package/dist/src/cli.js +15 -1
  5. package/dist/src/gateway.js +5 -3
  6. package/dist/src/hook/pre-tool-use.js +2 -1
  7. package/dist/src/ledger-panel.d.ts +40 -0
  8. package/dist/src/ledger-panel.js +174 -0
  9. package/dist/src/live-panel.d.ts +12 -0
  10. package/dist/src/live-panel.js +27 -107
  11. package/dist/src/record-ledger.d.ts +30 -0
  12. package/dist/src/record-ledger.js +165 -0
  13. package/dist/src/recording.js +4 -1
  14. package/dist/src/state/spawn-window.d.ts +13 -1
  15. package/dist/src/state/spawn-window.js +18 -3
  16. package/docs/LIVE_PANEL_2026_09.md +52 -28
  17. package/docs/assets/burn-real-stop-sep20-stop-1920.png +0 -0
  18. package/docs/assets/burn-real-stop-sep20-stop-390.png +0 -0
  19. package/docs/assets/burn-real-stop-sep20.evidence.json +67 -0
  20. package/docs/assets/burn-real-stop-sep20.gif +0 -0
  21. package/docs/assets/burn-real-stop-sep20.jsonl +39 -0
  22. package/docs/assets/burn-real-stop-sep20.mp4 +0 -0
  23. package/docs/assets/{burn-live-sep19.mp4.provenance.json → burn-real-stop-sep20.mp4.provenance.json} +45 -81
  24. package/docs/assets/burn-real-stop-sep20.policy.json +47 -0
  25. package/docs/assets/burn-real-stop-sep20.receipts.ndjson +18 -0
  26. package/docs/burn-render.md +12 -2
  27. package/fixtures/live-sep20-stop-104x35.txt +35 -0
  28. package/package.json +1 -1
  29. package/docs/assets/burn-live-sep19-stop-1920.png +0 -0
  30. package/docs/assets/burn-live-sep19-stop-390.png +0 -0
  31. package/docs/assets/burn-live-sep19.evidence.json +0 -288
  32. package/docs/assets/burn-live-sep19.gif +0 -0
  33. package/docs/assets/burn-live-sep19.jsonl +0 -45
  34. package/docs/assets/burn-live-sep19.mp4 +0 -0
  35. package/docs/assets/burn-live-sep19.observations.jsonl +0 -45
  36. package/docs/assets/burn-live-sep19.receipt.json +0 -38
@@ -0,0 +1,165 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ledgerTimestamp = ledgerTimestamp;
4
+ exports.validateCompanionRecording = validateCompanionRecording;
5
+ exports.recordLedgerArguments = recordLedgerArguments;
6
+ exports.reconstructLedgerFrames = reconstructLedgerFrames;
7
+ exports.recordLedger = recordLedger;
8
+ /** Reconstruct local instrument-panel observations from immutable ledger history. */
9
+ const node_fs_1 = require("node:fs");
10
+ const node_path_1 = require("node:path");
11
+ const ledger_panel_1 = require("./ledger-panel");
12
+ const live_panel_1 = require("./live-panel");
13
+ const USAGE = 'Usage: agentguard-burn record --session <id> [--from <iso>] [--to <iso>] --out <run.jsonl>';
14
+ const MAX_FRAMES = 10000;
15
+ /** Require an explicit timezone and reject dates Date.parse would normalize. */
16
+ function ledgerTimestamp(value) {
17
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?(Z|[+-]\d{2}:\d{2})$/.exec(value);
18
+ if (!match)
19
+ throw new Error('Record timestamps must be ISO dates with a timezone and at most millisecond precision.');
20
+ const year = Number(match[1]), month = Number(match[2]), day = Number(match[3]);
21
+ const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
22
+ const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
23
+ const offset = match[8];
24
+ const at = Date.parse(value);
25
+ if (month < 1 || month > 12 || day < 1 || day > days[month - 1] || Number(match[4]) > 23
26
+ || Number(match[5]) > 59 || Number(match[6]) > 59 || !Number.isFinite(at) || at < 0
27
+ || offset !== 'Z' && (Number(offset.slice(1, 3)) > 23 || Number(offset.slice(4)) > 59)) {
28
+ throw new Error('Record timestamps must contain a valid date, time and timezone.');
29
+ }
30
+ return at;
31
+ }
32
+ /** Compare aliases before writing, including a new file below a symlinked directory. */
33
+ function samePath(left, right) {
34
+ try {
35
+ const a = (0, node_fs_1.statSync)(left), b = (0, node_fs_1.statSync)(right);
36
+ if (a.dev === b.dev && a.ino === b.ino)
37
+ return true;
38
+ }
39
+ catch { /* A fresh output has no inode yet. */ }
40
+ const physical = (file) => {
41
+ let path = (0, node_path_1.resolve)(file);
42
+ const suffix = [];
43
+ for (;;) {
44
+ try {
45
+ return (0, node_path_1.resolve)((0, node_fs_1.realpathSync)(path), ...suffix);
46
+ }
47
+ catch {
48
+ const parent = (0, node_path_1.dirname)(path);
49
+ if (parent === path)
50
+ return (0, node_path_1.resolve)(file);
51
+ suffix.unshift((0, node_path_1.basename)(path));
52
+ path = parent;
53
+ }
54
+ }
55
+ };
56
+ return physical(left) === physical(right);
57
+ }
58
+ function validateCompanionRecording(home, output, recording) {
59
+ if (samePath(output, recording))
60
+ throw new Error('Use different paths for --out and --record.');
61
+ if (['decisions.ndjson', 'receipts.ndjson'].some(name => samePath(recording, (0, node_path_1.join)(home, name)))) {
62
+ throw new Error('Recording must not append to a source ledger.');
63
+ }
64
+ }
65
+ function recordLedgerArguments(args) {
66
+ const fields = {};
67
+ for (let i = 0; i < args.length; i++) {
68
+ const flag = args[i];
69
+ if (!['--session', '--from', '--to', '--out'].includes(flag))
70
+ throw new Error(USAGE);
71
+ const key = flag.slice(2), value = args[++i];
72
+ if (!value || value.startsWith('--') || fields[key] !== undefined)
73
+ throw new Error(USAGE);
74
+ fields[key] = value;
75
+ }
76
+ if (!fields.session || !fields.out)
77
+ throw new Error(USAGE);
78
+ return fields;
79
+ }
80
+ function reconstructLedgerFrames(rows, options) {
81
+ if (!options.session || /[\x00-\x20\x7f]/.test(options.session))
82
+ throw new Error('Record requires a session id or an unambiguous prefix.');
83
+ const selected = (0, ledger_panel_1.resolveLedgerSession)(rows, options.session);
84
+ if (!selected)
85
+ throw new Error(`No ledger decisions match session ${options.session}.`);
86
+ const history = rows.filter(row => row.sessionDigest === selected.sessionDigest);
87
+ if (!history.length)
88
+ throw new Error(`No ledger decisions match session ${options.session}.`);
89
+ const first = history[0], last = history.at(-1);
90
+ const from = options.from === undefined ? first.at : ledgerTimestamp(options.from);
91
+ const to = options.to === undefined ? last.at : ledgerTimestamp(options.to);
92
+ if (from > to)
93
+ throw new Error('Record --from must be at or before --to.');
94
+ if ((to - from) / 1000 + 1 > MAX_FRAMES)
95
+ throw new Error('Recording exceeds the 10,000 frame render limit; select a shorter time range.');
96
+ const within = history.filter(row => row.at >= from && row.at <= to);
97
+ if (!within.length)
98
+ throw new Error('No ledger decisions fall inside the requested time range.');
99
+ const session = selected.sessionId?.slice(0, 8) ?? selected.sessionDigest.slice(0, 8);
100
+ const project = (index, at) => {
101
+ const snapshot = (0, ledger_panel_1.projectLedgerPanel)(history.slice(0, index + 1), selected.sessionDigest, at);
102
+ return { ...snapshot, session, sessionDigest: selected.sessionDigest, startedAt: first.at, observedAt: at, elapsedMs: Math.max(0, at - first.at), controls: false };
103
+ };
104
+ // Seed the hold with prior history without treating an old STOP as a new event.
105
+ const hold = new live_panel_1.StopHold();
106
+ let priorIndex = -1;
107
+ while (priorIndex + 1 < history.length && history[priorIndex + 1].at < from - 2000)
108
+ priorIndex++;
109
+ if (priorIndex >= 0)
110
+ hold.update(project(priorIndex, from - 2000), from - 2000);
111
+ while (priorIndex + 1 < history.length && history[priorIndex + 1].at < from) {
112
+ priorIndex++;
113
+ hold.update(project(priorIndex, history[priorIndex].at), history[priorIndex].at);
114
+ }
115
+ const events = [];
116
+ const decisionTimes = new Set();
117
+ history.forEach((row, index) => {
118
+ if (row.at >= from && row.at <= to) {
119
+ events.push({ at: row.at, index });
120
+ decisionTimes.add(row.at);
121
+ }
122
+ });
123
+ for (let at = from; at <= to; at += 1000)
124
+ if (!decisionTimes.has(at))
125
+ events.push({ at });
126
+ if ((to - from) % 1000 !== 0 && !decisionTimes.has(to))
127
+ events.push({ at: to });
128
+ events.sort((a, b) => a.at - b.at || (a.index ?? -1) - (b.index ?? -1));
129
+ if (events.length > MAX_FRAMES)
130
+ throw new Error('Recording exceeds the 10,000 frame render limit; select a shorter time range.');
131
+ const frames = events.map(event => {
132
+ if (event.index !== undefined)
133
+ priorIndex = event.index;
134
+ else
135
+ while (priorIndex + 1 < history.length && history[priorIndex + 1].at <= event.at)
136
+ priorIndex++;
137
+ const snapshot = hold.update(project(priorIndex, event.at), event.at);
138
+ return { version: 1, at: new Date(event.at).toISOString(), data: { kind: 'live', snapshot }, viewport: { width: 104, height: 35 } };
139
+ });
140
+ const lastStop = history.filter(row => row.at <= to && row.verdict === 'STOP').at(-1);
141
+ return { frames, session, from, to, decisionCount: within.length, clippedStopHold: Boolean(lastStop && lastStop.at + 2000 > to) };
142
+ }
143
+ function recordLedger(home, options) {
144
+ if (!options.out || /[\x00-\x1f\x7f]/.test(options.out))
145
+ throw new Error('Recording output must be a local path without control characters.');
146
+ const output = (0, node_path_1.resolve)(options.out);
147
+ if (['decisions.ndjson', 'receipts.ndjson'].some(name => output === (0, node_path_1.resolve)((0, node_path_1.join)(home, name)))) {
148
+ throw new Error('Recording output must not replace a source ledger.');
149
+ }
150
+ const result = reconstructLedgerFrames((0, ledger_panel_1.readPanelLedger)(home), options);
151
+ const bytes = result.frames.map(frame => JSON.stringify(frame)).join('\n') + '\n';
152
+ if (Buffer.byteLength(bytes) > 128 * 1024 * 1024)
153
+ throw new Error('Recording exceeds the 128 MB render limit; select a shorter time range.');
154
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(output), { recursive: true, mode: 0o700 });
155
+ try {
156
+ (0, node_fs_1.writeFileSync)(output, bytes, { flag: 'wx', mode: 0o600 });
157
+ }
158
+ catch (error) {
159
+ if (error.code === 'EEXIST')
160
+ throw new Error(`Refusing to overwrite existing output: ${output}`);
161
+ throw error;
162
+ }
163
+ return { output, session: result.session, from: new Date(result.from).toISOString(), to: new Date(result.to).toISOString(),
164
+ frameCount: result.frames.length, decisionCount: result.decisionCount, clippedStopHold: result.clippedStopHold };
165
+ }
@@ -59,7 +59,10 @@ function readRecording(file) {
59
59
  return typeof d.id === 'string' && timestamp(d.at) && typeof d.tool === 'string' && typeof d.reason === 'string'
60
60
  && ['OK', 'WARN', 'STOP'].includes(String(d.verdict)) && (typeof d.blocked === 'boolean' || d.blocked === null);
61
61
  };
62
- if (!snapshot || typeof snapshot.session !== 'string' || typeof snapshot.note !== 'string'
62
+ const sessionSwitch = snapshot?.sessionSwitch;
63
+ if (!snapshot || typeof snapshot.session !== 'string'
64
+ || !(snapshot.sessionDigest === undefined || typeof snapshot.sessionDigest === 'string' && /^[a-f0-9]{64}$/.test(snapshot.sessionDigest))
65
+ || !(sessionSwitch === undefined || sessionSwitch && typeof sessionSwitch.from === 'string' && typeof sessionSwitch.to === 'string' && timestamp(sessionSwitch.at)) || typeof snapshot.note !== 'string'
63
66
  || !timestamp(snapshot.observedAt) || !timestamp(snapshot.startedAt)
64
67
  || !(snapshot.windowActiveMinutes === null || count(snapshot.windowActiveMinutes) && snapshot.windowActiveMinutes > 0)
65
68
  || !(snapshot.spawnCeiling === null || count(snapshot.spawnCeiling) && snapshot.spawnCeiling > 0)
@@ -1,10 +1,22 @@
1
+ import type { HostCapabilities } from '../events';
1
2
  import type { BurnReport, SessionState, Thresholds } from '../types';
2
3
  import type { ReserveResult, Transaction } from './reservations';
4
+ /** The exact decision-time measurements retained for local live and replay views. */
5
+ export interface DecisionWindow {
6
+ activeMinutes: number;
7
+ windowActiveMinutes: number;
8
+ spawns: number | null;
9
+ tokens: number | null;
10
+ spawnCeiling: number;
11
+ replayShare: number | null;
12
+ }
13
+ export declare function decisionWindow(state: SessionState, thresholds: Thresholds, proposedSpawn?: boolean, coverage?: Pick<HostCapabilities, 'spawns' | 'usage'>): DecisionWindow;
3
14
  /** Evaluate and reserve together. Pending forks must not bypass either window. */
4
15
  export declare function evaluateSpawnReservation(tx: Transaction, state: SessionState, thresholds: Thresholds, proposedDepth: number, toolUseId: string, now: number, account?: {
5
16
  sessions: SessionState[];
6
17
  now: number;
7
- }): {
18
+ }, coverage?: Pick<HostCapabilities, 'spawns' | 'usage'>): {
8
19
  report: BurnReport;
9
20
  reservation: ReserveResult;
21
+ window: DecisionWindow;
10
22
  };
@@ -1,11 +1,25 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.decisionWindow = decisionWindow;
3
4
  exports.evaluateSpawnReservation = evaluateSpawnReservation;
4
5
  const evaluate_1 = require("../detectors/evaluate");
5
6
  const defaults_1 = require("../defaults");
6
7
  const session_1 = require("./session");
8
+ function decisionWindow(state, thresholds, proposedSpawn = false, coverage) {
9
+ const windowActiveMinutes = thresholds.spawnRate.windowActiveMinutes;
10
+ const spawnKnown = !coverage || coverage.spawns !== 'missing';
11
+ const usageKnown = !coverage || coverage.usage !== 'missing';
12
+ return {
13
+ activeMinutes: state.activeMinutes,
14
+ windowActiveMinutes,
15
+ spawns: spawnKnown ? (0, session_1.windowSum)(state.spawnsByActiveMinute, state.activeMinutes, windowActiveMinutes) + (proposedSpawn ? 1 : 0) : null,
16
+ tokens: usageKnown ? (0, session_1.windowSum)(state.tokensByActiveMinute, state.activeMinutes, windowActiveMinutes) : null,
17
+ spawnCeiling: thresholds.spawnRate.stop,
18
+ replayShare: usageKnown && state.totalTokens > 0 ? Math.min(1, state.totalCacheRead / state.totalTokens) : null,
19
+ };
20
+ }
7
21
  /** Evaluate and reserve together. Pending forks must not bypass either window. */
8
- function evaluateSpawnReservation(tx, state, thresholds, proposedDepth, toolUseId, now, account) {
22
+ function evaluateSpawnReservation(tx, state, thresholds, proposedDepth, toolUseId, now, account, coverage) {
9
23
  const pending = tx.pendingSpawns(state.sessionId, toolUseId, now);
10
24
  const minute = Math.floor(state.activeMinutes);
11
25
  const spawns = new Map(state.spawnsByActiveMinute);
@@ -13,13 +27,14 @@ function evaluateSpawnReservation(tx, state, thresholds, proposedDepth, toolUseI
13
27
  spawns.set(minute, (spawns.get(minute) ?? 0) + pending);
14
28
  const candidateState = { ...state, spawnCount: state.spawnCount + pending, spawnsByActiveMinute: spawns };
15
29
  const report = (0, evaluate_1.evaluate)(candidateState, thresholds, proposedDepth, account);
30
+ const window = decisionWindow(candidateState, thresholds, true, coverage);
16
31
  // Receipts retain the lifetime ordinal, while admission uses the active window.
17
32
  const effectiveSpawns = state.spawnCount + pending + 1;
18
33
  if (report.verdict === 'STOP') {
19
34
  // A refused proposal never consumes a reservation. Its detector explains why.
20
- return { report, reservation: { allowed: true, effectiveSpawns, pending } };
35
+ return { report, reservation: { allowed: true, effectiveSpawns, pending }, window };
21
36
  }
22
37
  const recent = (0, session_1.windowSum)(state.spawnsByActiveMinute, state.activeMinutes, thresholds.fanout.windowActiveMinutes ?? defaults_1.DEFAULT_THRESHOLDS.fanout.windowActiveMinutes);
23
38
  const reservation = tx.reserve({ sessionId: state.sessionId, toolUseId, observedSpawns: recent, ceiling: thresholds.fanout.stop, now });
24
- return { report, reservation: { ...reservation, effectiveSpawns } };
39
+ return { report, reservation: { ...reservation, effectiveSpawns }, window };
25
40
  }
@@ -1,55 +1,79 @@
1
1
  # Burn live instrument panel
2
2
 
3
- `agentguard-burn live` reads the local session cache and decision ledgers every half second. It does not change admission, write session state, or open a socket. Use `--session ID` to select a session, `--once` for one frame, and `--record run.jsonl` for a local structured recording. `q` quits and `p` pauses display updates. An observed STOP holds its reason for two seconds while intake continues, then releases the latest view.
3
+ `agentguard-burn live` reads the local decision and receipt ledgers every half second. It follows the session with the newest decision on each refresh, unless `--session ID` pins a full ID, unique prefix or digest. The header shows the raw ID prefix when available. It does not change admission, write session state, or open a socket.
4
4
 
5
5
  ```sh
6
- agentguard-burn live --session e86f091b --record local-run.jsonl
6
+ agentguard-burn live --record local-run.jsonl
7
+ agentguard-burn live --session 0b0c2202 --record pinned-run.jsonl
7
8
  agentguard-burn live --replay local-run.jsonl
8
9
  agentguard-burn render local-run.jsonl --mp4 local-run.mp4 --gif local-run.gif
9
10
  ```
10
11
 
11
- The panel uses the existing Canvas and palette. SPAWNS and TOKENS IN WINDOW are observed bucket sums over the policy's active-minute window, including inherited team thresholds. The rate bar becomes amber at 80 percent of the configured STOP ceiling and red at the ceiling. REPLAY SHARE is session cache-read tokens divided by all observed tokens. It is a cache-read proxy, not measured repeated semantic work. No numeric value is synthesized when the corresponding source is unavailable.
12
+ Use `--once` for one frame. `q` quits and `p` pauses display updates. An observed STOP holds its reason and matching counters for two seconds while intake continues. Switching sessions releases that hold immediately. Recordings carry a `sessionSwitch` object identifying the previous and next sessions and the switch time.
12
13
 
13
- The right column shows the last eight locally recorded decisions. Historical ledgers record an action such as `spawn`, rather than the host's precise tool name; the panel displays that action. At fewer than 80 columns the right column is omitted, leaving full-width counters and the STOP card. The tested geometries are 104x35, 80x24 and a 43-column compact view rendered natively at 390 pixels.
14
+ ## One source for both columns
14
15
 
15
- Colored STOP reports use the same panel without reading files or delaying a hook. Their missing token-window, window-length and elapsed values remain unknown. They do not claim a two-second interactive hold. Existing plain-text cards and hook protocol bytes use their previous paths.
16
+ The hook now records its decision window alongside each ledger decision: active minutes, window length, spawn count including pending reservations and the proposed spawn, tokens in that window, the STOP ceiling, and session cache-read share. Gateway observations respect host coverage; missing usage remains unknown. These are measurements already available during evaluation, not a second evaluation. Receipt payloads and enforcement behavior are unchanged.
16
17
 
17
- ## Verification
18
+ Both panel columns use the same session's ledger. Changing the current policy cannot rewrite a recorded window. Elapsed begins at that session's first ledger decision, including history before the displayed range. It does not use the transcript start time. The bar turns amber at 80 percent of the measured ceiling and red at the ceiling.
18
19
 
19
- The full Node22 suite passed **265/265**, zero failures or skips, with `AGENTGUARD_STRESS=1`. The fixture stress test admitted exactly 40 of 240 concurrent hooks, denied 200, and verified the receipt chain. Full output is retained in `/private/tmp/burn-rulepack-panel-tests-final.log`. The first sandboxed attempt could not bind its local conformance server; the full permitted rerun passed.
20
+ Historical ledgers lack exact token-window and cache-read measurements, so those values remain unknown. A spawn-rate finding supplies its observed spawn count. A complete witnessed sequence of proposals can establish earlier counts. Historical configuration is usable only when its canonical hash matches a verified receipt's policy digest. This prevents today's settings from being applied to an unrelated historical policy.
20
21
 
21
- ## September 19 evidence
22
+ The right column shows the last eight decisions. When the ledger names the action `spawn` rather than the host tool name, the panel displays `spawn`. At fewer than 80 columns the right column is omitted. Both 104x35 and 80x24 are tested; the compact STOP image uses a native 43-column layout at 390 pixels.
22
23
 
23
- The site figure comes from `assets/stop-capture.txt` and `docs/redesign-2026-09/hero/REPORT.md`, which explicitly disclose prepared synthetic history and a real hook decision (receipt `4cb15245-a57e-463c-aed5-32b143c5d719`). The older `docs/STOP_CLIP_2026-09.md` describes another prepared capture. Those captures do not establish a naturally observed rate storm.
24
+ Colored STOP reports use the same panel without reading files or delaying hooks. Their unrecorded window metrics and elapsed remain unknown. Plain-text hook cards and protocol output retain their previous paths.
24
25
 
25
- The search covered all 13 receipts and 148 decision rows in the current local Burn ledgers, not only the selected session. September 19 UTC contains 11 rows in each ledger and two STOPs; September 19 Pacific contains 12 rows and one STOP. Every STOP uses fanout plus duplicate-work; neither date scope contains a spawn-rate finding. The evidence file records source hashes and both date boundaries. This search does not claim coverage of deleted or alternate-home ledgers.
26
+ ## Reconstruct an event
26
27
 
27
- The actual signed receipt is `d2f3ceec-816c-41ad-9e1d-23401eeb44f3`, session digest `3fa090ccbdfeaaa5de7b660af6dfb8dddfac5112e7a95188a0be494a90740263`. Ed25519 verification passed. Its timestamp is `2026-09-19T14:56:57.824Z`. The gate was historical **lifetime fanout: 48 proposed spawns against 40**, with a duplicate-work WARN. It was not the current 16-spawns-per-15-active-minutes gate.
28
+ ```sh
29
+ agentguard-burn record --session 0b0c2202 --from 2026-09-20T20:20:32Z --to 2026-09-20T20:20:52Z --out run.jsonl
30
+ agentguard-burn render run.jsonl --mp4 run.mp4 --gif run.gif
31
+ ```
32
+
33
+ The output must be a new file. Optional bounds default to the session's first and last decisions. Reconstruction writes one frame per decision, including equal timestamps, plus one-second clock frames and explicit range boundaries. Prior rows supply context; future rows never supply earlier measurements. Clock ticks add no decisions or counts. The requested end clips any remaining STOP hold. The video renderer adds its standard one-second final frame; GIF timing is limited to centiseconds.
28
34
 
29
- The current deduplicating reader, restricted to the receipt timestamp, finds 47 observed spawns, 1,628,919,976 total tokens and 1,599,279,923 cache-read tokens. Its final 15-active-minute window has **one spawn and 8,086,525 tokens**. These are a fresh reconstruction from the parent and its 45 child transcripts. They are not the historical inflated usage totals retained in the signed receipt. Live state and policy were not rewritten.
35
+ ## September 20 evidence
30
36
 
31
- The bounded clip starts at the real usage observation `14:56:18.247Z`. The next observed spawn is at `14:56:57.749Z`, followed 75 milliseconds later by the signed block. The counters show the actual zero-to-one spawn change. The reference rate bar stays below 16. The requested storm and rate-ceiling crossing cannot be substantiated from this event.
37
+ Session `0b0c2202-d0be-4867-97a6-7e050a9b21e0` hashes to `58143098bae620e9ae380915798f2ba88d9342f2de8d3908277461ff7cb0c60d`. The old display's `58143098` therefore referred to this same session. The clip did expose a real defect: persisted transcript state showed zero spawns while pending reservations in the hook ledger advanced from 1 to 16. The previous elapsed clock also began at the older transcript start.
32
38
 
33
- Original observation times are preserved. One-second display-clock ticks add no observations. The STOP card remains for two seconds, followed by the renderer's one-second final frame. The source observation interval lasts 39.577 seconds; the MP4 lasts 42.577001 seconds. GIF stores centisecond timing and lasts 42.58 seconds. Earlier session activity and later observations are outside the clip; prior decision rows provide context. Every frame says `RECORDED RUN · 1x`.
39
+ The first ledger decision is `2026-09-20T20:20:32.556Z`. The first enforced STOP is `2026-09-20T20:20:49.753Z`, receipt `e7e74842-5779-46e8-b9c2-3bf714e020f6`. Its `spawn_rate` finding records 16 against a threshold of 16. Elapsed at this decision is 17.197 seconds. Two subsequent STOPs at `20:20:50.936Z` and `20:20:51.935Z` remain at 16.
34
40
 
35
- Assets live in `docs/assets/`:
41
+ All 18 receipts in the interval verify, including their chain links. The normalized local policy's hash matches every receipt's policy digest: `7fc75c3841bbe6ee6401bfb57db26e1ce117de1d1dd53095cbb1f7fbc9c5cf6e`. It establishes the 15-active-minute window, WARN at 8 and STOP at 16. No policy or session file was changed. Token-window and replay-share displays remain unknown because those measurements were not recorded in this ledger.
36
42
 
37
- - `burn-live-sep19.mp4` and `burn-live-sep19.gif`: 1920x1080.
38
- - `burn-live-sep19-stop-1920.png`: the actual STOP frame.
39
- - `burn-live-sep19-stop-390.png`: native compact Canvas rendering at 390x540.
40
- - `burn-live-sep19.jsonl`: content-free frames written through the actual `live --replay --record` command.
41
- - `burn-live-sep19.observations.jsonl`: the reconstructed input; CLI output preserves its frame data, timestamps and viewport exactly.
42
- - `burn-live-sep19.receipt.json`: original signed receipt.
43
- - `burn-live-sep19.evidence.json`: input hashes, receipt verification, timing, omissions, and media hashes.
44
- - `burn-live-sep19.mp4.provenance.json`: renderer, font, frame times and encoding command.
43
+ The current showcase replaces the September 19 clip. Original decision timestamps, one-second display ticks and the recorded-run label are preserved. The STOP frame shows 16 spawns, the full red bar and its signed detector reason.
45
44
 
46
- Re-render the shipped recording without reading any private session data:
45
+ Assets under `docs/assets/`:
46
+
47
+ - `burn-real-stop-sep20.jsonl`: the ledger-derived recording.
48
+ - `burn-real-stop-sep20.mp4` and `burn-real-stop-sep20.gif`: 1920x1080 media.
49
+ - `burn-real-stop-sep20-stop-1920.png` and `burn-real-stop-sep20-stop-390.png`: the first STOP frame.
50
+ - `burn-real-stop-sep20.receipts.ndjson`: the 18 original signed, content-free receipts.
51
+ - `burn-real-stop-sep20.policy.json`: the normalized policy matching their signed digest.
52
+ - `burn-real-stop-sep20.evidence.json`: source hashes, timing, verification and media hashes.
53
+ - `burn-real-stop-sep20.mp4.provenance.json`: renderer version, source digest, frame times and encoding details.
54
+
55
+ Re-render the committed recording without private session data:
47
56
 
48
57
  ```sh
49
- agentguard-burn live --replay docs/assets/burn-live-sep19.jsonl --once --no-color --record /tmp/burn-review.jsonl
50
- agentguard-burn render /tmp/burn-review.jsonl --mp4 /tmp/burn-review.mp4 --gif /tmp/burn-review.gif
58
+ agentguard-burn render docs/assets/burn-real-stop-sep20.jsonl --mp4 /tmp/burn-review.mp4 --gif /tmp/burn-review.gif
51
59
  ```
52
60
 
53
- The non-TTY replay above checks the recording path; its stdout is not a timed terminal capture. The rendered media uses the original frame timestamps. The showcase itself was run through this CLI path, with frame equality checked before encoding.
61
+ ## Verification for 0.3.2
62
+
63
+ Node 22 full suite, including the optional concurrent-hook stress case:
64
+
65
+ ```text
66
+ # STRESS: 240 hook processes; cap=40; admitted=40; denied=200; signed receipts=240; lock failures denied=0; chain valid
67
+ # tests 287
68
+ # pass 287
69
+ # fail 0
70
+ # skipped 0
71
+ ```
72
+
73
+ The initial sandbox run could not bind the local conformance server. The permitted full run passed. Regression coverage includes interleaved sessions, explicit pinning, identity-switch recordings, pending reservations, token-window parity, unknown coverage, historical policy hash matching, recording round trips and the real STOP frame snapshot. Copy checks found no prohibited punctuation or wording in changed text files.
74
+
75
+ The MP4 is 1920x1080 and 21.000001 seconds; the GIF is 1920x1080 and 21.00 seconds. The requested ledger interval is 20 seconds, followed by the renderer's one-second final frame. Both first-STOP PNGs were visually inspected. The live policy and the source session file remained byte-identical during reconstruction. Version 0.3.2 is prepared locally, with no publication.
76
+
77
+ MP4 SHA256: `9b31a4ece0dc99e235f3f90b6e07e5ce59d573d094d46ef3b7d9bb56fccd3bfd`.
54
78
 
55
- Reconstruct with `npm run build` then `node scripts/record-september19.cjs /tmp/burn-september19-review`. The script requires the matching verified local receipt and transcripts and refuses to overwrite outputs. It exports numbers, opaque identifiers, action labels and detector reasons. It does not export tool inputs, outputs, prompts, source paths or file contents.
79
+ GIF SHA256: `d9ca444b696ba84ec33f9e5a143c2a7014c6e7e7f465f45cd54b28d2ba3ea6c4`.
@@ -0,0 +1,67 @@
1
+ {
2
+ "session_id": "0b0c2202-d0be-4867-97a6-7e050a9b21e0",
3
+ "session_digest": "58143098bae620e9ae380915798f2ba88d9342f2de8d3908277461ff7cb0c60d",
4
+ "old_display_was_same_session": true,
5
+ "first_decision": "2026-09-20T20:20:32.556Z",
6
+ "first_stop": "2026-09-20T20:20:49.753Z",
7
+ "first_stop_elapsed_ms": 17197,
8
+ "range": {
9
+ "from": "2026-09-20T20:20:32Z",
10
+ "to": "2026-09-20T20:20:52Z"
11
+ },
12
+ "source_sha256": {
13
+ "decisions": "20c23ef927848cf3ca1c738e0f80753ce0290ee9d77f6a9e12d1e6fd6afd8f89",
14
+ "receipts": "02cbb874d10f8e6d35fd1a96a2c7ac70b91242967accd12eff29b12e7dd74a6a"
15
+ },
16
+ "receipt_signatures_verified": 18,
17
+ "receipt_chain_links_verified": 18,
18
+ "policy_sha256": "7fc75c3841bbe6ee6401bfb57db26e1ce117de1d1dd53095cbb1f7fbc9c5cf6e",
19
+ "policy_matches_all_receipts": true,
20
+ "gate": {
21
+ "detector": "spawn_rate",
22
+ "observed": 16,
23
+ "threshold": 16,
24
+ "window_active_minutes": 15
25
+ },
26
+ "decision_counts": {
27
+ "OK": 7,
28
+ "WARN": 8,
29
+ "STOP": 3
30
+ },
31
+ "frame_count": 39,
32
+ "decision_times": [
33
+ "2026-09-20T20:20:32.556Z",
34
+ "2026-09-20T20:20:33.676Z",
35
+ "2026-09-20T20:20:34.808Z",
36
+ "2026-09-20T20:20:36.001Z",
37
+ "2026-09-20T20:20:37.136Z",
38
+ "2026-09-20T20:20:38.251Z",
39
+ "2026-09-20T20:20:39.443Z",
40
+ "2026-09-20T20:20:40.575Z",
41
+ "2026-09-20T20:20:41.787Z",
42
+ "2026-09-20T20:20:42.873Z",
43
+ "2026-09-20T20:20:44.005Z",
44
+ "2026-09-20T20:20:45.188Z",
45
+ "2026-09-20T20:20:46.314Z",
46
+ "2026-09-20T20:20:47.428Z",
47
+ "2026-09-20T20:20:48.629Z",
48
+ "2026-09-20T20:20:49.753Z",
49
+ "2026-09-20T20:20:50.936Z",
50
+ "2026-09-20T20:20:51.935Z"
51
+ ],
52
+ "missing_measurements": [
53
+ "tokens in window",
54
+ "session cache-read share"
55
+ ],
56
+ "timing": "Original decision timestamps plus one-second clock ticks and explicit range boundaries. STOP holds are display only; the end bound clips them. The renderer adds one second for the final frame. GIF uses centisecond precision.",
57
+ "output_sha256": {
58
+ "burn-real-stop-sep20.jsonl": "514152bfe025b12262f288c8fa7c3dc2cbafacd33f5b858f9bad26d981f47038",
59
+ "burn-real-stop-sep20.mp4": "9b31a4ece0dc99e235f3f90b6e07e5ce59d573d094d46ef3b7d9bb56fccd3bfd",
60
+ "burn-real-stop-sep20.gif": "d9ca444b696ba84ec33f9e5a143c2a7014c6e7e7f465f45cd54b28d2ba3ea6c4",
61
+ "burn-real-stop-sep20-stop-1920.png": "7063d9b26d97231db20663a114baf546689165bf782778e3b2f8202262750b4a",
62
+ "burn-real-stop-sep20-stop-390.png": "1b7eeeb357d9e1e310dc4f5bfb1d6b7babc6232381e4edddc30359aa745555b5",
63
+ "burn-real-stop-sep20.receipts.ndjson": "bd34a5cc07404d4a3c9023089b133ea01a4d55ee4c7a2558b4b04da966637fda",
64
+ "burn-real-stop-sep20.policy.json": "9a130d1bf4d8555ba2db0f4f41e24b881774461aaa20a3b6aed1c074e03bbca9",
65
+ "burn-real-stop-sep20.mp4.provenance.json": "9ef8f9d60b9802cc1f37eb85c87763e44b7577b8cdad3204fff8a8a08bd93f8d"
66
+ }
67
+ }