Skip to content

Commit 8d55bcb

Browse files
committed
Refuse to overwrite the audit trail with whatever the API still remembers
analytics/runs returned 150 runs on 2026-09-07. Paginated identically with the same credential on 2026-09-09, it returned 2, both from that morning. The 150 executions of 09-06/07 are gone from the API. Nothing in the response says so: no retention field, no truncation flag, no total, and pagination terminates normally, so exhaustion and expiry are indistinguishable. audit.mjs regenerated in place, which is what its own file header told you to do. Running it today silently replaced the 150-row ledger with a 1-row one and exited 0. That happened here; the file came back because it was committed. A shrinking rewrite is now a refusal that names both counts and points at the explorer, and --prune exists for when the loss is genuinely intended. The evidence itself does not depend on the endpoint. Every row carries a transaction hash and those are on Sepolia permanently. What the endpoint owns is convenience, and it turns out not to own durability. A second-order effect worth stating: a failed run has no transactionHashes, so it contributes no row at all. The losing half of this morning's deliberate race, yhak2pfniragtz0x8we15 / Error(GS026), is invisible in the receipts even while still inside the retention window. The ledger that records what executed structurally cannot record what did not - which is the argument for docs/outcomes-*.jsonl in one sentence, arrived at from the opposite direction. Filed as DX-8. Also corrected: 80 -> 87 tests in the submission description, the third stale count of the day.
1 parent 75c48ab commit 8d55bcb

3 files changed

Lines changed: 70 additions & 5 deletions

File tree

‎DX-REPORT.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,3 +284,36 @@ demo. Asking the organiser in Discord before restructuring.
284284
`staging` before it may be filed**: `check-and-execute` cannot address a member of a
285285
tuple-returning read (observed in production 2026-08-08, 25+ days stale as of this
286286
writing).
287+
288+
---
289+
290+
## DX-8 · `analytics/runs` silently ages history out, and it is the audit trail
291+
292+
**Severity:** high — it makes an execution ledger unreproducible, with no signal that it happened.
293+
**Date:** 2026-09-09
294+
295+
`GET /api/analytics/runs` is the only machine-readable record of what KeeperHub executed, and this
296+
project treats it as exactly that: `scripts/audit.mjs` paginates it to `nextCursor` exhaustion and
297+
renders the result as the audit trail.
298+
299+
On 2026-09-07 that call returned **150 runs** for this org. On 2026-09-09, paginated identically
300+
with the same credential, it returned **2** — both from that morning. The 150 executions of
301+
2026-09-06/07 were gone. Nothing in the response distinguishes "you have 2 runs" from "you have 2
302+
runs left": there is no retention field, no truncation flag, no `total`, and the pagination
303+
terminates normally.
304+
305+
The consequence is worse than a missing feature. A regenerate-in-place — the documented workflow
306+
for this kind of artifact, and what our own file header instructed — **silently replaces a 150-row
307+
audit trail with a 2-row one and exits 0.** We hit exactly that and recovered only because the file
308+
was committed. `audit.mjs` now refuses to shrink the file without an explicit `--prune`.
309+
310+
A second-order effect worth naming: a run that fails has no `transactionHashes`, so it contributes
311+
no row at all. The losing half of a deliberate race (`yhak2pfniragtz0x8we15`, `Error(GS026)`) is
312+
invisible in the receipts even while it is still inside the retention window. The endpoint that
313+
records what executed structurally cannot record what did not.
314+
315+
**Suggested fix:** state the retention period in the docs and return it in the response; add a
316+
`total` or an explicit `truncated` flag so a client can tell exhaustion from expiry; and expose
317+
failed runs with enough identity to be counted even without a transaction hash. If retention is
318+
plan-dependent, say which plan buys what — this is the one endpoint whose whole value is that it
319+
remembers.

