# Audit report

> FLOP Observatory M0: independent audit of a read-only devnet observer (TypeScript and Python, no Solidity in scope)
>
> SUBJECT: 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.
>
> OUT 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.
>
> WHAT 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.
>
> HARD 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.
>
> READ 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.
>
> RUN: 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:
> cd outputs && find . -type f | LC_ALL=C sort | xargs sha256sum | sha256sum
> decd38476868f2cd55209326f74de27f33214b404a2deaf69b66cadb85d3c372
> Report any difference.
>
> KNOWN AND ACCEPTED (report only a worse variant):
> 1. 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.
> 2. Drift spec and runtime columns are not checked against the header order.
> 3. Impossible calendar dates such as 2026-11-31 parse and roll over.
> 4. An event with one in-range and one out-of-range copy: the escrow is unverified, but the census counts it.
> 5. 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].
>
> LOOK HARDEST AT, in order:
> 1. 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.
> 2. 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.
> 3. 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-*.
> 4. 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.
> 5. 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.
> 6. 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.
>
> DELIVER: 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.

| | |
|---|---|
| Repository | https://github.com/uxp-labs/flop-devnet-lab.git |
| Commit | `bce10dcf54643e06a5f97435866df476eafcc453` |
| Job | `a24f9aa1-8a2e-49de-85c9-0c23ef14aed3` |
| Judged | 2026-11-10 04:29 UTC |
| Findings | 5 medium · 8 low · 2 info |

Four agents audited the code as it is at `bce10dc`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Medium: 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

`observatory/src/escrow.ts:99`

```
  if (typeof index !== "number" || !Number.isInteger(index) || index < 0) return "index is not a whole number";
```

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.

**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.

### 2. Medium: 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

`observatory/src/escrow.ts:108`

```
  return typeof value === "number" && Number.isInteger(value);
```

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.

**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 ...'.

### 3. Medium: 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

`observatory/src/source.ts:113`

```
        return JSON.parse(readFileSync(path, "utf8")) as T;
```

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.

**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.

### 4. Medium: 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

`observatory/src/cli.ts:151`

```
      { pinnedGenesis: pinned, now, sourceSha: one(a, "--source-sha") ?? null },
```

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.

**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.

### 5. Medium: 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

`observatory/src/status.ts:244`

```
  const rawFaucetGenesis = chain.body && typeof chain.body === "object" ? chain.body.genesisHash : undefined;
```

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.

**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 [].

### 6. Low: 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

`observatory/tools/recount.py:68`

```
    if os.path.isdir(pages_dir):
```

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.

**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.

### 7. Low: 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

`observatory/src/escrow.ts:377`

```
        evidence = [...(opened.length === 1 ? opened : []), ...released];
```

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.

**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.

### 8. Low: 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

`observatory/test/setup.ts:104`

```
afterAll(() => {
```

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.

**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.

### 9. Low: 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

`observatory/scripts/mutate.mjs:82`

```
  cpSync(repo, copy, {
```

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.

**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.

### 10. Low: 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

`observatory/src/drift.ts:90`

```
  const m = /^(-?)(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/i.exec(String(v));
```

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.

**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.

### 11. Low: 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

`observatory/src/escrow.ts:328`

```
    if (height !== null && copies.some((c) => c.block_number !== null && c.block_number > height)) {
```

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.

**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.

### 12. Low: 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

`observatory/scripts/consistency.mjs:114`

```
for (const m of html.matchAll(/<([a-z][a-z0-9]*)\b([^>]*\bdata-k="[^"]*"[^>]*)>/g)) {
```

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.

**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.

### 13. Low: 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

`observatory/test/guard.test.ts:131`

```
    /\bfetch\s*\(/,
```

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.

**Reproduction**

In a scratch clone at bce10dcf append to observatory/src/feed.ts:
export const leak = (u: string) => fetch.call(globalThis, u);
export const leak2 = (u: string) => new globalThis.WebSocket(u);
const f = globalThis.fetch;
export const leak3 = (u: string) => f(u);
Then 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.

### 14. Info: 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

`observatory/src/escrow.ts:101`

```
  if (n !== null && n !== undefined && (typeof n !== "number" || !Number.isInteger(n) || n < 0)) {
```

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.

**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.

### 15. Info: 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

`observatory/src/snapshot.ts:69`

```
    node: process.version,
```

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.

**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.

---

Judge's submission `26536f320d69a4f62437cf32c6dd5614751954465763ed41d70b0df0445d1618`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
