{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"a24f9aa1-8a2e-49de-85c9-0c23ef14aed3","kind":"audit","nodes":[{"acceptedSubmissionHash":"1d1048cbba1eb4119dbde95974b6c996369a219152f5c544295277a8ea817977","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"a65c5241049a93ed63551259e3dd9608656fdecd0341580046b3b7f24b56e6f9","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"26536f320d69a4f62437cf32c6dd5614751954465763ed41d70b0df0445d1618","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","subagents":4,"tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"4fefe3d04b5deced1c66c0ae671aa315169e9fcba004c26238919ea65df6911d","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"78f488841fefcefdbfe99f9cc3603c9eeed6b366ae2056299378c3612b376b83","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"}],"objective":"FLOP Observatory M0: independent audit of a read-only devnet observer (TypeScript and Python, no Solidity in scope)\n\nSUBJECT: observatory/ and interfaces/ in uxp-labs/flop-devnet-lab at exactly commit bce10dcf54643e06a5f97435866df476eafcc453 (branch claude/observatory-m0, PR #5; not on main yet). It is a Node 22 TypeScript package with an independent Python 3 recount. Inspect the TypeScript, Python, tests and scripts. If the audit tool cannot cover this scope, say so plainly.\n\nOUT OF SCOPE: tclk-rehearsal/ (including HashLock.sol, the only Solidity in the repo), imd-flop/, viewer/, spend-guard/, cost-checker/. Do not report contract findings.\n\nWHAT IT IS: a read-only observer for FLOP devnet (flop.finance). From saved fixtures it computes network status and reset detection, escrow states from finalized chain events, an event census and spec drift. It renders them as a page, JSON feeds, a hash-chained snapshot log and a read-only MCP server. M0 runs offline on fixtures only.\n\nHARD RULES: read only. No calls to FLOP endpoints (rpc.devnet.flop.network, faucet.flop.network, api.flop.network), no faucet requests, no keys, no signing, no spending, no source changes, no PRs or comments on the repo.\n\nREAD FIRST: observatory/RULES.md (the spec; every rule is binding), observatory/README.md, interfaces/README.md, and the PR #5 description, which lists every claim as tested, source-only, unresolved or not done.\n\nRUN: cd observatory && npm run check. It is offline except npm ci, and writes check-runs/<sha>/<run>/ with logs, results and SHA256SUMS. On Node 22 (our run: Node v22.23.4, Python 3.13.18) expect all 27 steps to exit 0, 485 tests to pass, and 159 of 159 mutants to be killed. Expect this digest over every output file:\ncd outputs && find . -type f | LC_ALL=C sort | xargs sha256sum | sha256sum\ndecd38476868f2cd55209326f74de27f33214b404a2deaf69b66cadb85d3c372\nReport any difference.\n\nKNOWN AND ACCEPTED (report only a worse variant):\n1. The status and census \"reset detected\" page rows share a label, so a page.ts change that swapped them between tables would pass the checks.\n2. Drift spec and runtime columns are not checked against the header order.\n3. Impossible calendar dates such as 2026-11-31 parse and roll over.\n4. An event with one in-range and one out-of-range copy: the escrow is unverified, but the census counts it.\n5. By design for M0: fixtures only and no live chain pin; the guard is not wired to a transport yet; the census channel event names (computeChannel.ChannelOpened, ChannelSettled) are assumed; the escrow id in EscrowReleased and EscrowRefunded is read from data[0].\n\nLOOK HARDEST AT, in order:\n1. Escrow evidence (RULES.md section 2, src/escrow.ts): any input that yields funded, claimed or refunded without finalized evidence on the pinned genesis, or despite missing, conflicting, malformed, unreadable, out-of-range or unfinalized evidence. This includes copies of one event, unparsable ids, page validity, and a finalized height from another chain.\n2. Network status (sections 1 and 1.1, src/status.ts): any input that yields health ok when a check failed or could not run, or reset_detected false without both faucet and RPC on the pinned genesis.\n3. Census (sections 3 and 3.1, src/census.ts against tools/recount.py): any input where a partial census reads complete, a count includes what the rules exclude, or the TypeScript census and the Python recount disagree. The built-in cross-check only covers observatory/fixtures/census-*.\n4. Read only (section 4, src/guard.ts, test/setup.ts, test/guard.test.ts): any src path to the network, a child process, a signature, a spend or a write. Also any way a test can leave such an attempt behind without failing.\n5. Outputs agree (section 5, src/page.ts, src/feed.ts, src/mcp.ts, scripts/consistency.mjs): any field where the page, the feeds and MCP disagree, or where the page shows a value in the wrong place, that the checks miss.\n6. Evidence honesty: does npm run check prove what README.md and PR #5 claim? Check scripts/mutate.mjs (how it classifies kills), the canaries and the recount comparison for anything that could report a pass that is not one.\n\nDELIVER: every finding with severity (critical, high, medium, low, info), file and line, and a reproduction (input files, command, output). Say whether it can cause a wrong escrow state, a wrong health or reset value, a partial count shown as complete, or a network, sign or spend path. Mark each finding as reproduced by running or found by reading. Critical or high means a consumer could act on a false escrow state or reset flag. If nothing at medium or above turns up, say so explicitly for each area.","parentJobId":null,"planHash":"117741dd38df0d3898e5a286a5fc2d8aed35bcd63b1bbf1b09fe59475608143e","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"a24f9aa1-8a2e-49de-85c9-0c23ef14aed3","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"51381","feedbackHash":"f0e4fbdcd32157a8ae7019ed3f07837fcb7f5d77765eb8ff30a8663ce2a9518f","nodeKey":"audit_economics","submissionHash":"1d1048cbba1eb4119dbde95974b6c996369a219152f5c544295277a8ea817977","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52019","feedbackHash":"f170436cfea0339afe913e4a0837e96bef0875c06e48fbca623485fb732d0336","nodeKey":"audit_flow","submissionHash":"a65c5241049a93ed63551259e3dd9608656fdecd0341580046b3b7f24b56e6f9","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52601","feedbackHash":"eb9e7030ab194112404f143f5a862361c77acbc9bd2e1812d27aab0a3d591ccd","nodeKey":"audit_judge","submissionHash":"26536f320d69a4f62437cf32c6dd5614751954465763ed41d70b0df0445d1618","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51201","feedbackHash":"605817ac56db96079b9343ffd1bc138c3048093fda89127b78a62c0ba91ae727","nodeKey":"audit_math","submissionHash":"4fefe3d04b5deced1c66c0ae671aa315169e9fcba004c26238919ea65df6911d","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"53909","feedbackHash":"42cdc58ba223ebfcf68091bb1fb35336e12f86aa767f93756676b5a007f9d0b7","nodeKey":"audit_permissions","submissionHash":"78f488841fefcefdbfe99f9cc3603c9eeed6b366ae2056299378c3612b376b83","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"09d1bf6cf3d8269f7d37cb8d30d03d2bc9d56f77cce6b3f08811eb3fd5e2050f","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"f843b0d093c69ab9","findings":[{"citation":"resolved","description":"Reproduced by running. RULES.md 3.1 clause 14 says an integer must be safe (at most 2^53-1), that a larger value in an event field makes the event malformed, and that this holds for every integer field including index and block_number; clause 7 says the escrow rules read events the same way, and section 2 says a malformed event can never make an escrow look funded, claimed or refunded. census.ts (isInt, Number.isSafeInteger) and recount.py (as_int, MAX_SAFE) both classify such an event as malformed_dropped. escrow.ts structureProblem uses Number.isInteger for index (line 99) and block_number (line 101), so the same finalized hasStation.EscrowOpened or EscrowReleased with index 9007199254740993 is treated as well formed, grouped, and used as evidence. The TypeScript census and the escrow module therefore disagree about the same event, and the escrow state comes from an event the spec says is never evidence. Because JSON.parse rounds 2^53+1 to 2^53, two distinct indexes also collapse into one event identity in escrow.ts (reported event_index 9007199254740992). Can cause a wrong escrow state as the spec defines it; no network, sign or spend path. Fix: use Number.isSafeInteger in structureProblem for index and block_number (and in isInt at line 108), matching census.ts.","line":99,"path":"observatory/src/escrow.ts","reproduction":"mkdir -p $T/e1/event-pages; meta.json: {\"scenario\":\"e1\",\"description\":\"x\",\"now\":\"2026-11-10T00:00:00Z\",\"synthetic\":true,\"escrow_ids\":[\"0xa1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1\"],\"pinned_genesis\":\"0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631\",\"census_request\":{\"from_block\":100,\"to_block\":199}}; event-pages/000.json: {\"genesis\":\"0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631\",\"from_block\":100,\"to_block\":199,\"page\":0,\"next_page\":null,\"events\":[{\"block_number\":150,\"block_hash\":\"0x0909090909090909090909090909090909090909090909090909090909090909\",\"finalized\":true,\"index\":9007199254740993,\"section\":\"hasStation\",\"method\":\"EscrowOpened\",\"data\":{\"0\":\"0xa1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1\",\"who\":\"alice\"}}]}. Run: cd observatory && node dist/cli.js escrow --fixtures $T/e1 ; node dist/cli.js census --fixtures $T/e1 ; python3 -I tools/recount.py --dir $T/e1 --genesis 0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631 --from 100 --to 199. Expected: escrow state unverified with a malformed note (clause 14 + section 2), consistent with census malformed_dropped 1. Actual: escrow state \"funded\" with evidence event_index 9007199254740992 and no malformed note, while the TypeScript census and the Python recount both report escrows_opened 0 and malformed_dropped 1. Replacing EscrowOpened with EscrowReleased gives state \"claimed\" the same way.","severity":"medium","snippet":"  if (typeof index !== \"number\" || !Number.isInteger(index) || index < 0) return \"index is not a whole number\";","title":"escrow.ts accepts an unsafe-integer event index as evidence; an event the rules (3.1 clause 14, clause 7) call malformed yields funded or claimed"},{"citation":"resolved","description":"Reproduced by running. RULES.md section 2 says that when any event page is structurally invalid (census clause 12) every escrow in the result is unverified with a note naming the page, and clause 14 says a page field holding an integer above 2^53-1 refuses the census. census.ts checkPage uses Number.isSafeInteger and refuses such a page; escrow.ts pageProblem (lines 112-125) uses this isInt helper built on Number.isInteger, so a page with to_block 9007199254740993 (or from_block, page or next_page of that size) passes as valid. The escrow module then reads its events and reaches claimed, funded or refunded from a page the spec and the sibling module say is invalid. The two modules read the same page file differently in a snapshot: the census refuses and the escrows show a state. Can cause an escrow state where the rules demand unverified; no network, sign or spend path. Fix: make isInt use Number.isSafeInteger so pageProblem matches census.ts checkPage.","line":108,"path":"observatory/src/escrow.ts","reproduction":"Same meta.json as the previous finding in $T/e2. event-pages/000.json: {\"genesis\":\"0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631\",\"from_block\":100,\"to_block\":9007199254740993,\"page\":0,\"next_page\":null,\"events\":[{\"block_number\":150,\"block_hash\":\"0x0909090909090909090909090909090909090909090909090909090909090909\",\"finalized\":true,\"index\":0,\"section\":\"hasStation\",\"method\":\"EscrowReleased\",\"data\":{\"0\":\"0xa1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1\"}}]}. Run: node dist/cli.js escrow --fixtures $T/e2 ; node dist/cli.js census --fixtures $T/e2. Expected: escrow unverified with note 'event page is invalid, so nothing is verified: ...', and the census refusal. Actual: escrow state \"claimed\" with evidence, while the census exits 2 with 'census refused: event page 0: to_block is missing or not a safe integer'.","severity":"medium","snippet":"  return typeof value === \"number\" && Number.isInteger(value);","title":"escrow.ts pageProblem accepts a page whose block bounds or page numbers are unsafe integers; a page the census refuses as structurally invalid still yields claimed instead of unverified"},{"citation":"resolved","description":"Reproduced by running. RULES.md 3.1 clause 12 says an event page file that cannot be parsed as standard JSON (RFC 8259) refuses the census and is never turned into a count, and section 2 says such a page leaves every escrow unverified. RFC 8259 section 8.1 requires JSON text to be UTF-8. readFileSync(path, 'utf8') replaces every invalid byte with U+FFFD instead of failing, so a page containing an invalid byte (for example 0xFF inside an account string) parses, and the garbled file is counted as a complete census and used as escrow evidence. tools/recount.py opens the file with encoding utf-8 and refuses it (exit 2). This is a direct TypeScript against Python disagreement on a page file, of the kind the built-in cross-check (scripts/compare-recount.mjs) would flag as disagree-on-refusal if such a fixture existed; none does. The same lossy read applies to every fixture file (faucet-health.json, rpc-head.json and the rest). Can cause a wrong escrow state and a count presented as complete from a file the rules say to refuse; no network, sign or spend path. Fix: decode with new TextDecoder('utf-8', { fatal: true }) and report a decode failure as 'not valid JSON' in loadErrors and pageErrors, which already makes the census refuse and every escrow unverified.","line":113,"path":"observatory/src/source.ts","reproduction":"Same meta.json as the first finding in $T/e3. Write event-pages/000.json with printf so the byte 0xFF lands inside the who string: printf '{\"genesis\":\"0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631\",\"from_block\":100,\"to_block\":199,\"page\":0,\"next_page\":null,\"events\":[{\"block_number\":150,\"block_hash\":\"0x0909090909090909090909090909090909090909090909090909090909090909\",\"finalized\":true,\"index\":0,\"section\":\"hasStation\",\"method\":\"EscrowReleased\",\"data\":{\"0\":\"0xa1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1\",\"who\":\"al\\xffice\"}}]}' > $T/e3/event-pages/000.json. Run: node dist/cli.js escrow --fixtures $T/e3 ; node dist/cli.js census --fixtures $T/e3 ; python3 -I tools/recount.py --dir $T/e3 --genesis 0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631 --from 100 --to 199. Expected: both implementations refuse the page (not valid JSON text); escrow unverified with a 'could not be read' note. Actual: TypeScript escrow state \"claimed\"; TypeScript census exit 0, status \"complete\", escrows_released 1 with a by_account key containing U+FFFD; Python recount exit 2 with \"'utf-8' codec can't decode byte 0xff in position 416: invalid start byte\".","severity":"medium","snippet":"        return JSON.parse(readFileSync(path, \"utf8\")) as T;","title":"A page file with invalid UTF-8 is not valid JSON text, yet source.ts decodes it lossily: the TypeScript census reports complete totals and escrows reach claimed, while the Python recount refuses the s"},{"citation":"resolved","description":"Reproduced by running. RULES.md 1.1 says that when there is a previous snapshot on the same genesis, RPC health ok also needs no rollback, no changed finalized hash and no finalized height stuck longer than the stale limit, and rpc.stale must be true when the height is stuck. status.ts classifyRpc implements this through opts.previous, and snapshot.ts buildSnapshot forwards o.previous, but cli.ts builds the snapshot without ever reading the last line of the --log file (the previous snapshot is right there: appendSnapshot reads it for seq and prev_hash). So in the only production path, these three checks never run: a second snapshot whose finalized height is 1000 blocks lower than the first, with a different hash, shows rpc.health ok, stale false and an empty conflicts list. The PR description lists 'rollback, changed finalized hash' among tested claims; they are tested only at the unit level on classifyRpc with a hand-built previous. This is a wrong health and stale value (not a wrong reset flag), and the lower finalized height is then used to bound escrow evidence. No network, sign or spend path. Fix: in the snapshot command, when --log is given, read the last snapshot with readSnapshots and pass { genesis: status.genesis.rpc, finalized_height, finalized_hash, observed_at: rpc.observed_at } as previous to buildSnapshot when its genesis is the pinned one (and add a cli-check for it).","line":151,"path":"observatory/src/cli.ts","reproduction":"cp -r fixtures/status-healthy $T/a; cp -r fixtures/status-healthy $T/b; edit $T/b/rpc-head.json: finalized {\"number\":3340000,\"hash\":\"0x1111111111111111111111111111111111111111111111111111111111111111\"}, best_number 3340002, observed_at \"2026-11-09T23:48:40Z\"; edit $T/b/meta.json now to \"2026-11-09T23:48:41Z\". Run: node dist/cli.js snapshot --status $T/a --out $T/out --log $T/out/snapshots.jsonl ; node dist/cli.js snapshot --status $T/b --out $T/out --log $T/out/snapshots.jsonl ; then read $T/out/status.json. Expected (RULES.md 1.1, previous snapshot on the same genesis, height 3341575 -> 3340000): rpc.health degraded, conflicts containing 'finalized height went backwards from 3341575 to 3340000 on the same genesis'. Actual: snapshots.jsonl has 2 lines (seq 1 chained to seq 0), yet status.rpc.health \"ok\", status.rpc.stale false, status.conflicts [], finalized_height 3340000.","severity":"medium","snippet":"      { pinnedGenesis: pinned, now, sourceSha: one(a, \"--source-sha\") ?? null },","title":"The snapshot command never passes the previous log entry to networkStatus, so the rollback, changed-finalized-hash and stuck-height checks of RULES.md 1.1 never run from the CLI even with --log; a 100"},{"citation":"resolved","description":"Reproduced by running. RULES.md section 1 says reset_detected is false only when both the faucet and the RPC report the pinned genesis, and section 1.1 says that for the faucet health body 'the HTTP status wins' and only HTTP 200 counts. The faucet /chain reading is not held to any of that: networkStatus reads chain.body.genesisHash even when chain.http is null with an error set (no response), when chain.http is 500, and when fetched_at is eight days old or null, and resetDetected then returns false. The HttpResult type says body is null when there was no response, so in M0 this needs an inconsistent fixture; in M1 a transport that keeps a cached or partially read body on a failed request would clear the reset flag from a read that did not happen. Severity is low because the input violates the HttpResult contract and the RPC must still agree; it is a reset value derived from a non-report rather than a wrong flag on well-formed input. Fix: treat the /chain genesis as unknown unless chain.http === 200 and chain.error is null (and optionally apply the same freshness rule as the health reading), and note a rejected reading in conflicts.","line":244,"path":"observatory/src/status.ts","reproduction":"cp -r fixtures/status-healthy $T/e5; write $T/e5/faucet-chain.json as {\"http\":null,\"error\":\"connect ETIMEDOUT\",\"fetched_at\":null,\"body\":{\"genesisHash\":\"0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631\"}}. Run: node dist/cli.js status --fixtures $T/e5. Expected: genesis.faucet null (no response was received) and reset_detected null. Actual: genesis.faucet 0x99c1...2631, reset_detected false, conflicts []. Variant: {\"http\":500,\"error\":null,\"fetched_at\":\"2026-11-01T00:00:00Z\",\"body\":{\"genesisHash\":\"0x99c1...2631\"}} also gives reset_detected false with no conflict.","severity":"low","snippet":"  const rawFaucetGenesis = chain.body && typeof chain.body === \"object\" ? chain.body.genesisHash : undefined;","title":"The faucet genesis for reset detection is read from the /chain body regardless of HTTP status, response presence or freshness, so a failed or 5xx faucet read with a leftover body still clears reset_de"},{"citation":"resolved","description":"Reproduced by running. RULES.md 3.1 clause 12 and 15 say input that cannot be read is refused and never turned into a partial count. When <scenario>/event-pages exists but is a regular file (or a symlink to one), FixtureSource.fromDir throws ENOTDIR and the TypeScript census exits 2, but recount.py only checks os.path.isdir, falls through with an empty page list, and prints a partial census with the whole request as one gap and exit 0. scripts/compare-recount.mjs would report this as disagree-on-refusal, but no fixture exercises it. The recount is the independent witness, so a lenient witness weakens the cross-check rather than any consumer-facing output; hence low. Fix: in load_pages raise Unreadable when event-pages exists and is not a directory (os.path.lexists(pages_dir) and not os.path.isdir(pages_dir)).","line":68,"path":"observatory/tools/recount.py","reproduction":"mkdir $T/e6; copy the meta.json from the first finding; echo '{}' > $T/e6/event-pages. Run: node dist/cli.js census --fixtures $T/e6 ; echo $? ; python3 -I tools/recount.py --dir $T/e6 --genesis 0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631 --from 100 --to 199 ; echo $?. Expected: both refuse (exit 2). Actual: TypeScript exits 2 with 'ENOTDIR: not a directory, scandir .../event-pages'; Python exits 0 with {\"complete\": false, \"status\": \"partial\", \"covered\": [], \"gaps\": [{\"from_block\": 100, \"to_block\": 199}], ...}.","severity":"low","snippet":"    if os.path.isdir(pages_dir):","title":"recount.py treats an event-pages entry that is not a directory as 'no pages' and emits a partial census, while the TypeScript source refuses; the two implementations disagree on refusal"},{"citation":"resolved","description":"Reproduced by running. escrow.ts treats more than one distinct finalized hasStation.EscrowOpened for one escrow id as conflicting evidence ('more than one distinct finalized open for this escrow', line 387) and leaves the escrow unverified, but that branch is only reached when there is no finalized release or refund. When a release (line 372) or refund (line 379) is present, the code drops the opens from the evidence when opened.length is not 1 and reports claimed or refunded without any note that the chain shows two distinct opens for one id, which on a well-behaved chain is impossible and on a reorged or inconsistent page set is a sign the pages cannot be trusted. The same evidence set is therefore judged conflicting or clean depending on whether a terminal event happens to be present, and the inconsistency disappears from the notes. RULES.md section 2 asks for 'unverified with a note' on conflicting evidence and says more conflicting evidence never gives a more confident answer. Severity low: claimed still rests on a finalized release that exists in the input, so a consumer acting on it is acting on real evidence; the defect is lost information and an inconsistent conflict rule. Fix: compute the 'more than one distinct finalized open' conflict before the terminal branches (alongside the release/refund multiplicity conflicts at lines 368-369), or at minimum add a note when opened.length > 1 on the claimed and refunded paths.","line":377,"path":"observatory/src/escrow.ts","reproduction":"Same meta.json as the first finding in $T/e7; event-pages/000.json with three finalized events on genesis 0x99c1...2631, page range 100-199, all with data {\"0\": \"0xa1a1...a1a1\"}: EscrowOpened at block 110 hash 0x0a*32 index 0, EscrowOpened at block 120 hash 0x0b*32 index 0, EscrowReleased at block 130 hash 0x0c*32 index 0. Run: node dist/cli.js escrow --fixtures $T/e7. With only the two opens: state unverified, note 'conflicting evidence: more than one distinct finalized open for this escrow'. With the release added: state \"claimed\", evidence [hasStation.EscrowReleased], notes contain only the finalized-height note, nothing about the two distinct opens. Expected: the same conflict (or at least a note) in both cases.","severity":"low","snippet":"        evidence = [...(opened.length === 1 ? opened : []), ...released];","title":"Two distinct finalized EscrowOpened events for one id are a conflict only when no terminal event exists; with a finalized release or refund the duplicate opens are silently accepted and not even noted"},{"citation":"resolved","description":"Reproduced by running. RULES.md 3.1 clause 7 defines a malformed event and says 'the escrow rules read events the same way'; a block_number that is 'present but neither null nor an integer' is malformed, and a negative integer is an integer. census.ts readEvent and recount.py parse_event accept -5 and the event is then skipped by the block range filter (clause 4). escrow.ts structureProblem rejects it as 'block_number is not a whole number or null', so the same event is reported as 'malformed event ignored' by the escrow module and as 'outside the request or their page's range were skipped' by the census. Both outcomes are conservative (never evidence; a finalized release naming the escrow leaves it unverified either way), so this is an inconsistency in notes and counts only. Fix: drop the n < 0 test and let the page range check classify it as out of range, so the notes agree with the census.","line":101,"path":"observatory/src/escrow.ts","reproduction":"Same meta.json as the first finding in $T/e8; event-pages/000.json with one finalized hasStation.EscrowReleased for 0xa1a1...a1a1 with block_number -5, block_hash 0x0a*32, index 0, page range 100-199. Run: node dist/cli.js escrow --fixtures $T/e8 ; node dist/cli.js census --fixtures $T/e8. Actual: escrow note 'malformed event ignored: hasStation.EscrowReleased (block_number is not a whole number or null)' (state unverified); census malformed_dropped 0 with note '1 event(s) outside the request or their page's range were skipped'. Expected: one classification for the same event.","severity":"info","snippet":"  if (n !== null && n !== undefined && (typeof n !== \"number\" || !Number.isInteger(n) || n < 0)) {","title":"A negative integer block_number is malformed in escrow.ts but a well-formed, out-of-range event in census.ts and recount.py, contrary to clause 7's 'the escrow rules read events the same way'"}],"hash":"1d1048cbba1eb4119dbde95974b6c996369a219152f5c544295277a8ea817977","nodeId":"7899c041-dfe7-404e-af61-9549b76bb87e","outcome":"completed","summary":"The review is complete. The findings file holds eight substantiated findings, every path, line and snippet verified against the tree, and no repository file was changed (only ignored `dist/` and `node_modules/` were created by the build).\n\n**Verification of the author's claims on this machine**\n\n| Check | Result |\n|---|---|\n| Unit tests (Node 24.21.0) | 485 of 485 pass |\n| Mutants for escrow, status, census (clean clone) | 87 of 87 killed, both controls pass |\n| Five scenario renders, consistency checker | 4503 checks, 0 failures |\n| Output digest on Node 22.23.4 | decd3847… matches the PR exactly |\n| Output digest on Node 24 | differs, only because pin.json embeds the Node version |\n\nThe full 159-mutant step was not run end to end: `scripts/mutate.mjs` copies the entire repo root and aborted on unreadable workspace dotfiles outside `observatory/`. From a clean clone it ran for the three economics modules without issue.\n\n**Findings (all reproduced by running, none critical or high)**\n\n- **Medium, escrow.ts:99.** An event with an unsafe-integer index (2^53+1) is well formed to the escrow module but malformed to both censuses. It yields funded or claimed from an event the rules say is never evidence.\n- **Medium, escrow.ts:108.** A page with an unsafe-integer bound passes `pageProblem` but the census refuses it. The same snapshot shows claimed where the rules demand unverified for every escrow.\n- **Medium, source.ts:113.** Invalid UTF-8 inside a page is decoded lossily. TypeScript reports a complete census and a claimed escrow while the Python recount refuses the file.\n- **Medium, cli.ts:151.** The snapshot command never reads the previous log entry, so rollback, changed-hash and stuck-height checks never run in production. A 1000-block rollback renders rpc health ok with no conflict.\n- **Low, status.ts:244.** The faucet genesis is read from the body even when the read failed, the status is 5xx or the reading is days old. This clears reset_detected to false on a contract-violating input.\n- **Low, recount.py:68.** An `event-pages` path that is a file gives a partial census from the recount while TypeScript refuses.\n- **Low, escrow.ts:377.** Two distinct finalized opens are a conflict only without a terminal event. With a release present the anomaly is accepted and not noted.\n- **Info, escrow.ts:101.** A negative block_number is malformed in escrow.ts but out-of-range in the census, against clause 7.\n\n**Areas with nothing at medium or above**\n\n- **Network status (section 1):** no well-formed input produced health ok with a failed check, or reset_detected false without both sources on the pinned genesis. The finding on cli.ts is a missing wiring of a spec'd check, not a classifier defect.\n- **Census (section 3):** no partial count reads complete and no excluded event is counted. TypeScript and Python agree on every fixture; they disagree only on the two malformed-input cases above.\n- **Read only (section 4):** the src scan found no fetch, socket, child process, signing or key path outside guard.ts.\n- **Outputs agree (section 5):** all five scenarios pass cell by cell. The known swapped-label and drift-column gaps are as the brief describes.\n- **Evidence honesty:** mutate.mjs classifies kills from the per-file JSON report with load errors excluded, and the digest reproduced exactly on the pinned Node version.","treeHash":null,"usage":{"cachedInputTokens":1414701,"cost":{"source":"runtime","usdMicros":5735552},"inputTokens":354,"model":"claude-fable-5-1","outputTokens":44472,"runtime":"claude","turns":38,"wallClockMs":783254}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"cc86b49d3c401897","findings":[{"citation":"resolved","description":"Reproduced by running. Merges the economics, permissions and math specialists' event-field findings. RULES.md 3.1 clause 14 says an integer must be safe (|v| <= 2^53-1) for every integer field, including index and block_number, and that an event field holding a larger value makes the event malformed. Clause 7 says the escrow rules read events the same way. Section 2 says a malformed event can never make an escrow look funded, claimed or refunded. structureProblem() checks index (line 99) and block_number (line 101) with Number.isInteger, not Number.isSafeInteger. census.ts and recount.py both use the safe check. So one finalized event is malformed_dropped in the census and the recount, but the escrow module uses it as evidence. JSON.parse also rounds 9007199254740993 to 9007199254740992, so two distinct indexes collapse into one (genesis, block_hash, index) identity, and the evidence shows the rounded value. Impact: a wrong escrow state as the spec defines it (funded or claimed where the rules require unverified), and the escrow and census outputs disagree about the same event in one snapshot. No network, sign or spend path. Fix: use Number.isSafeInteger at lines 99 and 101, and add an escrow fixture that mirrors fixtures/census-unsafe-integer.","line":99,"path":"observatory/src/escrow.ts","reproduction":"Scenario dir $T/x with meta.json = {\"scenario\":\"e1\",\"description\":\"x\",\"now\":\"2026-11-10T00:00:00Z\",\"synthetic\":true,\"escrow_ids\":[\"0xa1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1\"],\"pinned_genesis\":\"0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631\",\"census_request\":{\"from_block\":100,\"to_block\":199}} and event-pages/000.json = {\"genesis\":\"0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631\",\"from_block\":100,\"to_block\":199,\"page\":0,\"next_page\":null,\"events\":[{\"block_number\":150,\"block_hash\":\"0x0909090909090909090909090909090909090909090909090909090909090909\",\"finalized\":true,\"index\":9007199254740993,\"section\":\"hasStation\",\"method\":\"EscrowOpened\",\"data\":{\"0\":\"0xa1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1\",\"who\":\"alice\"}}]}. Commands, from observatory/ after tsc: node dist/cli.js escrow --fixtures $T/x ; node dist/cli.js census --fixtures $T/x ; python3 -I tools/recount.py --dir $T/x --genesis 0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631 --from 100 --to 199. Expected: escrow unverified with a 'malformed event ignored' note. Actual: escrow state \"funded\", evidence [hasStation.EscrowOpened, block 150, event_index 9007199254740992], and the only note is 'evidence is not bounded by a finalized height'. The TypeScript census reports complete, escrows_opened 0, malformed_dropped 1. The recount reports escrows_opened 0. With method EscrowReleased the escrow is \"claimed\" with event_index 9007199254740992.","severity":"medium","snippet":"  if (typeof index !== \"number\" || !Number.isInteger(index) || index < 0) return \"index is not a whole number\";","title":"escrow.ts accepts an unsafe-integer event index or block_number (above 2^53-1) as evidence; the census and recount call the same event malformed, yet the escrow becomes funded or claimed"},{"citation":"resolved","description":"Reproduced by running. Merges the page-field findings from the economics, flow, permissions and math specialists. RULES.md section 2 says that when any event page is structurally invalid (census clause 12), every escrow in the result is unverified with a note naming it. Clause 14 says a page field holding an integer above 2^53-1 refuses the census. The isInt() helper behind pageProblem() (lines 112-125) uses Number.isInteger, so a page with to_block 9007199254740993 passes as valid and its events become evidence. census.ts checkPage (Number.isSafeInteger) and recount.py both refuse the same file. In one snapshot the census refuses while escrows show definite states. Impact: a definite escrow state where the rules require unverified. No network, sign or spend path. Fix: make isInt() use Number.isSafeInteger.","line":108,"path":"observatory/src/escrow.ts","reproduction":"Scenario dir $T/x with the same meta.json as the event-field finding and event-pages/000.json = {\"genesis\":\"0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631\",\"from_block\":100,\"to_block\":9007199254740993,\"page\":0,\"next_page\":null,\"events\":[{\"block_number\":150,\"block_hash\":\"0x0909090909090909090909090909090909090909090909090909090909090909\",\"finalized\":true,\"index\":0,\"section\":\"hasStation\",\"method\":\"EscrowReleased\",\"data\":{\"0\":\"0xa1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1\",\"who\":\"alice\"}}]}. node dist/cli.js escrow --fixtures $T/x -> state \"claimed\", evidence [hasStation.EscrowReleased block 150 index 0]. node dist/cli.js census --fixtures $T/x -> exit 2, 'census refused: event page 0: to_block is missing or not a safe integer'. python3 -I tools/recount.py --dir $T/x --genesis 0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631 --from 100 --to 199 -> '000.json: to_block missing or not an integer'. Expected: escrow unverified with 'event page is invalid, so nothing is verified: page at position 0 ...'.","severity":"medium","snippet":"  return typeof value === \"number\" && Number.isInteger(value);","title":"escrow.ts pageProblem accepts a page whose from_block, to_block, page or next_page is an unsafe integer; the census refuses the page as structurally invalid, but escrows still reach claimed, refunded "},{"citation":"resolved","description":"Reproduced by running. Merges the economics and math findings. RULES.md 3.1 clause 12 says an event page file that cannot be read or parsed as standard JSON (RFC 8259) refuses the census and is never turned into a count. Section 2 says such a page leaves every escrow unverified. RFC 8259 section 8.1 requires JSON text to be UTF-8. readFileSync(path, 'utf8') replaces invalid bytes with U+FFFD instead of failing. The garbled page then parses, is counted as a complete census and is used as escrow evidence. tools/recount.py opens the file as strict utf-8 and exits 2. This is a direct TypeScript against Python disagreement that scripts/compare-recount.mjs would flag, but no fixtures/census-* scenario holds such a file. The same lossy read applies to every fixture file (meta.json, faucet-chain.json, rpc-head.json and the rest). Impact: a census shown as complete, and a claimed escrow, from a file the rules say to refuse. No network, sign or spend path. Fix: decode with new TextDecoder('utf-8', { fatal: true }) (or check Buffer.isUtf8) and record a failure as 'not valid JSON' in loadErrors and pageErrors.","line":113,"path":"observatory/src/source.ts","reproduction":"Scenario dir $T/x with the same meta.json as the event-field finding. Write the page so that the raw byte 0xFF sits inside the who string: printf the page JSON {\"genesis\":\"0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631\",\"from_block\":100,\"to_block\":199,\"page\":0,\"next_page\":null,\"events\":[{\"block_number\":150,\"block_hash\":\"0x0909090909090909090909090909090909090909090909090909090909090909\",\"finalized\":true,\"index\":0,\"section\":\"hasStation\",\"method\":\"EscrowReleased\",\"data\":{\"0\":\"0xa1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1\",\"who\":\"al\\xffice\"}}]} > $T/x/event-pages/000.json. node dist/cli.js escrow --fixtures $T/x -> state \"claimed\". node dist/cli.js census --fixtures $T/x -> complete true, escrows_released 1, malformed_dropped 0, by_account {\"al\\ufffdice\":{\"escrows_released\":1}}. python3 -I tools/recount.py --dir $T/x --genesis 0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631 --from 100 --to 199 -> \"cannot read .../000.json: 'utf-8' codec can't decode byte 0xff in position 416: invalid start byte\" (exit 2). Expected: both implementations refuse, and the escrow is unverified.","severity":"medium","snippet":"        return JSON.parse(readFileSync(path, \"utf8\")) as T;","title":"Fixture files are decoded lossily as UTF-8, so a page that is not valid JSON text (an invalid byte) becomes a complete census and escrow evidence in TypeScript, while the Python recount refuses it"},{"citation":"resolved","description":"Reproduced by running. Merges the economics, flow, permissions and math findings. RULES.md 1.1 says RPC ok needs every check to run and pass, including, when a previous snapshot on the same genesis exists, no rollback, no changed finalized hash and no finalized height stuck past the stale limit. In those cases rpc.stale must be true. classifyRpc (status.ts:178-195) implements this only through opts.previous, and buildSnapshot forwards o.previous. cli.ts builds SnapshotOptions without previous, even when --log names a log whose last entry appendSnapshot then reads for seq and prev_hash. The only tests of the check (test/status.test.ts) pass previous by hand, so the wiring is untested, although PR #5 lists rollback and changed hash as tested. Impact: a wrong health and stale value (ok and false) and a missing conflict on the page, the feeds and MCP. The lower, rolled-back finalized height is then used to bound escrow evidence. It cannot change reset_detected. No network, sign or spend path. Fix: when --log is given, read its last snapshot (readSnapshots). When that snapshot's status.genesis.rpc is non-null, pass {genesis, finalized_height, finalized_hash, observed_at: rpc.observed_at} as previous. Add a cli-checks case.","line":151,"path":"observatory/src/cli.ts","reproduction":"cp -r fixtures/status-healthy $T/a; cp -r fixtures/status-healthy $T/b. In $T/b/rpc-head.json set finalized to {number:3340000, hash:0x11*32}, best_number 3340002 and observed_at 2026-11-09T23:48:40Z. In $T/b/meta.json set now 2026-11-09T23:48:41Z, and set fetched_at 2026-11-09T23:48:40Z in the three HTTP fixtures. node dist/cli.js snapshot --status $T/a --out $T/o1 --log $T/log.jsonl; node dist/cli.js snapshot --status $T/b --out $T/o2 --log $T/log.jsonl. Actual log: seq 0 finalized 3341575, rpc ok; seq 1 finalized 3340000, rpc.health ok, stale false, conflicts []. Calling networkStatus on $T/b directly with previous={genesis pinned, finalized_height 3341575, ...} returns degraded with conflict 'finalized height went backwards from 3341575 to 3340000 on the same genesis'. That is the expected CLI result.","severity":"medium","snippet":"      { pinnedGenesis: pinned, now, sourceSha: one(a, \"--source-sha\") ?? null },","title":"The snapshot command never passes the previous log entry to networkStatus, so the RULES 1.1 rollback, changed-finalized-hash and stuck-height checks never run in production: RPC health stays ok after "},{"citation":"resolved","description":"Reproduced by running. Merges the economics (low), flow (medium) and permissions (medium and low) /chain findings. RULES.md 1.1: 'A reading time later than the snapshot time is a conflict, noted in conflicts, whatever that reading's health.' networkStatus applies that check to faucet /health, the gateway and the RPC head, but never reads chain.fetched_at, chain.http or chain.error. The shipped fixtures/status-healthy/faucet-chain.json has fetched_at 2026-11-09T23:47:52Z, 11 s after meta.now 2026-11-09T23:47:41Z. The status, and outputs/healthy/status.json from npm run check, still show conflicts [] and reset_detected false. The consistency step passes because every output agrees on the same unchecked value. The same 11 s skew on faucet-health.json produces the conflict and a degraded faucet. A body carried on HTTP 500, on http null with an error (no response), or on a 39-day-old reading also counts as the faucet reporting the pinned genesis. Impact: reset_detected false and an empty conflicts list from a /chain reading the rules treat as a conflict, or that is not a current report. A real reset still needs the RPC to report the pinned genesis too, which limits this to medium. Fix: take genesis.faucet from chain.body only when chain.http === 200 and chain.error is null; otherwise use null, so reset_detected is null. Run the applyFreshness time checks on chain.fetched_at, so a future time is a conflict and an old or unparsable time leaves the faucet genesis unknown or noted. Correct the status-healthy fixture.","line":244,"path":"observatory/src/status.ts","reproduction":"node dist/cli.js status --fixtures fixtures/status-healthy -> reset_detected false, conflicts [] (expected: conflict 'faucet /chain reading time 2026-11-09T23:47:52Z is later than the snapshot time 2026-11-09T23:47:41Z'). Control: copy status-healthy, set faucet-health.json fetched_at to 2026-11-09T23:47:52Z -> conflicts ['faucet reading time 2026-11-09T23:47:52Z is later than the snapshot time 2026-11-09T23:47:41Z'], faucet degraded. Variants on a copy of status-healthy, changing only faucet-chain.json: {http:500,error:'server error'} -> reset_detected false, conflicts []; {http:null,error:'timeout',fetched_at:null} with the body kept -> reset_detected false, conflicts []; fetched_at 2026-10-01T00:00:00Z -> reset_detected false, conflicts []; fetched_at 2026-12-01T00:00:00Z -> reset_detected false, conflicts [].","severity":"medium","snippet":"  const rawFaucetGenesis = chain.body && typeof chain.body === \"object\" ? chain.body.genesisHash : undefined;","title":"The faucet /chain reading that supplies half of reset_detected is never checked for HTTP status, response or reading time; a future-dated reading raises no conflict (the shipped healthy outputs show t"},{"citation":"resolved","description":"Reproduced by running. Merges the economics and flow findings. Clause 15 says every entry under event-pages must be a page file, and that anything else refuses the census and leaves escrows unverified. When event-pages itself is a regular file, FixtureSource.fromDir (source.ts:125-127: existsSync, then readdirSync) throws ENOTDIR. The CLI exits 2 with the raw OS message for both census and escrow, rather than a refusal or an unverified result with a note. recount.py checks only os.path.isdir, treats the scenario as having no pages and prints status partial with exit 0. compare-recount.mjs would call this disagree-on-refusal, but no fixture exercises it. It does not show a partial count as complete, but the independent witness is more lenient than the rules. Fix: in recount.py raise Unreadable when os.path.lexists(pages_dir) and not os.path.isdir(pages_dir). In source.ts record 'event-pages: not a directory' in loadErrors and pageErrors.","line":68,"path":"observatory/tools/recount.py","reproduction":"Scenario dir $T/x with the meta.json from the event-field finding; echo '{}' > $T/x/event-pages. node dist/cli.js census --fixtures $T/x -> exit 2, \"flop-observatory: ENOTDIR: not a directory, scandir '$T/x/event-pages'\" (escrow gives the same). python3 -I tools/recount.py --dir $T/x --genesis 0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631 --from 100 --to 199 -> exit 0, {\"complete\": false, \"status\": \"partial\", \"covered\": [], \"gaps\": [{\"from_block\": 100, \"to_block\": 199}], ...}. Expected: both refuse with the same reason.","severity":"low","snippet":"    if os.path.isdir(pages_dir):","title":"When event-pages is a regular file, the TypeScript loader crashes with a raw ENOTDIR while tools/recount.py prints a partial census with exit 0; the two implementations disagree on refusal"},{"citation":"resolved","description":"Reproduced by running (economics specialist). Without a terminal event, two distinct finalized opens for one id leave the escrow unverified with 'more than one distinct finalized open for this escrow' (line 387). That branch is reached only when there is no release or refund, though. With one finalized release, lines 372-377 report claimed, drop the opens from the evidence, and add no note. The same conflicting evidence is judged conflicting or clean depending on whether a terminal event is present. RULES section 2 asks for unverified with a note on conflicting evidence: 'More conflicting evidence never gives a more confident answer'. Low: claimed still rests on a finalized release that is in the input. Fix: test opened.length > 1 next to the release and refund multiplicity checks (lines 368-369), or at least add a note on the claimed and refunded paths.","line":377,"path":"observatory/src/escrow.ts","reproduction":"Scenario dir with the meta.json from the event-field finding. The page covers blocks 100-199 and has three finalized events naming 0xa1..a1: EscrowOpened at block 110 (hash 0xaa*32, index 0), EscrowOpened at block 120 (hash 0xbb*32, index 0) and EscrowReleased at block 130 (hash 0xcc*32, index 0). node dist/cli.js escrow --fixtures <dir> -> claimed, evidence [EscrowReleased 130], no note about the two opens. Without the release -> unverified, 'conflicting evidence: more than one distinct finalized open for this escrow'. The census and recount count escrows_opened 2.","severity":"low","snippet":"        evidence = [...(opened.length === 1 ? opened : []), ...released];","title":"Two distinct finalized EscrowOpened events for one id are a conflict only when no terminal event exists; with a finalized release or refund the duplicate opens are dropped silently and the escrow is c"},{"citation":"resolved","description":"Reproduced by running (permissions specialist). RULES section 4: 'Tests fail if any attempt is left in the log'. setup.ts reads attemptLog() once, in an afterAll hook that runs last in stack order. Anything recorded later, such as a blocked fetch from a setTimeout scheduled in a test file's own afterAll or an unawaited retry, is never written to OBS_ATTEMPTS_FILE and fails nothing. The canary covers only attempts made in a test body or earlier hooks. No src module in M0 schedules network work, so there is no current leak. The exposure is to future transports or tests with timers or background retries (M1). Fix: make recordAttempt append to OBS_ATTEMPTS_FILE synchronously, so check.sh fails on a non-empty file whatever the timing, and/or check again from a vitest globalTeardown or a process 'beforeExit' handler.","line":104,"path":"observatory/test/setup.ts","reproduction":"In a clean clone at bce10dcf, add observatory/test/zz-timer2.test.ts: import { afterAll, expect, it } from 'vitest'; it('t', () => { expect(1).toBe(1); }); afterAll(() => { setTimeout(() => { try { fetch('https://canary.invalid/fund'); } catch {} process.stderr.write('TIMER2 FIRED\\n'); }, 0); }); Run OBS_ATTEMPTS_FILE=/tmp/att.jsonl npx vitest run test/zz-timer2.test.ts -> stderr 'TIMER2 FIRED', 'Test Files 1 passed', exit 0, /tmp/att.jsonl not created. Control: call the fetch directly inside that afterAll -> 'Error: unexpected network, write, sign or spend attempts: [{\"kind\":\"network\",\"what\":\"fetch https://canary.invalid/fund\"...}]', exit 1.","severity":"low","snippet":"afterAll(() => {","title":"The read-only harness checks the attempt log once, in afterAll; a forbidden call fired from a timer after that hook is never recorded or failed"},{"citation":"resolved","description":"Reproduced by running (flow specialist). setup() copies the whole repository root, minus SKIP (.git, node_modules, check-runs, dist, out) and .build-* names, with no restriction to tracked files. Any untracked file the user cannot read makes cpSync throw before the first control. The exception is uncaught, mutants.json stays empty, and mutate-canary reports every outcome as missing. This fails closed (red), not a false pass. A related point found by reading: untracked files (for example a stray test file) are copied into the mutation sandbox and can change what kills a mutant, while the evidence is labelled with the commit sha. Fix: copy only `git ls-files` paths, and report a setup failure as a structured outcome.","line":82,"path":"observatory/scripts/mutate.mjs","reproduction":"cd <clone> && touch .unreadable && chmod 000 .unreadable && cd observatory && node scripts/mutate.mjs; echo $? -> exit 1, stderr \"Error: EACCES: permission denied, copyfile '<clone>/.unreadable' -> '/tmp/obs-mutate-XXXX/repo/.unreadable'\", stdout empty. Without the file, the same clone gives 159/159 killed.","severity":"low","snippet":"  cpSync(repo, copy, {","title":"mutate.mjs copies every entry of the repository root, tracked or not; one unreadable untracked file aborts the mutation step with an uncaught EACCES and turns mutants and mutants-canary red"},{"citation":"resolved","description":"Reproduced by running (math specialist). toRational() rebuilds the fraction from String(v), the shortest form of the parsed double, not from the literal text. Any literal with more than about 17 significant digits is silently rounded. 1200000.000000000000001 FLOP (decimals 18) is 1200000000000000000000001 base units, which differs from a runtime of 1200000000000000000000000, yet drift reports match. 1.0000000000000000001 FLOP is not a whole number of base units, so it should be unknown, but it reports match against 10^18. This gives a wrong drift status only; no escrow, health or reset effect. Fix: parse decimal scalars from their source text, or report unknown when String(v) differs from the source literal.","line":90,"path":"observatory/src/drift.ts","reproduction":"Scenario dir with meta.json {\"scenario\":\"drift-audit\",\"description\":\"x\",\"now\":\"2026-11-10T00:00:00Z\",\"synthetic\":true,\"params_spec\":\"spec.yaml\"}, runtime-constants.json {\"observed_at\":\"2026-11-10T00:00:00Z\",\"runtime_spec_version\":1,\"values\":{\"pal.a\":\"1000000000000000000\",\"pal.b\":\"1200000000000000000000000\"}}, and spec.yaml with meta {version: 1, decimals: 18} and parameters p_tiny_fraction (on_chain constants [Pal.A], value 1.0000000000000000001, unit FLOP) and p_mismatch (on_chain constants [Pal.B], value 1200000.000000000000001, unit FLOP). node dist/cli.js drift --fixtures <dir> -> both items: spec 1 / 1200000, expected 1000000000000000000 / 1200000000000000000000000, status match. Expected: unknown, and mismatch.","severity":"low","snippet":"  const m = /^(-?)(\\d+)(?:\\.(\\d+))?(?:e([+-]?\\d+))?$/i.exec(String(v));","title":"Drift rounds YAML decimal values to a double before the 'exact' BigInt comparison, so a spec value that is 1 base unit off the runtime reports match"},{"citation":"resolved","description":"Reproduced by running (permissions specialist). A finalized EscrowReleased above the finalized height is counted only as 'N event above the finalized height ignored', and the open alone gives funded. The same happens with fixtures/status-stale-rpc (rpc.health degraded, head an hour old), because snapshot.ts:45 applies the height whatever the RPC health. RULES section 2 allows funded without a terminal event, so this matches the letter of the spec: funded is true as of the bounded height. However, the page and the RPC disagree about the finality of a terminal event that names this escrow, and a consumer reading escrows.json sees funded for an escrow the retained page says was released. The out-of-range rule (lines 313-321) treats the same shape as a conflict. Fix: treat a finalized terminal copy above the height as conflicting evidence (unverified with a note), or at least name the ignored release or refund in the note.","line":328,"path":"observatory/src/escrow.ts","reproduction":"Scenario dir with meta.json {\"scenario\":\"esc-above\",\"description\":\"\",\"now\":\"2026-11-09T23:47:41Z\",\"synthetic\":true,\"escrow_ids\":[\"0xe0..e0\"],\"pinned_genesis\":\"0x99c1...2631\"} and one page on that genesis covering blocks 0-4000000, holding a finalized EscrowOpened at block 3341000 (hash 0xab*32, index 0) and a finalized EscrowReleased at block 3341580 (hash 0xcd*32, index 0), both with data {\"0\":\"0xe0..e0\"}. node dist/cli.js snapshot --status fixtures/status-healthy --escrows <dir> --out <o> -> escrows[0] state funded, finalized_height 3341575, evidence [hasStation.EscrowOpened], notes ['1 event above the finalized height ignored']. With --status fixtures/status-stale-rpc the result is the same.","severity":"low","snippet":"    if (height !== null && copies.some((c) => c.block_number !== null && c.block_number > height)) {","title":"A release or refund the page marks finalized but that lies above the RPC finalized height is dropped, and the escrow is shown funded, unlike the out-of-range case, which makes it unverified"},{"citation":"resolved","description":"Reproduced by running; found in this review, not reported by the specialists. RULES section 5 requires the page, the feeds and MCP to agree on every health value, reset_detected, conflicts and every escrow state. scripts/consistency.mjs finds cells with regexes over the raw HTML (line 114 for data-k cells, line 159 for rows) and never strips <!-- --> comments or looks at hidden, style or class visibility. A page.ts change that wraps a row in a comment, or adds hidden or style=\"display:none\" to a <tr>, removes the value from what a reader sees, while the checker still counts the cell as rendered and agreeing. The consistency canary has no tamper of this kind. Its existing tampers (dropping a row, a duplicate cell, text differing from data-v, an unmarked cell, a swapped label) are all caught, as verified. This is evidence honesty: the step can report a pass for a page that hides the reset flag. The shipped page is fine (it has no comments). Fix: strip comments before matching (or parse with a real HTML parser), fail on hidden, style or inline display on rows and cells, and add these tampers to consistency-canary.mjs.","line":114,"path":"observatory/scripts/consistency.mjs","reproduction":"After npm run check, take O=check-runs/<sha>/<run>/outputs and copy O/healthy to a temp dir three times. (a) In index.html wrap the first '<tr><th scope=\"row\" data-for=\"status.reset_detected\">...</tr>' in <!-- -->. (b) Wrap the first escrow '<tr><th scope=\"row\" data-for=\"escrow.<id>.state\">...</tr>' in <!-- -->. (c) Change the '<tr>' before '<th scope=\"row\" data-for=\"status.conflicts\">' to '<tr style=\"display:none\">' (or '<tr hidden>'). Run node scripts/consistency.mjs <dir> on each -> exit 0, {\"checks\":1107,\"keys\":200,\"failures\":[]}. Controls on the same copy, each caught with exit 1: deleting that row, changing its visible text to 'true' while keeping data-v=\"false\", and adding a duplicate cell. Expected: (a)-(c) fail, because the value is not shown.","severity":"low","snippet":"for (const m of html.matchAll(/<([a-z][a-z0-9]*)\\b([^>]*\\bdata-k=\"[^\"]*\"[^>]*)>/g)) {","title":"consistency.mjs reads the page with regular expressions and ignores HTML comments and hidden rows, so a page that does not show reset_detected, conflicts or an escrow state passes the check"},{"citation":"resolved","description":"Reproduced by running; found in this review. RULES section 4: 'A source scan fails any src module, other than guard.ts, that ... calls fetch, WebSocket ...'. The patterns are /\\bfetch\\s*\\(/ and /new\\s+WebSocket\\b/, so a call that does not put '(' straight after the word fetch, or does not put 'WebSocket' straight after 'new', passes the scan. Examples are fetch.call(globalThis, u), const f = globalThis.fetch; f(u), and new globalThis.WebSocket(u). The current src has no network path: grep finds no fetch, WebSocket, child_process or node:net/http use outside guard.ts. The runtime block in test/setup.ts would also catch such a call, but only on a path a test executes. This is a gap in the static guarantee that M1 transports will rely on, not a live network path. Fix: match the bare identifiers (/\\bfetch\\b/ and /\\bWebSocket\\b/) in src, since src has no legitimate use, and add these forms to the scan's own sample list.","line":131,"path":"observatory/test/guard.test.ts","reproduction":"In a scratch clone at bce10dcf append to observatory/src/feed.ts:\nexport const leak = (u: string) => fetch.call(globalThis, u);\nexport const leak2 = (u: string) => new globalThis.WebSocket(u);\nconst f = globalThis.fetch;\nexport const leak3 = (u: string) => f(u);\nThen run npx vitest run test/guard.test.ts -> 'Tests 50 passed (50)'; the 'feed.ts has no network, process, signing, spending or key handling' case passes. Control: also appending export { request as leak4 } from \"node:https\"; makes that case fail ('feed.ts matches /from\\s*[\"'](?:node:)?(?:http|https|...)/'). Expected: the scan fails on the fetch and WebSocket calls too.","severity":"low","snippet":"    /\\bfetch\\s*\\(/,","title":"The src source scan misses fetch and WebSocket calls that RULES section 4 says it fails: fetch.call(...), an aliased globalThis.fetch, and new globalThis.WebSocket(...) all pass"},{"citation":"resolved","description":"Reproduced by running (economics specialist). Both outcomes are conservative: the event is never evidence, and the escrow stays unverified. Only the classification and notes differ. The escrow reports 'malformed event ignored', while the census skips the event as out of range with malformed_dropped 0. Fix: drop the n < 0 test and let the page range check classify the event.","line":101,"path":"observatory/src/escrow.ts","reproduction":"A page covering blocks 100-199 with one finalized EscrowReleased for 0xa1..a1 at block_number -5. node dist/cli.js escrow -> unverified, 'malformed event ignored: hasStation.EscrowReleased (block_number is not a whole number or null)'. node dist/cli.js census and recount -> malformed_dropped 0, with the event skipped as outside the range.","severity":"info","snippet":"  if (n !== null && n !== undefined && (typeof n !== \"number\" || !Number.isInteger(n) || n < 0)) {","title":"A negative integer block_number is malformed in escrow.ts but a well-formed, out-of-range event in census.ts and recount.py, although clause 7 says the escrow rules read events the same way"},{"citation":"resolved","description":"Reproduced by running. This machine has Node v24.21.0 and Python 3.12.3; Node 22 was not available, so the Node 22 claim itself was not run. npm run check on a fresh clone at bce10dcf: all 27 steps exit 0, 485 tests passed, 159 of 159 mutants killed, no attempts left. The outputs digest was d7554801e2bf2c3d99ab65bd26d78d77afc646ca6be6df57e7c68817cf1ef7e4, not decd3847...c372. 'v24.21.0' appears in healthy/{snapshot.json,snapshots.jsonl,pin.json,mcp-transcript.jsonl} and the same files in every other scenario, and it feeds the snapshot hash chain. The difference is explained by the Node version alone and is not a regression, but the digest cannot be checked on any other Node patch release. Fix: document the digest as specific to Node v22.23.4, or also publish a digest that leaves pin.node out.","line":69,"path":"observatory/src/snapshot.ts","reproduction":"cd observatory && npm run check; cd check-runs/<sha>/<run>/outputs && find . -type f | LC_ALL=C sort | xargs sha256sum | sha256sum -> d7554801e2bf2c3d99ab65bd26d78d77afc646ca6be6df57e7c68817cf1ef7e4 on Node v24.21.0; grep -rl v24 . lists the four files per scenario.","severity":"info","snippet":"    node: process.version,","title":"The documented outputs digest only holds on the exact Node version: pin.node embeds process.version in snapshot.json, snapshots.jsonl, pin.json and the MCP transcript of every scenario"}],"hash":"26536f320d69a4f62437cf32c6dd5614751954465763ed41d70b0df0445d1618","nodeId":"56e78b89-74d9-4603-9082-b255ac56c1dd","outcome":"completed","summary":"I found no critical or high issues. 15 findings are in `.imd-findings.json`: 5 medium, 8 low and 2 info, each with file, line, snippet and an exact reproduction. All were reproduced by running, in a fresh clone at `bce10dcf` under `/tmp`. The repository is unchanged. No FLOP endpoint was contacted.\n\n**The run.** This machine has Node v24.21.0 and Python 3.12.3. I could not run on Node 22, so the Node 22 claim itself is unconfirmed. On Node 24, `npm run check` gave all 27 steps exit 0, 485 tests passing, 159 of 159 mutants killed and no attempts left in the log. The digest was `d7554801…f7e4` instead of `decd3847…c372`. The cause is the Node version, which is recorded in `pin.node` and written into each scenario's `snapshot.json`, `snapshots.jsonl`, `pin.json` and MCP transcript (info finding).\n\n**Medium findings**\n1. **Event fields above 2^53−1** (`escrow.ts:99`): an event the census and recount drop as malformed still makes an escrow `funded` or `claimed`, with the index silently rounded. This is a wrong escrow state as the rules define it.\n2. **Page fields above 2^53−1** (`escrow.ts:108`): the census refuses the page, but escrows on it still come out `claimed`, `refunded` or `funded`. Wrong escrow state.\n3. **Invalid UTF-8 page file** (`source.ts:113`): TypeScript decodes it lossily and reports a complete census and a `claimed` escrow; the Python recount refuses the file. The two disagree, and a file the rules say to refuse is counted as complete.\n4. **Rollback checks never run** (`cli.ts:151`): the snapshot command never passes the previous log entry, so the rollback, changed-hash and stuck-height checks of RULES 1.1 never run. After a 1,575-block finalized rollback, RPC health still reads `ok`. Wrong health value, not a wrong reset flag.\n5. **Faucet `/chain` reading unchecked** (`status.ts:244`): its HTTP status and time are never looked at. The shipped healthy fixture's `/chain` reading is 11 s later than the snapshot time, which RULES 1.1 says is a conflict, yet the outputs show no conflict. A 500 response, no response, or a 39-day-old reading all still give `reset_detected: false`. The RPC must also report the pinned genesis, so this stays medium.\n\n**Low findings**\n- Specialist findings that held up:\n  - `event-pages` as a file: TypeScript crashes, Python returns a partial census.\n  - Two distinct opens are not flagged when a release exists.\n  - A finalized release above the RPC finalized height leaves the escrow `funded`.\n  - The test harness misses an attempt fired from a timer after its single `afterAll` check.\n  - `mutate.mjs` aborts with EACCES on an unreadable untracked file.\n  - Drift rounds decimals, so a spec value 1 base unit off reports `match`.\n- Two that I found myself:\n  - `consistency.mjs` passes a page where the `reset_detected`, conflicts or escrow-state row is commented out or hidden.\n  - The source scan misses `fetch.call(...)`, an aliased `globalThis.fetch`, and `new globalThis.WebSocket(...)`. No src module currently has a network path.\n\n**Info:** a negative `block_number` is classified differently by the escrow module and the census (no state effect), plus the digest note above.\n\nThe specialists' reports overlapped heavily: their 25 findings collapse into the 13 kept above, and none was dropped as not reproducing.\n\n**Areas with nothing at medium or above:**\n- **Read-only (area 4):** no network, process, sign or spend path in src.\n- **Census against recount:** they agreed on 450 random adversarial page sets and 28 raw-JSON edge cases (number spellings, BOM, NaN, duplicate keys, surrogates), apart from findings 1 and 3.\n- **Mutation scoring:** a mutant counts as killed only when a test fails or the harness rejects a leftover attempt.\n- **Page and feed agreement:** dropped rows, mismatched text, duplicate or unmarked cells and swapped labels are all caught.\n\nCoverage limits: I used the Pashov specialties as passes of my own; no helper agents ran. Foundry proofs don't apply because there is no Solidity","treeHash":null,"usage":{"cachedInputTokens":3893711,"cost":{"source":"runtime","usdMicros":2682538},"inputTokens":77,"model":"claude-opus-5-5","outputTokens":43506,"runtime":"claude","turns":41,"wallClockMs":827887}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"9fbeb473fba88fbd","findings":[{"citation":"resolved","description":"pageProblem() (escrow.ts:112-125) validates page integer fields with isInt() at line 107-109, which uses Number.isInteger. RULES.md 3.1 clause 14 says an integer 'must also be safe: from -(2^53 - 1) to 2^53 - 1. A larger value is not an integer, so a page field holding one refuses the census', and section 2 (lines 86-88) says a page that is structurally invalid per census clause 12 makes every escrow unverified with a note. census.ts isInt() (line 105-107) correctly uses Number.isSafeInteger, so the two modules disagree on the same file: the census refuses (exit 2) while the escrow command returns definite states claimed, refunded and funded from the page the rules call invalid. Can cause a wrong escrow state as defined by the spec: a definite state from a page that must leave every escrow unverified. Found by reading, reproduced by running. Fix: use Number.isSafeInteger in escrow.ts isInt() (and structureProblem) so pageProblem matches census checkPage.","line":108,"path":"observatory/src/escrow.ts","reproduction":"Scenario dir with meta.json {\"now\":\"2026-11-10T00:00:00Z\",\"pinned_genesis\":\"0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631\",\"escrow_ids\":[E1,E2,E3],\"census_request\":{\"from_block\":0,\"to_block\":199}} and event-pages/000.json = the escrows-verification page with \"to_block\": 9007199254740993 (or 1e300) and a third finalized hasStation.EscrowOpened for E3 (block_hash 0xab..ab, index 0, block_number 5, data {\"0\":E3}). Commands: node dist/cli.js escrow --fixtures <dir>; node dist/cli.js census --fixtures <dir>; python3 -I tools/recount.py --dir <dir> --genesis 0x99c1... --from 0 --to 199. Expected (RULES.md section 2 lines 86-88, clause 14): all three escrows unverified with a note naming the page. Actual: escrow prints E1 claimed, E2 refunded, E3 funded with evidence; census prints 'census refused: event page 0: to_block is missing or not a safe integer' (exit 2); recount prints '000.json: to_block missing or not an integer' (exit 2).","severity":"medium","snippet":"  return typeof value === \"number\" && Number.isInteger(value);","title":"Escrow accepts a structurally invalid page (unsafe integer to_block) and reports claimed, refunded and funded where RULES.md section 2 and clause 14 require every escrow unverified; the census refuses"},{"citation":"resolved","description":"structureProblem() checks index (line 99) and block_number (line 101) with Number.isInteger, not Number.isSafeInteger. RULES.md 3.1 clause 14 says a value larger than 2^53-1 'is not an integer, so ... an event field holding one makes the event malformed. This holds for every integer field in these rules (index, block_number, ...)' and clause 7 says 'The escrow rules read events the same way'. Section 2 says a malformed event 'can never make an escrow look funded, claimed or refunded'. The census (census.ts readEvent/isInt) counts the same event in malformed_dropped, so the escrow and census disagree about the same event. Can cause a wrong escrow state as defined by the spec (funded from an event the rules define as malformed). Note too that JSON indices above 2^53 collapse in JavaScript (9007199254740992 and 9007199254740993 parse to the same number), so two distinct chain events would share one (genesis, block_hash, index) key in escrow.ts. Found by reading, reproduced by running. Fix: use Number.isSafeInteger at lines 99 and 101.","line":99,"path":"observatory/src/escrow.ts","reproduction":"Scenario as in the previous finding but with \"to_block\": 10 and the EscrowOpened for E3 given \"index\": 1e300 (finalized true, block_number 5, data {\"0\":E3}). node dist/cli.js escrow --fixtures <dir> -> E3 state funded with evidence hasStation.EscrowOpened (expected: unverified, 'malformed event ignored'). node dist/cli.js census --fixtures <dir> and python3 -I tools/recount.py ... --from 0 --to 199 both report escrows_opened 0 and count the event in malformed_dropped.","severity":"medium","snippet":"  if (typeof index !== \"number\" || !Number.isInteger(index) || index < 0) return \"index is not a whole number\";","title":"Escrow treats an event with an unsafe integer index (or block_number) as well formed evidence and reports funded; RULES.md clause 14 and clause 7 call it malformed and the census drops it"},{"citation":"resolved","description":"FixtureSource.fromDir reads page files with readFileSync(path, 'utf8'), which silently replaces invalid byte sequences with U+FFFD, and then JSON.parse succeeds. RULES.md clause 12 says a page file that 'cannot be read or parsed as standard JSON (RFC 8259 ...)' refuses the census and leaves every escrow unverified; RFC 8259 section 8.1 requires JSON text to be UTF-8 encoded. tools/recount.py opens the file with encoding='utf-8' (strict), gets a UnicodeDecodeError (a ValueError) and exits 2 with 'cannot read ... invalid start byte'. So a corrupted or non-UTF-8 page yields a census marked complete, with a corrupted by_account key, and definite escrow states in TypeScript, where the rules and the independent recount both refuse. The built-in cross-check does not cover this because no fixtures/census-* scenario holds such a file. Impact: a count shown as complete from an unreadable page; TypeScript and Python disagree. Found by reading, reproduced by running. Fix: decode with new TextDecoder('utf-8', { fatal: true }) (or check the buffer with Buffer.isUtf8) before JSON.parse and treat a decode failure like a parse failure (push to loadErrors and pageErrors).","line":113,"path":"observatory/src/source.ts","reproduction":"Scenario dir with meta.json as in the first finding and event-pages/000.json = a census-complete style page for blocks 0-199 holding one finalized hasStation.EscrowOpened whose data is {\"0\":E3,\"who\":\"ALICE<0xFF>\"} where <0xFF> is the single raw byte 0xFF (not valid UTF-8). node dist/cli.js census --fixtures <dir> -> exit 0, status complete, totals escrows_opened 1, by_account {\"ALICE\\ufffd\": {escrows_opened: 1}}, malformed_dropped 0. node dist/cli.js escrow --fixtures <dir> -> E3 funded. python3 -I tools/recount.py --dir <dir> --genesis 0x99c1... --from 0 --to 199 -> exit 2, stderr \"cannot read .../000.json: 'utf-8' codec can't decode byte 0xff in position 440: invalid start byte\". Expected: both refuse (census refused, every escrow unverified).","severity":"medium","snippet":"        return JSON.parse(readFileSync(path, \"utf8\")) as T;","title":"TypeScript census counts a page file that is not valid UTF-8 (so not standard JSON) as a complete census, while the Python recount refuses it; the two implementations disagree on the same input"},{"citation":"resolved","description":"RULES.md 1.1 (lines 43-44) says RPC health ok requires, 'when there is a previous snapshot on the same genesis: no rollback, no changed finalized hash, and no finalized height stuck for longer than the stale limit'. classifyRpc() implements these checks only when StatusOptions.previous is supplied (status.ts:178-194) and buildSnapshot forwards o.previous (snapshot.ts:34). The snapshot command, even with --log where the previous snapshot is on disk (appendSnapshot reads it at store.ts:54), never sets previous (cli.ts:149-152), so the check silently cannot run and health is still 'ok'. Section 1.1 says a check that could not run never yields ok ('ok needs every check below to run and pass'). This yields a wrong health value and a missing conflict: a chain that rolled back by 75 finalized blocks between two logged snapshots is shown healthy. The unit tests (test/status.test.ts:98-113) exercise classifyRpc with previous directly, so nothing covers the CLI wiring. Found by reading, reproduced by running. Fix: in the snapshot command, read the last line of --log (readSnapshots) and pass previous: { genesis: last.status.genesis.rpc, finalized_height: last.status.finalized_height, finalized_hash: last.status.finalized_hash, observed_at: last.status.rpc.observed_at ?? last.observed_at } when those are non-null; or mark rpc health unknown/degraded with a note when a log exists but no previous was applied.","line":151,"path":"observatory/src/cli.ts","reproduction":"S1 = copy of fixtures/status-healthy (finalized 3341575). S2 = copy with rpc-head.json finalized.number 3341500, finalized.hash 0x77..77, best_number 3341502, observed_at 2026-11-09T23:48:40Z, meta.now 2026-11-09T23:48:41Z and fetched_at of the three HTTP fixtures set to 2026-11-09T23:48:40Z. Run: node dist/cli.js snapshot --status S1 --out o1 --log log.jsonl; node dist/cli.js snapshot --status S2 --out o2 --log log.jsonl. Expected for seq 1 (same genesis as seq 0): rpc.health degraded, conflicts ['finalized height went backwards from 3341575 to 3341500 on the same genesis']. Actual (log.jsonl line 2): seq 1, prev_hash set, finalized_height 3341500, rpc.health 'ok', stale false, conflicts [].","severity":"medium","snippet":"      { pinnedGenesis: pinned, now, sourceSha: one(a, \"--source-sha\") ?? null },","title":"The snapshot command never passes the previous snapshot to networkStatus, so the RULES.md 1.1 rollback, changed-finalized-hash and stuck-height checks never run through the CLI: a finalized height tha"},{"citation":"resolved","description":"Run on this machine (Node v24.21.0, Python 3.12.3): every step exit 0, 485/485 tests passed, recount 13 agree + 7 both-refused of 20 fixtures, mutants 159 killed, no attempts.jsonl. The digest over outputs/ was d7554801e2bf2c3d99ab65bd26d78d77afc646ca6be6df57e7c68817cf1ef7e4, not the expected decd38476868f2cd55209326f74de27f33214b404a2deaf69b66cadb85d3c372. The run pin stores process.version, which is part of snapshot.json, snapshots.jsonl, pin.json and the get_snapshot_meta MCP transcript in each of the five scenario outputs, so the digest changes with any Node patch release and the README expectation cannot be checked except on v22.23.4. Not a correctness defect; reported so the difference is explained rather than taken as a regression. The requested Node 22 run was not possible on this machine, so the digest claim remains unverified here. Fix: document the digest as Node-version-specific, or publish a second digest computed with pin.node excluded (for example over the four feed files only).","line":69,"path":"observatory/src/snapshot.ts","reproduction":"cd observatory && npm run check; cd check-runs/<sha>/<run>/outputs && find . -type f | LC_ALL=C sort | xargs sha256sum | sha256sum -> d7554801e2bf2c3d99ab65bd26d78d77afc646ca6be6df57e7c68817cf1ef7e4 on Node v24.21.0; grep -l v24.21.0 */pin.json lists all five scenarios.","severity":"info","snippet":"    node: process.version,","title":"The documented output digest is only reproducible on the exact Node patch version: pin.node embeds process.version into five output files, so npm run check on Node v24.21.0 gives a different digest al"},{"citation":"resolved","description":"parseSpec() uses intAsBigInt so integers never round, but YAML floats still arrive as IEEE doubles and toRational() rebuilds the fraction from String(v), the shortest round-trip form of the double, not from the literal in the file. drift.ts lines 11-14 promise 'match or mismatch only from exact integer arithmetic (BigInt)' and 'unknown ... when scaling leaves a fraction'. A literal with more than about 17 significant digits is silently rounded first: 1.0000000000000000001 FLOP (decimals 18, which is 10^18 + 0.1 base units, not a whole number) becomes 1 and the item reports match against a runtime of exactly 1000000000000000000; the item's spec field shows 1. The same rounding could turn a true mismatch into a match when the discrepancy sits beyond the 17th digit of a FLOP amount with 18 decimals (for example 1200000.000000000000001 FLOP). Impact: a wrong drift status; no escrow, health or reset effect. Found by reading, reproduced by running. Fix: parse float scalars from their source text (the yaml library exposes the scalar source via a custom tag/resolver or by walking the CST), or treat any float whose String() form differs from its source text as unknown.","line":90,"path":"observatory/src/drift.ts","reproduction":"Scenario dir with meta.json {\"scenario\":\"drift-audit\",\"description\":\"x\",\"now\":\"2026-11-10T00:00:00Z\",\"synthetic\":true,\"params_spec\":\"spec.yaml\"}, runtime-constants.json {\"observed_at\":\"2026-11-10T00:00:00Z\",\"runtime_spec_version\":1,\"values\":{\"pal.a\":\"1000000000000000000\"}} and spec.yaml:\nmeta:\n  version: 1\n  decimals: 18\nparameters:\n  - name: p_tiny_fraction\n    on_chain: {constants: [Pal.A]}\n    value: 1.0000000000000000001\n    unit: FLOP\nRun node dist/cli.js drift --fixtures <dir>. Expected: status unknown with note 'the expected value is not a whole number of base units' (1.0000000000000000001 x 10^18 = 1000000000000000000.1). Actual: spec 1, expected \"1000000000000000000\", status match.","severity":"low","snippet":"  const m = /^(-?)(\\d+)(?:\\.(\\d+))?(?:e([+-]?\\d+))?$/i.exec(String(v));","title":"Drift: a non-integer YAML spec value is rounded to a double before the 'exact' BigInt arithmetic, so a spec value whose base-unit amount is not whole can report match (and the spec column shows the ro"}],"hash":"4fefe3d04b5deced1c66c0ae671aa315169e9fcba004c26238919ea65df6911d","nodeId":"3ca240dc-d90d-477a-83a7-6b8f3cb140a8","outcome":"completed","summary":"The review is complete. Findings are written to `.imd-findings.json` in the repository root, and the working tree is unchanged.\n\n**What I ran.** `npm run check` at commit bce10dc on Node v24.21.0 and Python 3.12.3 (this machine has no Node 22). All 27 steps exited 0, 485 of 485 tests passed, 159 of 159 mutants were killed, the recount cross-check agreed on 13 fixtures and both sides refused the other 7, and no `attempts.jsonl` was left. The output digest was `d7554801…`, not the expected `decd3847…`. The cause is the run pin, which embeds the Node version into five output files, so the digest cannot match on any other Node release. The Node 22 digest claim stays unverified here.\n\n**Findings, all reproduced by running** (inputs, commands and outputs are in the file):\n\n- **Medium, escrow.ts:108.** `pageProblem` validates page integers with `Number.isInteger`, not `isSafeInteger`. A page with `to_block` 9007199254740993 or 1e300 is refused by the census and by the recount, but the escrow command reports claimed, refunded and funded from it. RULES.md section 2 says such a page leaves every escrow unverified.\n- **Medium, escrow.ts:99.** `structureProblem` accepts an event `index` or `block_number` above 2^53. A finalized EscrowOpened with `index` 1e300 yields funded, while both census implementations count it as malformed. Clause 14 and clause 7 define it as malformed.\n- **Medium, source.ts:113.** Page files are read with the lossy `utf8` decoder. A page containing one invalid byte 0xFF gives a complete TypeScript census, a corrupted `by_account` key and a funded escrow. The Python recount refuses it as unreadable, so the two implementations disagree on an input the fixture cross-check does not cover.\n- **Medium, cli.ts:151.** The snapshot command never passes the previous logged snapshot to `networkStatus`, so the rollback, changed-hash and stuck-height checks of RULES.md 1.1 never run through the CLI. Two logged snapshots where the finalized height drops from 3341575 to 3341500 on the same genesis show rpc health ok and no conflict.\n- **Low, drift.ts:90.** YAML float spec values are rounded to doubles before the BigInt arithmetic. A spec value of 1.0000000000000000001 FLOP with 18 decimals reports match against exactly 10^18 instead of unknown.\n- **Info, snapshot.ts:69.** The digest reproducibility note above.\n\n**Areas with nothing at medium or above.** Status boundaries held: age exactly at the stale limit and lag exactly at 30 are ok, one past is degraded, future times conflict, malformed offsets are unknown. Census range arithmetic held on adjacent, overlapping, partially intersecting, broken, cyclic, negative-start and other-genesis chains, with TypeScript and Python agreeing on every field. The mutant classifier and the recount comparison looked sound by reading. No critical or high finding: no input produced a state contradicted by finalized evidence or a false reset flag.\n\n**Not covered in depth.** The read-only guard, test harness and MCP wiring were only read in passing, and the assigned math, boundary and numerical guides took priority. No Solidity is in scope, so no Foundry proofs apply.","treeHash":null,"usage":{"cachedInputTokens":1532756,"cost":{"source":"runtime","usdMicros":6067341},"inputTokens":386,"model":"claude-fable-5-1","outputTokens":48750,"runtime":"claude","turns":33,"wallClockMs":1578606}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"782f24113d209be2","findings":[{"citation":"resolved","description":"Reproduced by running. RULES.md 3.1 clause 14 (binding for 'every integer field in these rules' and explicitly for the escrow rules via clause 7: 'The escrow rules read events the same way') says an integer must be safe (|v| <= 2^53-1); a larger value is not an integer, so an event field holding one makes the event malformed and a page field holding one refuses. Section 2 says a malformed event 'can never make an escrow look funded, claimed or refunded'. src/census.ts:106 uses Number.isSafeInteger, but src/escrow.ts uses Number.isInteger in structureProblem (line 99 for index, line 101 for block_number) and in isInt (line 108, used by pageProblem for from_block/to_block/page/next_page). Number.isInteger(9007199254740992) is true, so an EscrowOpened/EscrowReleased/EscrowRefunded with index or block_number 2^53+1 (parsed as 2^53) is accepted as finalized evidence and sets the state, and its evidence record carries a silently rounded event_index/block_number (9007199254740992). The same page is refused by the census (page field) or counted as malformed_dropped (event field). Trust gap: the escrow module trusts an identity (genesis, block_hash, index) the spec says has no identity; two distinct indexes 2^53 and 2^53+1 collapse into one key. This can cause a wrong escrow state (funded shown where the rules require unverified). Fix: use Number.isSafeInteger in structureProblem and isInt (escrow.ts lines 99, 101, 108), matching census.ts.","line":99,"path":"observatory/src/escrow.ts","reproduction":"Fixture dir $W/esc-unsafe-index with meta.json {\"scenario\":\"x\",\"description\":\"\",\"now\":\"2026-11-10T00:00:00Z\",\"synthetic\":true,\"escrow_ids\":[\"0xe0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0\"],\"pinned_genesis\":\"0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631\",\"census_request\":{\"from_block\":0,\"to_block\":10}} and event-pages/000.json {\"genesis\":\"0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631\",\"from_block\":0,\"to_block\":10,\"page\":0,\"next_page\":null,\"events\":[{\"block_number\":5,\"block_hash\":\"0xabababababababababababababababababababababababababababababababab\",\"finalized\":true,\"index\":9007199254740993,\"section\":\"hasStation\",\"method\":\"EscrowOpened\",\"data\":{\"0\":\"0xe0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0\"}}]}. Run: node dist/cli.js escrow --fixtures $W/esc-unsafe-index -> state \"funded\", evidence [{event hasStation.EscrowOpened, event_index 9007199254740992, ...}], notes only 'evidence is not bounded by a finalized height'. Expected: state \"unverified\" with a 'malformed event ignored' note (clause 14). node dist/cli.js census --fixtures $W/esc-unsafe-index on the same page -> totals all 0, malformed_dropped 1. Variant: page with \"to_block\":9007199254740993 and an event at \"block_number\":9007199254740993 -> escrow command prints funded with block_number 9007199254740992, census exits 2 with 'census refused: event page 0: to_block is missing or not a safe integer'. Expected: escrow result unverified with 'event page is invalid, so nothing is verified'.","severity":"medium","snippet":"  if (typeof index !== \"number\" || !Number.isInteger(index) || index < 0) return \"index is not a whole number\";","title":"Escrow accepts unsafe integers (index, block_number, page bounds) that RULES clause 14 calls malformed, so a malformed event yields funded/claimed/refunded and escrow and census read the same page dif"},{"citation":"resolved","description":"Reproduced by running. RULES.md 1.1 says RPC health is ok only when every check runs and passes, including 'when there is a previous snapshot on the same genesis: no rollback, no changed finalized hash, and no finalized height stuck for longer than the stale limit', and 1.1 lists a backwards height as a conflict. src/status.ts:178 implements this only when opts.previous is given, and src/snapshot.ts:34 forwards o.previous, but the only producer of real snapshots, the `snapshot` command in src/cli.ts (lines 149 to 158), builds the snapshot without reading the last line of the --log file it then appends to (appendSnapshot at line 157 reads that line for seq and prev_hash, so the data is available). The result: in a hash-chained log whose previous entry on the same genesis has finalized_height 3341575, a new snapshot whose RPC head reports finalized 3341500 is published with rpc.health \"ok\", rpc.stale false and conflicts []. A changed finalized hash at the same height, or a finalized height stuck for hours, is likewise never degraded. This is a wrong health value (ok when a check the rules require did not run) and the only unit tests of the check (test/status.test.ts:98-120) pass previous by hand, so no check-step covers the wiring. Fix: in the snapshot command, when --log is given, read the last snapshot (readSnapshots) and pass previous = {genesis: last.status.genesis.rpc, finalized_height, finalized_hash, observed_at} when those are non-null, into buildSnapshot; or else make rpc health unknown and note that the continuity check could not run.","line":151,"path":"observatory/src/cli.ts","reproduction":"cd observatory; W=$TMPDIR/r; cp -r fixtures/status-healthy $W/status-second; edit $W/status-second/rpc-head.json: finalized.number 3341575 -> 3341500, best_number -> 3341502, observed_at -> 2026-11-09T23:48:40Z; edit $W/status-second/meta.json: now -> 2026-11-09T23:48:41Z. Then: node dist/cli.js snapshot --status fixtures/status-healthy --out $W/out1 --log $W/snapshots.jsonl; node dist/cli.js snapshot --status $W/status-second --out $W/out2 --log $W/snapshots.jsonl. Actual log: seq 0 finalized 3341575 rpc.health ok; seq 1 finalized 3341500 rpc.health ok, stale false, conflicts []. Expected for seq 1 (RULES 1.1 and the behaviour of networkStatus when previous is supplied): rpc.health \"degraded\", conflicts [\"finalized height went backwards from 3341575 to 3341500 on the same genesis\"].","severity":"medium","snippet":"      { pinnedGenesis: pinned, now, sourceSha: one(a, \"--source-sha\") ?? null },","title":"The rollback, changed-hash and stuck-height checks are never run by the CLI: snapshot --log appends to a chain but never passes the previous snapshot, so RPC health stays ok after a finalized rollback"},{"citation":"resolved","description":"Reproduced by running. RULES.md 1.1: 'A reading time later than the snapshot time is a conflict, noted in conflicts, whatever that reading's health.' networkStatus applies this (applyFreshness, classifyRpc) to faucet /health, the gateway and the RPC head, but the faucet /chain result, the one reading whose body supplies genesis.faucet and therefore half of reset_detected (RULES 1: false only when both faucet and RPC report the pinned genesis), is read at line 244 with its fetched_at never looked at: no future-time conflict, no staleness, no 'no parsable time'. The shipped fixtures/status-healthy/faucet-chain.json has fetched_at 2026-11-09T23:47:52Z while meta.now (the snapshot time) is 2026-11-09T23:47:41Z, 11 seconds in the future, yet the healthy status, feeds, page and MCP all publish conflicts [] and reset_detected false; the consistency step passes on it because every output agrees on the same unchecked value. Trust gap and asymmetry: the same 11 second skew on faucet-health.json or rpc-head.json is reported as a conflict and lowers health, while on the reading that governs the reset flag it is silent. A faucet /chain reading from days before a reset (same genesis as the pin) would also set reset_detected false with no note while the RPC read is fresh. Because RULES 1 makes reset_detected depend on the faucet's genesis report, callers treat false as 'safe to spend'; a stale or inconsistent report should at least be a conflict. Fix: run the time checks on chain.fetched_at too: a time later than now goes to conflicts; a missing or unparsable time or one older than the stale limit should either make genesis.faucet null (so reset_detected becomes null, 'not enough evidence') or add a conflict, and the status-healthy fixture's fetched_at should not be later than its now.","line":244,"path":"observatory/src/status.ts","reproduction":"cd observatory; node dist/cli.js status --fixtures fixtures/status-healthy | grep -A2 conflicts  -> \"conflicts\": [] and reset_detected false, although fixtures/status-healthy/faucet-chain.json has \"fetched_at\": \"2026-11-09T23:47:52Z\" and fixtures/status-healthy/meta.json has \"now\": \"2026-11-09T23:47:41Z\". Control showing the asymmetry: copy status-healthy, set faucet-health.json fetched_at to 2026-11-09T23:47:52Z -> conflicts contains 'faucet reading time 2026-11-09T23:47:52Z is later than the snapshot time 2026-11-09T23:47:41Z' and faucet.health degraded (test/status.test.ts:348 asserts the same for the gateway). Expected for the /chain reading per RULES 1.1: a conflict entry such as 'faucet /chain reading time 2026-11-09T23:47:52Z is later than the snapshot time 2026-11-09T23:47:41Z'. Stale variant: set faucet-chain.json fetched_at to 2026-11-01T23:47:41Z (8 days old; the fixture status-readings-days-old does exactly this for /chain as well) -> reset_detected false, conflicts [], no note.","severity":"medium","snippet":"  const rawFaucetGenesis = chain.body && typeof chain.body === \"object\" ? chain.body.genesisHash : undefined;","title":"The faucet /chain reading that decides reset_detected is trusted without any reading-time check: a reading later than the snapshot time raises no conflict (the shipped status-healthy fixture has one),"},{"citation":"resolved","description":"Reproduced by running. For faucet /health the rules and classifyFaucet make the HTTP status win over the body ('A body that says ok with any other HTTP status is degraded'; 'only HTTP 200 counts'). For faucet /chain, the reading that supplies genesis.faucet, the body is read whatever the http field says: http 500 with error 'server error', or http null (no response) with error 'timeout', both with a body carrying the pinned genesisHash, give genesis.faucet = pinned and reset_detected false with conflicts []. A proxy or error page returning a cached or default body on a failing faucet is therefore counted as the faucet 'reporting the pinned genesis'. Low because in M0 the HttpResult comes from a fixture and a live transport (M1) may never produce body with http null; still a trust gap in the status module itself, and the asymmetry with /health is not documented in RULES. Fix: take genesis.faucet from chain.body only when chain.http === 200 (otherwise null, so reset_detected is null, 'not enough evidence'), and add a conflict or detail when a non-200 /chain carried a genesis.","line":244,"path":"observatory/src/status.ts","reproduction":"cd observatory; cp -r fixtures/status-healthy $W/s500; in $W/s500/faucet-chain.json set \"http\": 500 and \"error\": \"server error\" (body unchanged). node dist/cli.js status --fixtures $W/s500 -> reset_detected false, genesis.faucet 0x99c172c0..., conflicts []. Same with \"http\": null, \"error\": \"timeout\". Expected: genesis.faucet null and reset_detected null (or true with a conflict), since no 200 response reported the genesis.","severity":"low","snippet":"  const rawFaucetGenesis = chain.body && typeof chain.body === \"object\" ? chain.body.genesisHash : undefined;","title":"A faucet /chain body is trusted for genesis.faucet on a 5xx status or with no HTTP status at all, so reset_detected can read false from an error response"},{"citation":"resolved","description":"Reproduced by running. When a snapshot supplies finalizedHeight (src/snapshot.ts:45 does so whenever the RPC genesis equals the pin, whatever rpc.health is), any copy with block_number above it is skipped with the note 'N event above the finalized height ignored' and the state is computed from the rest. An EscrowOpened above the height leaves the escrow unverified (safe), but an EscrowReleased or EscrowRefunded above the height, marked finalized on the page, leaves a positive 'funded' with the opened event as evidence. The same happens when the RPC head is an hour stale (fixtures/status-stale-rpc: rpc.health degraded, finalized 3341575) because the height is still applied. RULES section 2 lets 'funded' stand with no finalized terminal event, so this is allowed by the letter of the spec, but it is a trust asymmetry: the page and the RPC disagree about finality of a terminal event ('More conflicting evidence never gives a more confident answer'), and the consumer of escrows.json sees funded, not unverified, for an escrow that the retained raw page says was released. The out-of-range rule (lines 313 to 322) already treats a suspect finalized release as a conflict; the above-height case is the same shape and should be handled the same way. Fix: when a finalized terminal copy for this escrow is above the finalized height (or the height comes from a head whose health is not ok), push a conflict such as 'a finalized release above the RPC finalized height names this escrow' so the state is unverified, matching the out-of-range handling.","line":328,"path":"observatory/src/escrow.ts","reproduction":"cd observatory; $W/esc-above/meta.json: {\"scenario\":\"esc-above\",\"description\":\"\",\"now\":\"2026-11-09T23:47:41Z\",\"synthetic\":true,\"escrow_ids\":[\"0xe0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0\"],\"pinned_genesis\":\"0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631\"}; event-pages/000.json: genesis = pinned, from_block 0, to_block 4000000, page 0, next_page null, events: EscrowOpened finalized at block 3341000 (block_hash 0xab*32, index 0, data {\"0\": the id}) and EscrowReleased finalized at block 3341580 (block_hash 0xcd*32, index 0, same id). node dist/cli.js snapshot --status fixtures/status-healthy --escrows $W/esc-above --out $W/outE (RPC finalized 3341575) -> escrows[0].state \"funded\", evidence [hasStation.EscrowOpened], notes ['1 event above the finalized height ignored']. Same with --status fixtures/status-stale-rpc (rpc.health degraded, head 1 h old): still \"funded\". Expected: \"unverified\" with a conflict note, as for the out-of-range finalized release (fixtures/escrows-outside-range-refund-vs-claim).","severity":"low","snippet":"    if (height !== null && copies.some((c) => c.block_number !== null && c.block_number > height)) {","title":"Asymmetry: a release or refund the page marks finalized but above the RPC finalized height (including a stale or degraded head) is silently dropped and the escrow is published as funded"},{"citation":"resolved","description":"Reproduced by running. RULES section 4: 'Tests fail if any attempt is left in the log', and the canary step proves this for attempts made inside a test body. The check is a single afterAll hook that reads attemptLog() once. Anything recorded after that hook, for example a blocked fetch called from a setTimeout, setImmediate or an unawaited promise that resolves after the last test, is recorded in memory and then discarded with the worker: the file passes, exit 0, and OBS_ATTEMPTS_FILE stays empty, so check.sh never sees it. The same holds for a leftover attempt from a test in a file whose afterAll ordering runs the harness hook before another file-level hook (vitest runs after hooks in stack order, so a hook declared in the test file itself is caught, as verified). In M0 no src module schedules network calls, so the exposure is to future tests and transports (M1) that use timers or background retries; a retry loop that fires after the suite would leave no trace. Fix: make recordAttempt append synchronously to OBS_ATTEMPTS_FILE when set (so check.sh can fail the test step on a non-empty attempts.jsonl regardless of timing), and/or add a process 'beforeExit' or vitest teardown check that rethrows if attemptLog() is non-empty.","line":104,"path":"observatory/test/setup.ts","reproduction":"In a clean clone of bce10dcf at observatory/, add test/zz-timer.test.ts:\nimport { expect, it } from \"vitest\";\nit(\"schedules a forbidden call for after the file's afterAll\", () => {\n  setTimeout(() => { try { fetch(\"https://canary.invalid/fund\"); } catch {} }, 200);\n  expect(1 + 1).toBe(2);\n});\nRun: OBS_ATTEMPTS_FILE=$TMPDIR/att.jsonl npx vitest run test/zz-timer.test.ts -> 'Test Files 1 passed', exit 0, $TMPDIR/att.jsonl absent. Control: the same fetch placed in the test file's own afterAll -> 'unexpected network, write, sign or spend attempts: [{kind network, what fetch https://canary.invalid/fund}]', exit 1, attempt written. Expected per RULES 4: both fail.","severity":"low","snippet":"afterAll(() => {","title":"A test can leave a forbidden network attempt behind without failing: an attempt fired from a timer after the last test runs after the leftover check in setup.ts afterAll and is never written to attemp"}],"hash":"78f488841fefcefdbfe99f9cc3603c9eeed6b366ae2056299378c3612b376b83","nodeId":"5ea2bbc9-259e-443b-8fda-3cfeb15869b0","outcome":"completed","summary":"The review is complete. Six findings are in `.imd-findings.json` at the repository root, all reproduced by running against the built CLI at commit bce10dcf. No files in the tree were changed. Nothing reached a FLOP endpoint.\n\n**Audit tool coverage.** The pinned skills are Solidity checklists. Their Access Control, Trust Gap and Asymmetry guides were applied as review passes over the TypeScript, Python, tests and scripts. No Slither or Foundry step applies to this scope, and no Foundry proof files were written since there is no Solidity in scope.\n\n**Findings, by severity**\n\n- **Medium, escrow (src/escrow.ts:99, also 101 and 108).** Escrow uses `Number.isInteger` where the census uses `Number.isSafeInteger`. An EscrowOpened with index or block_number 2^53+1 yields `funded` with a silently rounded evidence record, while the census counts the same event as malformed, and a page with an unsafe `to_block` is accepted by the escrow command but refused by the census. RULES clause 14 calls such an event malformed, and section 2 says a malformed event can never make an escrow look funded. Wrong escrow state.\n- **Medium, status wiring (src/cli.ts:151).** The rollback, changed-hash and stuck-height checks exist in status.ts but the `snapshot --log` command never passes the previous snapshot from the log it appends to. A second snapshot whose finalized height fell from 3341575 to 3341500 on the same genesis is published with RPC health `ok` and no conflict. Wrong health value.\n- **Medium, reset trust (src/status.ts:244).** The faucet /chain reading that supplies half of `reset_detected` has its `fetched_at` ignored entirely. The shipped status-healthy fixture carries a /chain time 11 seconds after the snapshot time and publishes `conflicts: []`, while the same skew on /health or the RPC head is reported as a conflict. A /chain reading from days before a reset still yields `reset_detected: false` with no note.\n- **Low, reset trust (src/status.ts:244).** A /chain body is trusted on HTTP 500 or with no HTTP status at all, giving `reset_detected: false` from an error response. /health applies the opposite rule, that status wins over body.\n- **Low, escrow asymmetry (src/escrow.ts:328).** A release the page marks finalized but above the RPC finalized height, including a height from a stale, degraded head, is dropped and the escrow reads `funded`. The out-of-range rule treats the same shape as a conflict.\n- **Low, guard harness (test/setup.ts:104).** A blocked fetch fired from a timer after the last test passes the file with exit 0 and nothing in attempts.jsonl. A fetch in the test file's own afterAll is caught, as verified.\n\n**Areas with nothing at medium or above.** Census and recount (TypeScript and Python agree on every fixture here, 485 tests pass). Read-only paths in src (source scan holds, no network, process, sign or spend path found; only the harness timing gap above). Outputs agreement (page, feeds and MCP matched in all five scenarios, including the hash chain).\n\n**Check run.** All 27 steps exit 0 on a clean clone of the pinned commit. In my working tree the mutants steps failed only because untracked, unreadable sandbox files under the repo root broke the copy step; the clean clone killed 159 of 159 mutants and classified all four canaries right. The outputs digest here is a85eeabe… rather than the expected decd3847…, identical in the working tree and the clean clone. The pin embeds `node: v24.22.1` on this machine against the requester's v22.23.4, and the README states byte-identity only for the same Node version, so I could not confirm the digest and attribute the difference to that, unverified.\n\nKnown-and-accepted items 1 to 5 were not reported.","treeHash":null,"usage":{"cachedInputTokens":1512307,"cost":{"source":"runtime","usdMicros":4827442},"inputTokens":418,"model":"claude-fable-5-1","outputTokens":35414,"runtime":"claude","turns":39,"wallClockMs":634780}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"be3be4cc237417f9","findings":[{"citation":"resolved","description":"RULES.md 1.1 requires, when there is a previous snapshot on the same genesis, that RPC health be degraded on a rollback, a changed finalized hash, or a finalized height stuck longer than the stale limit, and that rpc.stale be true when stuck. status.ts implements this behind StatusOptions.previous and snapshot.ts forwards o.previous, but cli.ts builds SnapshotOptions without `previous` even when --log names a log that already holds snapshots (store.appendSnapshot reads that log for seq and prev_hash only). No test or script exercises the wired path: the only uses of `previous` are three unit tests of networkStatus. Result: a wrong health value (ok instead of degraded), a wrong stale value (false instead of true on a stuck height) and a dropped conflict in the page, feeds and MCP. Found by reading, reproduced by running. Fix: in the snapshot command, read the last line of --log (readSnapshots) and, when its status.genesis.rpc is non-null, pass {genesis, finalized_height, finalized_hash, observed_at} as `previous` to buildSnapshot; add a cli-checks case.","line":151,"path":"observatory/src/cli.ts","reproduction":"cp -r fixtures/status-healthy s1; cp -r fixtures/status-healthy s2; edit s2/rpc-head.json: finalized.number 3341575 -> 3341565, finalized.hash -> 0xab..ab (64 hex). Then: node dist/cli.js snapshot --status s1 --out out1 --log log.jsonl; node dist/cli.js snapshot --status s2 --out out2 --log log.jsonl. Expected (RULES 1.1): out2/status.json status.rpc.health == \"degraded\" and status.conflicts contains \"finalized height went backwards from 3341575 to 3341565 on the same genesis\". Actual: status.rpc.health == \"ok\", status.rpc.stale == false, status.conflicts == []. Calling networkStatus(FixtureSource.fromDir('s2'), {pinnedGenesis, now, previous:{genesis, finalized_height:3341575, finalized_hash, observed_at}}) directly returns degraded with that conflict, showing the check exists but is never wired from the CLI.","severity":"medium","snippet":"      { pinnedGenesis: pinned, now, sourceSha: one(a, \"--source-sha\") ?? null },","title":"CLI snapshot never passes the previous snapshot to networkStatus, so rollback, changed-hash and stuck-height checks are dead: RPC health stays ok after a finalized-height rollback in the same log"},{"citation":"resolved","description":"RULES.md 3.1 clause 14 says an integer must be safe (at most 2^53-1): a page field holding a larger value refuses the census and an event field holding one makes the event malformed, and this holds for every integer field (index, block_number, page, next_page, from_block, to_block). RULES.md section 2 says a structurally invalid page (census clause 12) leaves every escrow unverified, and a malformed event (wrong types) can never make an escrow look funded, claimed or refunded. escrow.ts implements isInt() (line 107-109, used by pageProblem) and structureProblem() (line 99 for index, line 101 for block_number) with Number.isInteger, not Number.isSafeInteger, while census.ts and recount.py use the safe check. So the same page set is refused by both census implementations but accepted by the escrow module, which then reports claimed/refunded with evidence from it. For an event index of 2^60 the census counts the event as malformed_dropped while the escrow module uses it as the event identity and evidence (event_index rendered as the rounded 1152921504606847000). This is a confident state from input the spec classifies as invalid, and the escrow and census modules disagree on what is evidence. Found by reading, reproduced by running. Fix: use Number.isSafeInteger in isInt() and in structureProblem's index and block_number checks, and add escrow tests mirroring fixtures/census-unsafe-integer.","line":108,"path":"observatory/src/escrow.ts","reproduction":"cp -r fixtures/escrows-verification e1; in e1/event-pages/000.json set \"to_block\": 9007199254740993 (or 1e300); add \"census_request\": {\"from_block\":0,\"to_block\":0} to e1/meta.json. node dist/cli.js census --fixtures e1 -> exit 2 'census refused: event page 0: to_block is missing or not a safe integer'; python3 -I tools/recount.py --dir e1 --genesis 0x99c1...2631 --from 0 --to 0 -> exit 2 'to_block missing or not an integer'. node dist/cli.js escrow --fixtures e1 -> expected every escrow 'unverified' with a note naming the invalid page (RULES 2, clause 12); actual 0x6fca...cac5 'claimed' and 0x222e...f851 'refunded' with evidence. Variant: set events[0].index to 1152921504606846976 (2^60): census counts it in malformed_dropped, escrow reports 0x6fca...cac5 'claimed' with event_index 1152921504606847000.","severity":"medium","snippet":"  return typeof value === \"number\" && Number.isInteger(value);","title":"escrow.ts accepts unsafe integers (beyond 2^53-1) in page fields and event index, so a page the census must refuse under clause 12/14 still yields claimed or refunded instead of unverified"},{"citation":"resolved","description":"networkStatus reads chain.body.genesisHash and feeds it straight to resetDetected() (line 271). It never looks at chain.http, chain.error or chain.fetched_at. RULES.md 1: 'Unknown is never good news' and reset_detected is false 'only when both the faucet and the RPC report the pinned genesis'. RULES.md 1.1: 'A reading time later than the snapshot time is a conflict, noted in conflicts, whatever that reading's health.' The /chain reading is the only reading whose time is never parsed, so a genesisHash carried in the body of an HTTP 500, a reading taken weeks before the snapshot, or a reading dated after the snapshot still produces reset_detected=false with an empty conflicts list. The faucet /health and gateway readings are subject to freshness and future-time checks (applyFreshness) but the one reading that drives the reset flag is not. A consumer that gates spending on reset_detected===false can be cleared by a stale or error-status faucet response while the RPC side alone says nothing about the faucet. Found by reading, reproduced by running. Fix: treat a /chain result as a genesis report only when http===200 and fetched_at parses, is not later than now and is within the stale limit; otherwise set genesis.faucet to null (reset_detected becomes null) and push a conflict for a future reading time, as applyFreshness does for the other two readings.","line":244,"path":"observatory/src/status.ts","reproduction":"cp -r fixtures/status-healthy s3 and set faucet-chain.json http to 500 (body unchanged): node dist/cli.js status --fixtures s3 -> reset_detected false, genesis.faucet 0x99c1..., conflicts []. Expected: genesis.faucet null and reset_detected null (no readable faucet report), or at least a conflict. cp -r fixtures/status-healthy s6 and set faucet-chain.json fetched_at to 2026-12-01T00:00:00Z (snapshot now is 2026-11-09T23:47:41Z): status -> reset_detected false, conflicts []. Expected per RULES 1.1: a conflict 'faucet /chain reading time ... is later than the snapshot time'. cp -r fixtures/status-healthy s5 and set fetched_at to 2026-10-01T00:00:00Z: reset_detected false, conflicts [] (the reading is 39 days old; the same age on faucet-health degrades the faucet).","severity":"medium","snippet":"  const rawFaucetGenesis = chain.body && typeof chain.body === \"object\" ? chain.body.genesisHash : undefined;","title":"Faucet /chain genesis counts toward reset_detected=false whatever its HTTP status or reading time: a 500 response, a 39-day-old reading or a reading later than the snapshot all clear the reset flag, a"},{"citation":"resolved","description":"setup() copies every entry under the repository root except the SKIP names and .build-* prefixes. Nothing limits the copy to tracked files or to the directories the tests read (observatory/, interfaces/, imd-flop/). An unreadable file at the repository root (here a 0-permission .mcp.json left by the environment) makes cpSync throw before any control runs; the exception is not caught, so mutants.json is empty, the step exits 1 and mutants-canary reports every outcome as 'missing'. The check is therefore red on a tree that is otherwise clean, and the README promise 'rerun this command on the same revision in a fresh clone and compare outputs/' is the only way to get a result. Running the same commit from a fresh local clone gave 159/159 killed with both controls passing, so this is a robustness defect of the runner, not a false pass. Reproduced by running. Fix: copy only `git ls-files`-tracked paths, or only observatory/ plus the sibling directories the tests reference, and wrap setup() so a copy failure is reported as a structured 'setup-error' outcome instead of a crash.","line":82,"path":"observatory/scripts/mutate.mjs","reproduction":"In the repository root create a file the current user cannot read: touch .unreadable && chmod 000 .unreadable. cd observatory && node scripts/mutate.mjs; echo $? -> exit 1, stdout empty, stderr 'Error: EACCES: permission denied, copyfile .../.unreadable -> /tmp/obs-mutate-*/repo/.unreadable at setup (scripts/mutate.mjs:82)'. node scripts/mutate-canary.mjs -> every mutant 'got: missing', control_before 'missing', exit 1. Expected: the runner either skips untracked entries or reports the setup failure as a result; observed in this review's npm run check (steps mutants and mutants-canary exit 1 while the other 25 steps exit 0).","severity":"low","snippet":"  cpSync(repo, copy, {","title":"mutate.mjs copies the whole repository root, so any unreadable or foreign entry outside observatory/ aborts the mutation step with an uncaught EACCES and an empty mutants.json, and mutants-canary fail"},{"citation":"resolved","description":"FixtureSource.fromDir tests existsSync(pagesDir) and then calls readdirSync on it. When event-pages exists but is a file, readdirSync throws ENOTDIR; the error propagates out of fromDir, the CLI prints the raw OS message and exits 2. tools/recount.py load_pages tests os.path.isdir and, when false, treats the scenario as having no pages, printing a partial census (covered [], whole request a gap) with exit 0. RULES.md says the recount is written from the rules alone and both must agree, including on refusals; compare-recount.mjs would classify this scenario as disagree-on-refusal. Neither behaviour is a wrong escrow state or a complete census, but the TypeScript side fails with a loader exception rather than a clause-15 style refusal naming the entry, and the escrow path would likewise crash instead of returning unverified with a note. Reproduced by running. Fix: in fromDir, when event-pages exists and is not a directory, record it in loadErrors and pageErrors ('event-pages: not a directory') so census refuses and escrows are unverified; mirror that in recount.py (raise Unreadable when event-pages exists and is not a directory).","line":125,"path":"observatory/src/source.ts","reproduction":"cp -r fixtures/census-complete c1; rm -rf c1/event-pages; echo '{}' > c1/event-pages. node dist/cli.js census --fixtures c1 -> exit 2, stderr 'flop-observatory: ENOTDIR: not a directory, scandir .../c1/event-pages'. python3 -I tools/recount.py --dir c1 --genesis 0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631 --from 0 --to 199 -> exit 0 with {\"complete\": false, \"status\": \"partial\", \"covered\": [], ...}. Expected: both refuse with the same reason, or both treat it the same way.","severity":"low","snippet":"    if (existsSync(pagesDir)) {","title":"event-pages that is a regular file instead of a directory: the TypeScript loader crashes with a raw ENOTDIR while the Python recount returns a partial census with exit 0, so the two implementations di"}],"hash":"a65c5241049a93ed63551259e3dd9608656fdecd0341580046b3b7f24b56e6f9","nodeId":"ab9ba96d-4ec4-4d5b-8589-7ffa5fdcd8d5","outcome":"completed","summary":"Review complete. The findings file holds five substantiated findings, all with snippets verified against the tree. No critical or high finding was found.\n\n**Check results on this machine (Node v24.21.0, Python 3.12.3, not the Node 22 the task expects)**\n\n| Item | Result |\n|---|---|\n| Steps exiting 0 | 25 of 27 |\n| Tests | 485 passed |\n| Recount cross-check | agree on every census fixture |\n| Mutants (clean local clone) | 159 of 159 killed, canary classified right |\n| Output digest | d7554801…ef7e4, not the expected decd3847…372 |\n\nThe two failing steps (mutants, mutants-canary) failed because the runner copies the whole repository root and hit an unreadable foreign dotfile in this worktree. Rerun from a fresh local clone of the same commit, both pass. The digest difference is expected: pin.json carries the Node version and the snapshot hash covers it, so Node 24 cannot reproduce a Node 22 digest. Beyond that I could not verify the published digest.\n\n**Findings (all in `.imd-findings.json`)**\n\n- **Medium, cli.ts:151.** The snapshot command never passes the previous snapshot from the log to the status builder, so the rollback, changed-hash and stuck-height checks in RULES 1.1 are dead code in production. Reproduced: a second snapshot into the same log with the finalized height ten blocks lower shows RPC health ok, stale false, no conflict. Wrong health and stale values.\n- **Medium, escrow.ts:108.** The escrow module checks integers with `Number.isInteger`, not the safe-integer test clause 14 requires. A page with `to_block` 2^53+1 is refused by both census implementations but yields claimed and refunded escrows, and an event index of 2^60 is malformed to the census yet evidence to the escrow module. Confident state from input the spec calls invalid.\n- **Medium, status.ts:244.** The faucet /chain genesis clears `reset_detected` regardless of HTTP status or reading time. An HTTP 500 body, a 39-day-old reading and a reading dated after the snapshot all give reset false with empty conflicts. The future-time case breaks the explicit \"whatever that reading's health\" conflict rule.\n- **Low, mutate.mjs:82.** The repo copy aborts with an uncaught EACCES on any unreadable root entry, leaving mutants.json empty and the canary reporting \"missing\". Robustness, not a false pass.\n- **Low, source.ts:125.** When event-pages is a file, the TypeScript loader crashes with raw ENOTDIR while the Python recount prints a partial census and exits 0. The two implementations disagree.\n\n**Areas with nothing at medium or above**\n\n- **Census (area 3).** A randomized differential fuzz of 470 generated scenarios (malformed events, duplicates, unsafe integers, floats, other geneses, broken chains) produced zero disagreements between the TypeScript census and the Python recount, including refusals.\n- **Read only (area 4).** The harness also records the lower-layer paths I probed beyond its own tests: a directly constructed ClientRequest, the captured Agent createConnection reference, and a TLSSocket wrapping a fresh socket.\n- **Outputs agree (area 5).** No field found where page, feeds and MCP disagree that the checker misses. The finalized-height gate for escrows behaves correctly in the reset and conflict scenarios.\n- **Evidence honesty (area 6).** The mutation classifier, canaries and recount comparison report what they claim once the environmental copy failure is removed.\n\nNothing in the repository was modified. The check wrote its own ignored `check-runs/` directory; all probe inputs live under the temporary directory.","treeHash":null,"usage":{"cachedInputTokens":1540183,"cost":{"source":"runtime","usdMicros":6553549},"inputTokens":354,"model":"claude-fable-5-1","outputTokens":51177,"runtime":"claude","turns":49,"wallClockMs":1396755}}],"verification":[]}