‎README.md‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,12 @@ The graph is generated, never hand-drawn: `sync.mjs` injects [`src/assemble.mjs`
214214
**verbatim** into the Code node, so the function the tests run offline and the function the canvas
215215
runs on-chain cannot drift apart. That is the whole reason the decision surface is pure.
216216

217-
Seven reproducible findings came out of building against it, dated as they were hit and filed
218-
upstream — see [`DX-REPORT.md`](DX-REPORT.md).
217+
Eight reproducible findings came out of building against it, dated as they were hit and filed
218+
upstream — see [`DX-REPORT.md`](DX-REPORT.md). The latest is the sharpest: **`analytics/runs` ages
219+
history out with no signal**, so the endpoint this project treats as the audit trail returned 150
220+
runs on 09-07 and 2 on 09-09. `audit.mjs` now refuses to shrink `docs/receipts-*.json` without
221+
`--prune`, because a regenerate-in-place would have destroyed a ledger the API can no longer
222+
reproduce. The transaction hashes on disk remain verifiable on the explorer regardless.
219223

220224
---
221225
## 🕳️ The gap that would have silently disarmed a security guard
@@ -462,7 +466,7 @@ refusing correctly is a success, not an error.
462466
| [`contracts/`](contracts/) | `MockUSDC.sol`, the testnet stand-in, and [`contracts/test/`](contracts/test/) — 22 tests at 100% coverage on every metric, dependency-free |
463467
| [`test/`](test/) | 87 tests: unit fixtures, the live-response regression file, manifest/roster invariants, and the coverage-gap suite ([`COVERAGE.md`](test/COVERAGE.md)) |
464468
| [`survey/`](survey/) | The 1,299-Safe measurement: collectors, 1.3 MB of raw responses, and `rederive.py`, which asserts all 24 published figures offline |
465-
| [`DX-REPORT.md`](DX-REPORT.md) | Seven reproducible KeeperHub findings, dated as they were hit |
469+
| [`DX-REPORT.md`](DX-REPORT.md) | Eight reproducible KeeperHub findings, dated as they were hit |
466470
| [`workflows/`](workflows/) | Generated `gavel-drain` graph, 11 nodes (1 trigger + 10 actions), 10 edges |
467471
| [`docs/rehearsal-11155111.md`](docs/rehearsal-11155111.md) | Rehearsal log. Labelled NOT EVIDENCE |
468472
| [`docs/outcomes-11155111.jsonl`](docs/outcomes-11155111.jsonl) | Every decision gavel made on that chain, refusals included. Append-only JSON Lines |

‎scripts/audit.mjs‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
*/
2323

2424
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
25+
import { basename } from 'node:path';
2526
import { homedir } from 'node:os';
2627
import { join, dirname } from 'node:path';
2728
import { fileURLToPath } from 'node:url';
@@ -118,8 +119,35 @@ function table(list) {
118119
}
119120

120121
mkdirSync(join(ROOT, 'docs'), { recursive: true });
121-
writeFileSync(join(ROOT, 'docs', `receipts-${chainId}.json`), JSON.stringify({
122-
$comment: 'Generated by scripts/audit.mjs from KeeperHub execution rows. Do not hand-edit — regenerate.',
122+
const receiptsPath = join(ROOT, 'docs', `receipts-${chainId}.json`);
123+
124+
// KeeperHub's analytics/runs does NOT keep history indefinitely. On 2026-09-09 it
125+
// returned 2 runs for this org; the 150 rows recorded on 2026-09-06/07 were gone.
126+
// Nothing in the API says so, and the naive regenerate-in-place this file used to
127+
// do would have silently replaced a 150-row audit trail with a 2-row one and
128+
// reported success. The rows are not recoverable from the API afterwards.
129+
//
130+
// So a shrinking rewrite is now a REFUSAL, not a write. The evidence itself
131+
// survives independently of this endpoint -- every row carries a transaction hash
132+
// and those are on the explorer permanently -- but the file must not be quietly
133+
// truncated to match whatever the API is willing to remember today.
134+
let previous = null;
135+
try { previous = JSON.parse(readFileSync(receiptsPath, 'utf8')); } catch { /* first run */ }
136+
if (previous && rows.length < (previous.count ?? 0) && !flags.prune) {
137+
console.error(`\n REFUSING TO SHRINK ${basename(receiptsPath)}`);
138+
console.error(` on disk ${previous.count} row(s) · analytics/runs returned ${rows.length}`);
139+
console.error(` KeeperHub does not retain run history indefinitely, so this is expected to`);
140+
console.error(` happen as rows age out — and overwriting would destroy an audit trail the API`);
141+
console.error(` can no longer reproduce. Every row on disk carries a tx hash; verify those on`);
142+
console.error(` the explorer instead.`);
143+
console.error(`\n If the loss is genuinely intended: re-run with --prune\n`);
144+
process.exit(1);
145+
}
146+
147+
writeFileSync(receiptsPath, JSON.stringify({
148+
$comment: 'Generated by scripts/audit.mjs from KeeperHub execution rows. Do not hand-edit — regenerate. '
149+
+ 'A regenerate can only ever ADD rows: analytics/runs ages history out, and audit.mjs refuses to '
150+
+ 'shrink this file without --prune.',
123151
chainId, chainName: chain.name, receiptsEligible: chain.receiptsEligible,
124152
generatedAt: new Date().toISOString(), count: rows.length, rows,
125153
}, null, 2) + '\n');

0 commit comments

Comments
 (0)