# Audit report

> FLOP Observatory M0, follow-up audit: recheck the 15 findings of job a24f9aa1 and try to break the fixes (TypeScript and Python, no Solidity in scope)
>
> SUBJECT: observatory/ and interfaces/ in uxp-labs/flop-devnet-lab at exactly commit b4a7a99a3c7557256a516fdf1190e0103205fb10 (branch claude/observatory-m0, PR #5; not on main yet). 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.
>
> 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. Its "Round 5" section maps each of the 15 findings of job a24f9aa1 (https://explorer.imd.fun/jobs/a24f9aa1-8a2e-49de-85c9-0c23ef14aed3) to its fix and proof.
>
> RUN: cd observatory && npm run check. Offline except npm ci. On Node v22.23.4 (Python 3.13.18) expect all 28 steps to exit 0, 612 tests to pass, 206 of 206 mutants to be killed, and this digest over every output file:
> cd outputs && find . -type f | LC_ALL=C sort | xargs sha256sum | sha256sum
> bc051936209195958adaf1383c497a00eb6b340f675570a876e9b30b54ec994a
> On any other Node version the digest differs, because each snapshot pin records process.version. Report any other difference.
>
> FIRST: for each of the 15 earlier findings, say fixed, partly fixed or not fixed, with a reproduction.
>
> THEN LOOK HARDEST AT, in order:
> 1. Escrow evidence (RULES.md section 2, src/escrow.ts, src/strict.ts): any input that yields funded, claimed or refunded without finalized evidence on the pinned genesis.
> 2. Network status (sections 1 and 1.1, src/status.ts, src/cli.ts): any input that yields health ok when a check failed or could not run, or reset_detected false without both a usable faucet /chain reading and the RPC on the pinned genesis. Include the snapshot log the command line now reads.
> 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 TypeScript and Python disagree, including UTF-8, safe integers, negative ranges and copies both in and out of range.
> 4. Read only (section 4, test/setup.ts, test/global-setup.ts, scripts/attempts-check.mjs, the source scan in test/guard.test.ts): any src path to the network, a process, a signature, a spend or a write, and any way a test can leave an attempt behind that no check sees.
> 5. Outputs agree (section 5, src/page.ts, scripts/consistency.mjs): any way to hide or misplace a value on the page that passes.
> 6. Evidence honesty (scripts/mutate.mjs, scripts/tracked.mjs, the canaries): 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. 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 | `b4a7a99a3c7557256a516fdf1190e0103205fb10` |
| Job | `c0ebfa5e-cf39-4631-9e44-cee039176a70` |
| Judged | 2026-11-10 06:13 UTC |
| Findings | 2 high · 5 medium · 7 low · 1 info |

Four agents audited the code as it is at `b4a7a99`, 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. High: Escrow takes an event with no block_number key as clean evidence: a malformed open/release/refund yields funded/claimed/refunded, and gets past the finalized-height bound

`observatory/src/escrow.ts:105`

```
  if (n !== null && n !== undefined && !isSafeInt(n)) {
```

RULES.md section 2 says a malformed event (missing fields) is ignored and can never make an escrow look funded, claimed or refunded. Clause 7 says an event with a missing block_number is malformed and that the escrow rules read events the same way. census.ts readEvent and tools/recount.py parse_event both reject it. structureProblem() in escrow.ts lets `undefined` through (`n !== undefined`), so the event is grouped as clean evidence with block_number null. Because the copy's block_number is null, the above-finalized-height check (fix 11) and the page block-range check both skip it. A finalized release with no block number therefore makes an escrow claimed even under an RPC finalized height. Merged from audit_flow, audit_permissions and audit_economics. Impact: WRONG ESCROW STATE (funded/claimed/refunded without well-formed finalized evidence). Reproduced by running. Fix: treat an absent key as malformed, e.g. `if (!("block_number" in ev) || (n !== null && !isSafeInt(n)))`, and add a fixture shared with the recount and a mutant.

**Reproduction**

Fixture /tmp/rp/e1o: meta.json {scenario:'e1o', 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:pinned, from_block:100, to_block:199, page:0, next_page:null, events:[{block_hash:'0x'+'01'*32, finalized:true, index:0, section:'hasStation', method:'EscrowOpened', data:{'0':'0xa1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1'}}]} (NO block_number key). `node dist/cli.js escrow --fixtures /tmp/rp/e1o` -> state "funded", evidence EscrowOpened block_number null, no malformed note. Fixture /tmp/rp/e1r: a well-formed open at block 110 plus a finalized EscrowReleased (block_hash 0x02..02, index 0) with no block_number -> state "claimed". `node dist/cli.js census --fixtures /tmp/rp/e1o` -> complete, malformed_dropped 1; `python3 -I tools/recount.py --dir /tmp/rp/e1o --genesis 0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631 --from 100 --to 199` -> escrows_opened 0, malformed_dropped 1. Bound bypass: copy fixtures/status-healthy, set rpc-head finalized.number 150 and best_number 155 (/tmp/rp/h150); `node dist/cli.js snapshot --status /tmp/rp/h150 --escrows /tmp/rp/e1r --out /tmp/rp/o1r --now 2026-11-09T23:47:53Z` -> escrows[0] state "claimed", finalized_height 150, notes []. Control: fixtures/escrows-release-above-height under the same head gives unverified '1 event above the finalized height'. Expected: unverified with 'malformed event ignored' and a 'malformed finalized release could be about this escrow' conflict.

### 2. High: Rollback check reads only the last log line: one snapshot with no RPC reading in between hides a finalized-height rollback, and RPC health reads ok

`observatory/src/cli.ts:156`

```
      const st = readSnapshots(log).at(-1)?.status;
```

RULES.md 1.1 says: when there is a previous snapshot on the same genesis, there must be no rollback, no changed finalized hash and no stuck height. cli.ts takes only readSnapshots(log).at(-1). If that snapshot has no RPC genesis or finalized head (the RPC was not read or the read failed), previous stays null and all three checks are skipped. The next snapshot's RPC health is then ok even though its finalized height is below the one recorded two lines earlier on the same genesis. So fix 4 (finding 4 of a24f9aa1) is only partly done. Impact: WRONG HEALTH (ok after a rollback, conflicts empty). Reproduced by running. Fix: walk the verified log back to the latest snapshot whose status has an RPC genesis equal to the current one and a finalized head, or carry the highest finalized head seen per genesis.

**Reproduction**

sA = copy of fixtures/status-healthy (finalized 3341575, hash 0x5e..5e); sB = same with rpc-head.json deleted; sC = same with rpc-head finalized {number:3341500, hash:'0x'+'7a'*32}, best_number 3341510. Control: `node dist/cli.js snapshot --status sA --out o1 --log L1/log.jsonl --now 2026-11-09T23:47:53Z` then the same with sC -> last line rpc.health degraded, conflicts ['finalized height went backwards from 3341575 to 3341500 on the same genesis']. Attack: run sA, sB, sC in order into L2/log.jsonl -> last line rpc.health "ok", detail null, conflicts [], reset_detected false. Expected: degraded with the rollback conflict, as in the control.

### 3. Medium: A finalized height stuck across many snapshots is never reported: the stuck time is measured only since the immediately previous snapshot

`observatory/src/status.ts:174`

```
      const since = secondsBetween(previous.observed_at, o.now);
```

RULES.md 1.1 says there must be no finalized height stuck for longer than the stale limit (120 s), and rpc.stale is true when the height is stuck. classifyRpc measures the stuck time as now minus the previous snapshot's observed_at. With snapshots taken more often than the limit, the height can stay at one value forever and every snapshot sees only 60 s. Impact: WRONG HEALTH and stale value (ok/false while the height has not moved for 4+ minutes). interfaces/README tells Seat Court to use rpc.stale for deadlines. Reproduced by running. Fix: carry forward the time a finalized head was first seen (walk back through consecutive snapshots with the same height and hash, or store finalized_since in the status).

**Reproduction**

Five copies stk0..stk4 of fixtures/status-healthy with an unchanged finalized head 3341575/0x5e..5e. Copy k has rpc-head observed_at 2026-11-09T23:(47+k):48Z and faucet-health, faucet-chain and gateway-models fetched_at 23:(47+k):50Z. Run `node dist/cli.js snapshot --status stk<k> --out o3<k> --log L3/log.jsonl --now 2026-11-09T23:(47+k):53Z` for k=0..4. Expected: by seq 3 or 4 (180-240 s unchanged) rpc.health degraded 'finalized height stuck at 3341575 ...' and rpc.stale true. Actual: all five lines have rpc.health ok, stale false, detail null, conflicts [].

### 4. Medium: RPC genesis counts toward reset_detected=false even when the RPC read failed, is days old, or is later than the snapshot (no gate like the faucet /chain one)

`observatory/src/status.ts:243`

```
  const rpcGenesis = normalizeHash(head.genesis_hash);
```

Round 5 fix 5 makes the faucet genesis count only from a usable /chain reading: HTTP 200, no error, and a reading time that is fresh and not in the future. The RPC side has no such gate. rpcGenesis is head.genesis_hash whatever head.error and head.observed_at say, so reset_detected is false on a reading the status itself calls degraded. A reset after that old reading cannot show. Callers gate spending on reset_detected === false. RULES.md section 1 says 'report the pinned genesis' without a freshness rule for the RPC, so this is a spec gap as much as a code gap, but fix 5's reasoning applies exactly. The same head's finalized_height also bounds the escrow evidence (snapshot.ts). Merged from audit_flow, audit_permissions and audit_economics. Impact: WRONG RESET VALUE (false instead of null). Reproduced by running. Fix: use the RPC genesis only when head.error is null and observed_at parses, is not later than now and is within the stale limit; otherwise null, as chainUsable does. State it in RULES.md 1.

**Reproduction**

Copy fixtures/status-healthy and set rpc-head.json to (a) error 'websocket closed: read timed out', finalized null, best_number null, observed_at 2026-11-07T23:47:40Z; (b) observed_at 2026-11-01T00:00:00Z with error 'read timed out; values are from the client cache'; (c) observed_at 2026-11-09T23:50:00Z (meta now 23:47:53Z). `node dist/cli.js status --fixtures <dir>`: (a) reset_detected false, genesis.rpc pinned, rpc.health degraded 'read error ...; head is 172813s old; no valid finalized head'; (b) reset_detected false, finalized_height 3341575, rpc degraded 'head is 776873s old'; (c) reset_detected false, conflict 'rpc head observed_at ... is later than the snapshot time'. Control: a /chain reading 2 days old (fetched_at 2026-11-07T00:00:00Z) gives genesis.faucet null and reset_detected null. Expected for a-c: genesis.rpc null and reset_detected null.

### 5. Medium: A page file whose name holds a non-UTF-8 byte is silently dropped: escrow reads funded over a finalized release, the TypeScript census shows a gap, Python counts the page as complete

`observatory/src/source.ts:111`

```
      if (!existsSync(path)) return undefined;
```

readdirSync decodes an invalid UTF-8 file name to U+FFFD. The entry passes the clause 15 checks, but join(dir, name) names a path that does not exist, so read() returns undefined at `!existsSync(path)` and adds nothing to loadErrors or pageErrors. RULES.md section 2 and clause 12 say a page that cannot be read leaves every escrow unverified and refuses the census. Instead the escrow is judged without the page. tools/recount.py lists the directory with os.listdir (surrogateescape), reads the page and calls the census complete. Impact: WRONG ESCROW STATE (funded although the input holds a finalized release) and a TypeScript/Python disagreement on completeness and totals. Reproduced by running. Fix: list with encoding 'buffer' and a strict decode, or treat a missing file for a listed entry as a page error. Never return undefined silently for an entry the listing produced.

**Reproduction**

/tmp/rp/e3: meta escrow_ids ['0xa1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1'], census_request 0..199; event-pages/00.json = page 0..99, page 0, next_page null, finalized EscrowOpened at block 10. A second file named with the bytes b'01-\xff.json' holds page 100..199, page 0, next_page null, with a finalized EscrowReleased at block 150 (hash 0x02..02) for the same id. `node dist/cli.js escrow --fixtures /tmp/rp/e3` -> state funded, only note 'evidence is not bounded...'. `node dist/cli.js census --fixtures /tmp/rp/e3` -> exit 0, status partial, gaps [100-199], no page error. `python3 -I tools/recount.py --dir /tmp/rp/e3 --genesis 0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631 --from 0 --to 199` -> exit 0, status complete, escrows_opened 1, escrows_released 1. Expected: escrow unverified with a note naming the unreadable page, and the census refused (exit 2) by both.

### 6. Medium: Escrow ignores page chains: an open on a page whose next_page is not in the input reads funded, while the census calls the same chain broken

`observatory/src/escrow.ts:225`

```
  for (const page of pageList) {
    if (pageProblem(page) !== null) continue;
```

Section 2 makes every escrow unverified when a page could not be read, because the missing page could hold a conflicting event. escrowState only checks each page with pageProblem() and never walks page chains. A page that names a next_page missing from the input is not treated as missing, so an open on page 0 with next_page 1 absent gives funded, though page 1 could hold the release or refund. The census over the same input reports a broken chain and a 100-block gap. The rule names only unreadable and invalid pages, so this is a spec gap too, but its stated reason applies. Impact: WRONG ESCROW STATE (funded while the evidence that could contradict it is known to be missing). Reproduced by running. Fix: apply census clause 2 to escrow pages (a broken chain on the pinned genesis, or disagreeing copies of a page number, makes every escrow unverified with a note), and state it in RULES.md 2.

**Reproduction**

/tmp/rp/e2: meta escrow_ids ['0xa1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1'], census 100..199; event-pages/000.json {genesis:pinned, from_block:100, to_block:199, page:0, next_page:1, events:[finalized EscrowOpened at block 110, hash 0x01..01, index 0]}, no page 1. `node dist/cli.js escrow --fixtures /tmp/rp/e2` -> state funded, evidence EscrowOpened@110. `node dist/cli.js census --fixtures /tmp/rp/e2` -> status partial, gaps [100-199], note 'next_page 1 on page 0 names a page that does not exist, so the chain is broken'. Expected: escrow unverified with a note that page 1 is missing.

### 7. Medium: Page check lets free text show a wrong value: a th-only 'reset detected / false' row, a p note, a second h1 or a lone th all pass consistency.mjs

`observatory/scripts/consistency.mjs:181`

```
    if (tds.length === 0) continue; // a header row
```

RULES.md section 5 says nothing on the page may be hidden and every value sits where a person reads it. consistency.mjs requires data-k only on td and span (line 150). It skips any tr with no td as a header row (line 181) and never compares the text of th-only rows, p, h1, code or div, all of which are on the tag allowlist (line 359). So a tampered page can show, in the status table under Network status, a row reading 'reset detected' / 'false' directly above the real row that says true, and the check passes. Fix 12 (finding 12 of a24f9aa1) is therefore partly done. Merged from audit_math, audit_permissions and audit_economics. Impact: a wrong reset value shown on the page while the check reports 0 failures (feeds and MCP stay right). Reproduced by running. Fix: require data-for or data-col on every th, fail any tr in a section table with no data-k cell, and allow outside marked cells only the renderer's fixed text (compare against renderPage(snapshot) outside marked cells).

**Reproduction**

Copy check-runs/<sha>/<run>/outputs/reset (from this commit's npm run check) and edit index.html. (row) insert '<tr><th scope="row">reset detected</th><th scope="row">false</th></tr>' before '<tr><th scope="row" data-for="status.reset_detected">'. (p) insert '<p class="note">reset detected: false. Chain is the pinned genesis; safe to spend.</p>' after '<h2>Network status</h2>'. (h1) insert '<h1>No reset detected</h1>' after the page h1. (th1) insert '<tr><th scope="row">reset detected: false, safe to spend</th></tr>' right after '<table data-section="status">'. `node scripts/consistency.mjs <dir>` -> exit 0 and failures [] for all four (2107/2103/2103/2105 checks), while the snapshot and the real cell say reset_detected true. Expected: exit 1 with a failure for each.

### 8. Low: consistency.mjs allows aria-label with any value on any element, so a cell can read 'false' to a screen reader while its text and data-v say true

`observatory/scripts/consistency.mjs:360`

```
  const ATTRS = new Set(["lang", "charset", "name", "content", "class", "scope", "aria-label", "data-k", "data-v", "data-n", "data-for", "data-row", "data-section", "data-col", "data-escrow"]);
```

The renderer writes aria-label only as aria-label="unknown" on blank unknown cells. The checker allows the attribute name on every element and never compares its value, and aria-label replaces the cell text for assistive technology. Merged from audit_math, audit_permissions and audit_economics. Impact: a wrong value presented to screen-reader users; the check passes. Reproduced by running. Fix: accept aria-label only equal to 'unknown', only on a td whose data-v is unknown and whose text is empty.

**Reproduction**

Copy outputs/reset from the check run and change '<td data-k="status.reset_detected" data-v="true">' to '<td data-k="status.reset_detected" data-v="true" aria-label="false">'. `node scripts/consistency.mjs <dir>` -> exit 0, 2101 checks, failures []. Expected: an attribute failure.

### 9. Low: status.ts reads heights and the runtime version with Number.isInteger, not the shared isSafeInt: finalized 2^53+1 is reported as 2^53 with rpc health ok

`observatory/src/status.ts:40`

```
function nonNegativeInt(value: unknown): number | null {
  return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
}
```

Round 5 says src/strict.ts holds the one safe-integer reading for escrow, census and status. status.ts nonNegativeInt still uses Number.isInteger, so a head whose finalized number JSON.parse has rounded is accepted as present and valid. Its lag is computed from rounded values, and the height becomes the escrow evidence bound. Merged from audit_math and audit_permissions. Impact: wrong health (ok on an untrusted reading). Unrealistic heights, hence low. Reproduced by running. Fix: use isSafeInt in nonNegativeInt.

**Reproduction**

Copy fixtures/status-healthy, set rpc-head.json finalized.number 9007199254740993 and best_number 9007199254740995 (JSON number literals). `node dist/cli.js status --fixtures <dir>` -> rpc.health ok, stale false, finalized_height 9007199254740992, best_height 9007199254740996, finality_lag 4, conflicts []. Expected: rpc degraded ('no valid finalized head', 'no valid best height'), finalized_height null.

### 10. Low: When the previous snapshot's RPC reading time does not parse, the stuck-height check silently does not run and RPC health is ok

`observatory/src/status.ts:175`

```
      if (since !== null && since > o.staleAfterSeconds) {
```

RULES.md 1.1: ok needs every check to run and pass. cli.ts passes the previous snapshot's rpc.observed_at whenever it is non-null, and the status stores the raw head time even when it does not parse (here a zoneless time, which already made that snapshot unknown). In the next snapshot with the same finalized head, secondsBetween returns null and the stuck branch is skipped without marking the check as unable to run. Impact: wrong health (ok while the height may have been stuck for 70 minutes). Reproduced by running. Fix: when previous is on the same genesis with the same head and its time does not parse, health unknown, or only take a previous whose observed_at parses.

**Reproduction**

pz0 = fixtures/status-healthy with rpc-head observed_at '2026-11-09T23:47:40' (no zone); `node dist/cli.js snapshot --status pz0 --out o40 --log L4/log.jsonl --now 2026-11-09T23:47:53Z` -> rpc.health unknown, finalized_height 3341575. pz1 = same head, rpc-head observed_at 2026-11-10T00:57:48Z, three fetched_at 2026-11-10T00:57:50Z; `node dist/cli.js snapshot --status pz1 --out o41 --log L4/log.jsonl --now 2026-11-10T00:57:53Z` -> rpc.health ok, stale false, detail null. Expected: unknown (check could not run) or degraded (stuck 70 min).

### 11. Low: A finalized release (or refund) with an unparsable escrow id does not block claimed (or refunded), though a second well-formed release of the same kind makes the escrow unverified

`observatory/src/escrow.ts:383`

```
      if (suspectTerminals.has(REFUNDED)) {
```

Section 2: an unparsable finalized terminal on the pinned genesis could contradict any escrow, and more than one distinct finalized release is a conflict (line 376). The claimed branch checks suspectTerminals only for REFUNDED, and the refunded branch only for RELEASED. So one clean release plus one unparsable finalized release gives claimed, while one clean release plus a second clean release gives unverified. More conflicting evidence gives a more confident answer. Impact: WRONG ESCROW STATE (claimed/refunded) in a conflicting-evidence case. Reproduced by running. Fix: in the claimed branch also treat suspectTerminals.has(RELEASED) as a conflict (REFUNDED in the refunded branch), or state the exception in RULES.md.

**Reproduction**

/tmp/rp/e4: one page 100..199 with finalized EscrowOpened@110 (0x01..01) and EscrowReleased@120 (0x02..02) for 0xa1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1, plus a finalized EscrowReleased@130 (0x03..03) with data {'0':'not-an-id'}. `node dist/cli.js escrow --fixtures /tmp/rp/e4` -> state claimed. e4r (Refunded instead) -> refunded. Control e4c (third event's id valid) -> unverified 'more than one distinct finalized release for this escrow'. Expected for e4/e4r: unverified.

### 12. Low: Python recount crashes (RecursionError, exit 1) or refuses (4300-digit integer) valid JSON that the TypeScript census counts as complete

`observatory/tools/recount.py:90`

```
        except (OSError, ValueError) as exc:
```

Clause 12 refuses a page only when it is not UTF-8 or not RFC 8259 JSON. json.load raises RecursionError on deeply nested data, which `except (OSError, ValueError)` does not catch, so the recount dies with a traceback. On Python 3.11+ it raises ValueError for an integer literal over 4300 digits and refuses the whole census, while TypeScript marks that one event malformed and counts the rest. compare-recount.mjs line 73 then calls any two non-zero exits 'both-refused' agreement. Merged from audit_flow and audit_permissions. Impact: no wrong count shown (both sides fail closed), but the two implementations disagree on valid input. Reproduced by running on Python 3.12.3 (3.13 has the same limits). Fix: catch RecursionError as Unreadable and bound nesting on both sides; parse long integers with parse_int that maps over 16 digits to an unsafe marker; in compare-recount count 'both refused' only on exit 2 from Python.

**Reproduction**

/tmp/rp/c1: page 100..199 with one finalized EscrowOpened@110 whose data also holds 'junk': 100000 nested arrays. `node dist/cli.js census --fixtures /tmp/rp/c1` -> exit 0, complete. `python3 -I tools/recount.py --dir /tmp/rp/c1 --genesis 0x99c172c095340ace980631f9123544c6bebd4839f3437e463533422d8be2d631 --from 100 --to 199` -> exit 1, 'RecursionError: maximum recursion depth exceeded while decoding a JSON array'. /tmp/rp/c2: two opens, the second with index 1 followed by 5000 zeros -> TypeScript exit 0, complete, malformed_dropped 1; Python exit 2 'Exceeds the limit (4300 digits) for integer string conversion'.

### 13. Low: Source scan misses computed access on process and the Function constructor reached through .constructor, and does not scan src subfolders

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

```
  const files = readdirSync(dir).filter((f) => f.endsWith(".ts") && f !== "guard.ts");
```

RULES.md 4 says the scan fails any src module that uses process.getBuiltinModule, process.binding, eval or Function, and computed access, so that an aliased or computed form cannot slip past. The regexes match only literal spellings, and readdirSync(dir) is not recursive while tsconfig compiles everything under src. The runtime block in test/setup.ts still catches these if a test runs them, so this is a gap in the static layer, not a live path; no current src file uses these forms. Fix 13 is partly done. Impact: no current network/sign/spend path. Reproduced by running the scan's own regex list. Fix: scan recursively and ban `process[`, `Reflect.get(process`, `Reflect.` and `.constructor.constructor` in src.

**Reproduction**

/tmp/rpscripts/scan.mjs takes the `forbidden` array verbatim from test/guard.test.ts (from 'const NET =' to 'const dir') and tests lines: 'const m = (process as any)["getBuiltin" + "Module"]("node:" + "https");' -> MISSED; 'const gb = Reflect.get(process, "getBuiltin" + "Module"); ...' -> MISSED; 'const F = [].constructor.constructor; const g = F("return this")();' -> MISSED; 'const b = (process as any)["bind" + "ing"]("spawn_sync");' -> MISSED; control 'process.getBuiltinModule("node:https")' -> CAUGHT. `node -e 'console.log(typeof process["getBuiltin"+"Module"]("node:child_"+"process").spawnSync)'` -> function. tsconfig include ['src'], but a file src/sub/x.ts is not in readdirSync(src).

### 14. Low: Mutation runner fails every control when the temp folder is reached through a symlink, so 206 of 206 cannot be shown there (fails closed)

`observatory/scripts/mutate.mjs:43`

```
const work = mkdtempSync(join(tmpdir(), "obs-mutate-"));
```

The work folder comes from os.tmpdir() without realpath. Vitest reports test file names by real path, and relative(copyPkg, f.name) then gives '../../../../realtmp/...', so the control marks every file missing and every mutant 'error'. It fails closed (no false pass), but on stock macOS (/var/folders -> /private/var) the mutants step always exits 1. mutate-canary.mjs and canary.mjs use the same pattern. Reproduced by running. Fix: realpathSync(mkdtempSync(...)).

**Reproduction**

mkdir /tmp/realtmp; ln -s /tmp/realtmp /tmp/symtmp; `TMPDIR=/tmp/symtmp node scripts/mutate.mjs mutants/census.json` -> exit 1, control_before {outcome:'control-fail', exit:0, files:[{file:'../../../../realtmp/obs-mutate-*/repo/observatory/test/census.test.ts', status:'passed', tests:162}], missing:['test/census.test.ts']}, summary killed 0, other 31. Expected: control-pass and 31 killed, as without the symlink.

### 15. Info: Mutation runner applies mutants with String.prototype.replace, which expands $& / $' / $` / $$ in the replacement

`observatory/scripts/mutate.mjs:160`

```
      writeFileSync(target, original.replace(m.find, m.replace));
```

original.replace(m.find, m.replace) with a string pattern still expands $-patterns in the replacement, so a future mutant containing $& would be written differently from what mutants.json records. No current mutant has such a sequence, so no present result is wrong. Found by reading; the replace behaviour was reproduced. Fix: original.replace(m.find, () => m.replace) or split/join.

**Reproduction**

`node -e 'console.log("a+b".replace("b","$&$&"))'` -> a+bb (not a+$&$&). A mutant {find:'b', replace:'$&$&'} would write 'bb'.

---

Judge's submission `9a328272cbc82fb339a26988ff07e5e233ac992758caf0775f1bc596590593ad`, 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.
