# Audit report

> Second round on PaidOraclePilot (src/PaidOraclePilot.sol) and its tally builder (pilot/build-tally.mjs). Round one was job 3d5324e9: its ten findings are in audit/work-pilot-3d5324e9/FINDINGS.md and how each was resolved, with its regression test, is in audit/work-pilot-3d5324e9/RESOLUTION.md. The changes are the diff 5f40fa02644d72350857b8f4d6b1a11f32925fa3..953d7c7 (git diff 5f40fa0 953d7c7): 548b006 fixes round one, 953d7c7 makes the builder take a job's LATEST frozen record within REVISION_DAYS (the plane can credit split panels late, plane PR #630). Read pilot/README.md for the design. Tests: forge test (offline) and forge test --match-path test/fork/PaidOraclePilot.t.sol --fork-url <mainnet rpc> (15 tests against the live imdUSD vault 0x0b69F8DA8D8cE521d74Ea69a7A3475B77302EF82, its Parameters and the plane's ERC-8004 adapter; only feed freshness is mocked).
>
> Answer each with a concrete reproduction or a clear no:
> 1. Does each fix close its finding fully, including the variants the round-one reports describe? Does each regression test fail on the round-one code for the right reason, not a different revert?
> 2. Contiguity: is fromBlock == nextFromBlock plus a veto resetting nextFromBlock to the struck epoch's fromBlock correct in every sequence (veto, recount, veto again, retire in between)? The veto resets it on the assumption that only the latest epoch can be inside its 24 h window because epochs are at least 6 days apart: is that assumption airtight?
> 3. retire(): can it release more than was committed, be used to pay or block anything it should not, interact badly with an epoch being paid in batches around the 30-day mark, or let the lifetime cap be exceeded in total minted? Is leaving a retired epoch's window counted the right choice?
> 4. controllerOf now reads the adapter's answer as three raw words: any answer that still reverts the batch, or a well-formed binding it now wrongly rejects?
> 5. Requiring toBlock < block.number: any case where an honest poster cannot post (reorgs, the builder's own head read, a window ending at the head)?
> 6. Builder: is every case that could hide paid work now a refusal rather than a skip, and is every remaining skip a definite exclusion (not an oracle request, not paid in IMD, Intake status 1 or 2, a duplicate oracle request)? Can a refusal become permanent (a request that can never be proven blocking every later epoch), and if so what should the operator do? Does --pilot mode read the right epoch and window start?
> 7. Late revisions (953d7c7): can a job's credit change after its epoch is posted, so agents are paid twice, paid under the wrong version or missed? Is taking the latest record within REVISION_DAYS deterministic (two operators, same window, same root)? Should an epoch wait REVISION_DAYS before it is built?
> 8. Anything new the changes introduce.
>
> Not deployed yet. Report findings with severity and a reproduction.

| | |
|---|---|
| Repository | https://github.com/fa11up/infer-protocol |
| Commit | `953d7c751f972b70d670db83032283eed7c64974` |
| Job | `d516fafb-d86b-4b89-9854-74d87ae15f26` |
| Judged | 2026-10-29 02:58 UTC |
| Findings | 1 medium · 5 low · 5 info |

Four agents audited the code as it is at `953d7c7`, 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: Builder: 'latest frozen version within REVISION_DAYS' is read at build time, so the root depends on WHEN the window is built; late credit is missed and two operators get different roots

`pilot/build-tally.mjs:204`

```
  for (let k = -1; k <= REVISION_DAYS; k++) {
```

Commit 953d7c7 takes the LAST leaf found for the job in the receipts of day-1..day+REVISION_DAYS. receipt(d) (line 137) returns null when WorkRegistry.latest(dayJobId(d)) is zero, and the loop treats null exactly like 'the job was not revised that day'. A day not anchored YET is therefore indistinguishable from a day with nothing, and nothing in the builder requires the window's last answer day + REVISION_DAYS to be anchored: the only time rule is toBlock < head (line 63). So the result depends on the wall-clock time of the run. (1) A build made soon after the window ends (the normal case) reads the ORIGINAL credit of every request answered in the last ~REVISION_DAYS; a revision anchored later (the split-panel late credit this commit exists for) is never paid, because the request is counted only in the window holding its Completed block and the window is consumed once posted (retire does not hand it back). A revision that removes an agent is the reverse: that agent was paid under a superseded version. No double payment across epochs. (2) Determinism fails: a governor rebuilding one or two days later reads more receipts and gets a different root, so the 24 h veto cannot tell an honest early build from a wrong root, and the README's 'rebuildable by anyone' property does not hold. (3) The refusal/success outcome is itself time dependent: a first frozen version with an empty oracleAcceptance refuses ('do not all answer this request'), and a superseded document can disappear from the records API, in which case frozenDoc throws an uncaught error (exit 1, not the documented exit 2). Live evidence (2026-10-29, Tenderly RPC): WorkRegistry.latest for 2026-10-26..28 is anchored and for 2026-10-29 is 0x00..00, so a build today of a window ending yesterday reads days 10-29..10-31 as empty. Job 569e4f4c-dc58-4e3f-9c9e-bc09837092a8 (oracle request d73223d0-54b9-4b91-a727-576597042689, attestedAt 2026-10-14T00:43Z, status attested) is listed in the 2026-10-13 receipt (0x7514...7b9a) with documentHash 9559ecc8...85fc, which api.imd.fun/work-records now answers 404 'unknown_record', and in the 2026-10-14 receipt (0xf35c...cff3) with documentHash 1c3fb8d5...9f96 naming agents 51553, 52713, 51046. A build between the Completed on 10-14 and the anchoring of the 10-14 receipt crashes on the 10-13 version (work record not found); a build afterwards pays three agents. Four specialists reported this (permissions, math, economics, flow); merged. Fix (builder only): before stage 3 refuse (exit 2) unless the receipt for addDays(day, REVISION_DAYS) is anchored for every credited request (or the run is past that day plus the plane's anchoring lag), treat a zero anchor for a past day as 'not proven yet'; write every receipt hash read, the head block and REVISION_DAYS into requests.json/tally.json; document in the README that an epoch's --to must be at least REVISION_DAYS + 1 days old.

**Reproduction**

State: request U Completed(status 0) at block B inside the window, attested on day D; the receipt for D lists job J with document v1 crediting agents {A,B}; on D+2 the plane anchors a receipt listing J with document v2 crediting {A,B,C}. Run 1 on D+1: receipt(D+1..D+3) return null (latest()==0), found = v1, shares A=B=0.75e18, root R1, exit 0, posted as epoch N; nextFromBlock = toBlock+1. Run 2 on D+4 with the same --pilot/--to: found = v2, shares A=B=C=0.5e18, root R2 != R1. Expected: one root per window, C paid 0.5 imdUSD or the run refuses. Actual: R1 is posted, C is never paid (propose for blocks <= toBlock reverts WindowNotContiguous), and a governor rebuilding on D+2 or later sees the honest epoch as wrong. Live instance: cast call 0xb6d0a187b050fa5bb0b87033a203f37becf4a775 'latest(bytes16)(bytes32)' with dayJobId('2026-10-13') and dayJobId('2026-10-14') both list jobId 569e4f4c-dc58-4e3f-9c9e-bc09837092a8 with different documentHash values; the first document is 404 at the records API, the second credits 3 agents.

### 2. Low: Builder: REVISION_DAYS and LOOKBACK change the root or the refuse/success outcome but are not written to tally.json, so a published tally is not reproducible from its own inputs

`pilot/build-tally.mjs:31`

```
const REVISION_DAYS = Number(process.env.REVISION_DAYS ?? 3);
```

The root is a function of the window, the chain, the frozen documents AND two environment inputs: REVISION_DAYS (which frozen version of a job is eligible, line 204) and LOOKBACK (whether a Requested event is found at all; a miss is a refusal, line 168). The summary written at lines 260-267 records bonusPerRequest and the script path as the reproducibility inputs but neither of these. A governor rebuilding with the defaults during the veto window gets a different result from a poster who exported REVISION_DAYS=0 or 7, and tally.json gives no hint why. Reported by the permissions specialist (with a live dry run: REVISION_DAYS=3 on 26194146..26215746 builds root 0xebaa5549...d824 while REVISION_DAYS=0 refuses two requests). Distinct from the time-dependence finding: its fix is to pin or record the inputs. Fix: make both explicit flags or constants and write them (plus the head block and every receipt hash read) into tally.json next to bonusPerRequest.

**Reproduction**

cd pilot; REVISION_DAYS=3 node build-tally.mjs --epoch 0 --from 26194146 --to 26215746 --out a -> a root; REVISION_DAYS=0 node build-tally.mjs --epoch 0 --from 26194146 --to 26215746 --out b -> exit 2 (jobs anchored a day after their attestedAt are 'not in the receipts'). a/tally.json has no field naming the value that produced it (summary keys: dryRun, pilot, epoch, fromBlock, toBlock, root, total, scaledToCap, bonusPerRequest, paidRequests, agents, skipped, script). Expected: every input that affects the root is recorded.

### 3. Low: Builder: a paid request that can never be proven blocks every later epoch permanently; contiguity makes 'end the window earlier' a one-time remedy and there is no recorded override

`pilot/build-tally.mjs:233`

```
  console.error("Nothing written. Post when those receipts are on chain, or end the window earlier.");
```

Round-one fix 7 rightly turned every uncertain case into a refusal (exit 2), but several refusal causes do not resolve by waiting: (a) a status-0 Completed whose uri does not match /\/oracle\/requests\/([0-9a-f-]{36})/ (another uri form, or an upper-case UUID, line 163/172); (b) GET /oracle/requests/<U> answering 404 for good (verified: an unknown id answers 404), or a final status other than 'attested' for a request the Intake completed with status 0 (round one saw 'disagreed' on such ids), lines 197-198; (c) a job absent from every receipt in day-1..day+REVISION_DAYS (a day the plane never anchored, or attestedAt missing so createdAt is used and the job is anchored later than +3), line 210; (d) a frozen document whose acceptance entries do not all name the request, line 215; (e) a receipt leaf whose document the records API no longer serves, where frozenDoc throws (uncaught, exit 1). The contract requires fromBlock == nextFromBlock (src/PaidOraclePilot.sol:220) and toBlock >= fromBlock, so a window cannot route around block B: 'end the window earlier' works once (--to B-1), after which nextFromBlock == B and every later run includes B and refuses again. The pilot's remaining lifetime budget is then stuck with no on-chain defect; the only ways out are editing the published script (breaking the 'built by a published script from public inputs' statement in the NatSpec), setPoster(0), or a hand-built root. README line 53 ('Wait, or end the window earlier; nothing is lost') is wrong for these cases. Four specialists reported it; merged. The frozen data already carries the mapping (every acceptance entry has document.oracle.attestation.requestId), so a live-API 404 need not be fatal. Fix: classify refusals as transient or permanent; fall back to scanning the receipts around the Completed block's day for a document whose entries all name U; add an explicit, recorded exclusion input (e.g. --exclude <intakeRequestId>:<reason>) written into tally.json's skipped so the governor sees it in the veto window; lower-case the uri before matching; correct the README sentence.

**Reproduction**

State: nextFromBlock = F. Intake Requested(R, payer, 'oracle.request@oracle-1', ..., asset IMD, amount 1e18) and Completed(R, status 0, hash, 'https://api.imd.fun/oracle/requests/U', true) at block B >= F, with GET /oracle/requests/U answering 404 (or status 'disagreed') permanently. node build-tally.mjs --pilot P --to T for any T >= B: stage 2 refuses 'the plane names no job for it (yet)', exit 2, nothing written. --to B-1 succeeds once; after posting, nextFromBlock == B and every later run refuses again. Expected: a documented way to exclude R visibly or prove U from frozen receipts. Actual: no epoch can be posted again; epochCount() stays constant while paid work after B accumulates uncounted.

### 4. Low: Builder lets a window end one block below an unfinalized head; a reorg after the log read can pay a Completed event twice across two epochs or never, and contiguity makes it uncorrectable

`pilot/build-tally.mjs:63`

```
if (toBlock >= head) throw new Error(`--to ${toBlock} must be before the current block ${head}`);
```

Question 5 answered: an honest poster can always post (block.number only grows, so any toBlock < head at build time satisfies toBlock < block.number at inclusion; verified propose accepts block.number - 1 and refuses block.number). The defect is the other direction: head is getBlockNumber() ('latest'), logs are read at 'latest', and nothing requires toBlock to be finalized. The two-provider agreement check catches a lagging provider, not a reorg that happens after both answered. Mainnet still sees occasional 1-2 block reorgs. If a Completed event in block toBlock is reorged out and re-included at toBlock+1 or later, epoch N (built before the reorg) pays it and epoch N+1 (window starting at toBlock+1, `seen` is per run) pays it again; if an event is reorged INTO the window after the read, neither epoch pays it. The posted root is accepted regardless and the window cannot be recounted after 24 h. Anchoring lag of the daily receipts makes this rare but does not prevent it (a request answered just before midnight UTC whose receipt is anchored quickly). Four specialists reported it; merged. Fix: bound --to by the finalized block (client.getBlock({ blockTag: 'finalized' }) from both providers) and record toBlock's block hash in tally.json so a rebuild detects a reorg; optionally require toBlock + FINALITY_DEPTH < block.number in propose().

**Reproduction**

Head H. Completed(R, 0, .., '/oracle/requests/U') is in block H-1 in both providers' view. node build-tally.mjs --pilot P --to H-1: accepted (H-1 < H); U credited in epoch N; the poster posts at block > H (accepted, H-1 < block.number). Block H-1 is reorged and the Intake completion lands in block H+1. Next build: nextFromBlock = H, window H..H+k contains Completed(R) at H+1, `seen` is empty, U credited again. Expected: each Completed counted once. Actual: U's agents receive 1.5 imdUSD twice (or, if the event moved into the window after the read, never).

### 5. Low: Builder --pilot mode reads epochCount() and nextFromBlock() from ONE provider with no cross-check; a stale read across a propose+veto pair yields a root for the wrong epoch number that propose() accep

`pilot/build-tally.mjs:50`

```
  epoch = await client.readContract({ address: args.pilot, abi: pilotAbi, functionName: "epochCount" });
```

Round-one fix 2/3 moved --epoch/--from from operator typing to chain reads, but the reads go to clients[0] only (mevblocker by default), with no block tag and no agreement check against clients[1], while every log read must agree between both providers precisely because a public RPC can answer wrongly without erroring. A wrong nextFromBlock is harmless (WindowNotContiguous). A wrong epochCount is not: the epoch number is hashed into every leaf, propose() cannot check it, and a stale provider CAN return a wrong epochCount with a correct nextFromBlock: epoch N (X..Y) is posted and vetoed on chain (nextFromBlock back to X, epochCount N+1) while the provider still serves the pre-propose state (epochCount N, nextFromBlock X). The build produces leaves (N, agent, amount) with fromBlock X; propose(root, total, X, Y') is accepted as epoch N+1; every pay() reverts BadProof; the governor can only retire it after 30 days and the window X..Y' is lost for good (see the retire finding). This is round one's 'mistyped --epoch' failure with the typo moved into the RPC. Reported by the permissions and flow specialists; merged. Fix: read epochCount, nextFromBlock and head from every client at the same block number and require agreement; warn when the latest epoch is still inside its veto window; optionally give propose() an expectedEpoch argument compared with _epochs.length.

**Reproduction**

Chain: epoch 3 (1000..1500) posted at T, vetoed at T+1h: epochCount()==4, nextFromBlock()==1000. Provider 0 lags and answers epochCount 3, nextFromBlock 1000. Build: leaves (3, agent, amount), fromBlock 1000, toBlock 1600, root R. propose(R, total, 1000, 1600, uri) succeeds as epoch 4 (fromBlock matches). After 24 h pay(4, ids, amounts, proofs) -> BadProof for every leaf; veto window closed; retire(4) only after 30 days; blocks 1000..1600 can never be recounted. Expected: the build refuses when the two providers disagree, as it does for logs.

### 6. Low: retire() never hands the window back, even when paid == 0 and the epoch is the latest, so the paid work in a mistaken epoch's window is lost although a safe recount was possible

`src/PaidOraclePilot.sol:194`

```
    /// bind a seat). Its window stays counted: a correct recount of it is not possible after this.
```

Question 3: keeping the window counted is right once any share was paid (a recount would double pay) or once a later epoch exists (contiguity). It is not right in the case retire() was written for: a mistaken root (wrong epoch number in the leaves, a typo) for which every pay() reverted BadProof, paid == 0, no later epoch. There the window could be handed back exactly as veto() does (nextFromBlock = e.fromBlock) with no double-payment risk, and the bonus for every paid request completed in it preserved. As written, the operator's only recovery for a wrong root that the Safe missed in its 24 h window is to lose that window's work for good. Round-one finding 2 asked for both the cap release and the floor fallback; the fix did the first only. Everything else about retire() is sound: released = total - paid never underflows (paid <= total by OverEpochTotal), veto and retire are mutually exclusive in time and each checks the other's flag, totalPaid <= totalCommitted <= LIFETIME_CAP holds through any order of pay/retire/propose (checked in test/scratch/Round2Judge.t.sol::test_retireStrandsOutstandingBatch_capsHold), and an epoch being paid in batches around day 30 cannot release more than its unpaid remainder. Reported by the math specialist. Fix: in retire(), `if (e.paid == 0 && epoch == _epochs.length - 1) nextFromBlock = e.fromBlock;` and document that a retired epoch with payments stays counted.

**Reproduction**

Pilot with nextFromBlock == 1. Poster posts a root whose leaves hash epoch 7 as epoch 0 over blocks 1..100, total 2e18. Governor misses the veto window; pay(0, [1], [2e18], [[]]) reverts BadProof. After 30 days governor calls retire(0): totalCommitted == 0, epochs(0).paid == 0. Poster proposes a corrected root for the same window propose(root', 2e18, 1, 100, '') -> reverts WindowNotContiguous(101). Expected: a recount of a window from which nothing was paid is accepted. Actual: the Intake-paid requests completed in blocks 1..100 can never be bonused. Reproduced offline: test/scratch/Round2Judge.t.sol::test_retireOfUnpaidLatestEpochLosesItsWindow passes on the current code (defect present).

### 7. Info: retire() is a governor power over any 30-day-old epoch, including one still being paid in batches: unpaid valid shares are stranded; NatSpec and README describe it only as 'for an epoch that cannot be

`src/PaidOraclePilot.sol:200`

```
        e.retired = true;
```

The only gates are the vetoed/retired flags and the 30-day delay. An epoch whose shares are valid and whose batches are being submitted around day 30 (feeds stale for weeks, Unpayable agents who bind late, batches nobody submitted yet) can be retired between batch 1 and batch 2; batch 2 then reverts EpochRetired and those agents lose their shares for good, while the released cap can be re-posted to other recipients. Accounting stays sound (released == total - paid; totalPaid <= totalCommitted <= LIFETIME_CAP in every sequence). It cannot pay anything, cannot be used before day 30, and is consistent with the governor's veto power, so this is a trust assumption to record rather than a bypass: it is not in 'Who holds what power' (NatSpec or README), which say retire is for epochs that cannot be paid. Three specialists reported it; merged. Suggested: state the power, and either an operational rule (retire only after a final pay() attempt in the same bundle) or a contract guard such as requiring no Paid event in the last VETO_WINDOW (a lastPaidAt per epoch).

**Reproduction**

Epoch 0 (agents 1: 1e18, 2: 2e18, total 3e18) posted; both agents resolve. Day 30: pay(0, [1], ..) pays 1e18. Governor: retire(0) -> totalCommitted 1e18, totalPaid 1e18. pay(0, [2], ..) -> EpochRetired although agent 2's proof is valid and its controller resolves. Reproduced offline in test/scratch/Round2Judge.t.sol::test_retireStrandsOutstandingBatch_capsHold (also checks the caps hold through 8 further full epochs).

### 8. Info: Regression test for round-one finding 4 does not exercise the finding's variant (the round-one contract accepts its whole sequence), and RESOLUTION's 'fails on old code with Panic 0x11' claim is not w

`test/fork/PaidOraclePilot.t.sol:287`

```
        pilot.propose(t.root, 3e18, 101, 200, ""); // epoch 2, narrower recount
```

Question 1. Every contract fix closes its finding including the variants (verified by reading and by test/scratch/Round2Judge.t.sol: any toBlock >= block.number refused, any gap refused, a recount with a LATER start refused with WindowNotContiguous(101), bit 200 in the address word and 256 in the standard word skip one share). But two regression claims do not hold. (a) Finding 4 was: vetoed [201,300] recounted as [250,300] (later START), vetoed again, floor stuck at 250. test_consecutiveVetoesLoseNoBlocks narrows the END instead ([101,300] -> [101,200]); under the old rule floor = last.fromBlock = 101 and the final propose(101,300) was accepted by the round-one code too. Compiled 5f40fa0's contract as OldPaidOraclePilot and replayed: test_oldCode_acceptsTheFinding4RegressionSequence passes (epoch 3 created on the old code); the finding's sequence with epoch 2 = [250,300] reverts WindowNotAfterPrevious on the old code and WindowNotContiguous(101) on the new (test_finding4Variant_oldFails_newRefusesNarrowedStart). (b) RESOLUTION.md line 5 says test_windowCannotEndInTheFuture 'fails on old code with Panic 0x11'; on the old code propose(.., 1, type(uint64).max) is ACCEPTED, so the first expectRevert fails with 'call did not revert as expected', and the Panic appears only on the NEXT propose (test_oldCode_acceptsMaxToBlock_panicOnlyOnNextPropose). The test file itself does not compile against 5f40fa0 (three-argument constructor, nextFromBlock, retire, new errors), so neither claim can have been checked with the file in the tree. (c) Builder fixes 6, 7 and 10 are marked 'manual' with no fixture test, so the skip/refuse classification can regress unnoticed; and every pilot test lives in test/fork, so `forge test` offline exercises none of the pilot (a mock vault/adapter pattern, as in the scratch test, works offline). Four specialists reported parts of this; merged. Fix: make epoch 2 in the test start at 250 and expect WindowNotContiguous(101); extend test_windowCannotEndInTheFuture with 'a later propose still succeeds'; correct RESOLUTION; add an offline unit test and a fixture-driven test for the builder's classification.

**Reproduction**

On the round-one contract (git show 5f40fa0:src/PaidOraclePilot.sol): propose(root,3e18,1,100); warp 6d; propose(root,3e18,101,300); veto(1); warp 6d; propose(root,3e18,101,200); veto(2); warp 6d; propose(root,3e18,101,300) -> ACCEPTED as epoch 3, i.e. the new test's sequence holds on the old code. With epoch 2 = propose(root,3e18,250,300): old code accepts it and the final propose reverts WindowNotAfterPrevious (the finding); new code refuses epoch 2 with WindowNotContiguous(101). Old code: propose(root,3e18,1,type(uint64).max) -> accepted; the next propose reverts with Panic 0x11. forge test --match-path test/scratch/Round2Judge.t.sol: 8 passed.

### 9. Info: Constructor does not bound firstFromBlock: type(uint64).max (or any block the chain will not reach) makes every propose() impossible with no setter; propose's @param says 'at most the current block' w

`src/PaidOraclePilot.sol:156`

```
        nextFromBlock = firstFromBlock;
```

New in 548b006: nextFromBlock is seeded from a constructor argument with no check. propose() needs toBlock >= fromBlock == nextFromBlock and toBlock < block.number, so firstFromBlock above the head only waits for the chain, but firstFromBlock = type(uint64).max makes every propose revert (InvalidRoot when toBlock < fromBlock, WindowInFuture when toBlock == max) and there is no governor setter for nextFromBlock. One-shot, deployment-time only, visible at the first propose, but the pilot can be installed through the 96 h Parameters timelock before that. Also a NatSpec nit from the same commit: line 211 says toBlock is 'at most the current block' while line 219 requires toBlock < block.number (README line 13 says 'ending before the current block', which matches the code). Reported by the permissions specialist. Fix: require(firstFromBlock <= block.number) in the constructor, or deploy from a script that reads the head; align the @param text.

**Reproduction**

new PaidOraclePilot(vault, poster, type(uint64).max); poster: propose(bytes32(1), 1e18, type(uint64).max, 5, '') -> InvalidRoot; propose(bytes32(1), 1e18, type(uint64).max, type(uint64).max, '') -> WindowInFuture; no input passes both checks. Reproduced offline in test/scratch/Round2Judge.t.sol::test_toBlockBoundaryAndFirstFromBlockMax.

### 10. Info: Builder drops 'not an oracle request' completions with a bare continue, so they are not listed in `skipped` as the README states

`pilot/build-tally.mjs:169`

```
    if (!actionOf(r.args.action).startsWith("oracle.request@")) continue; // not an oracle request: out of the pilot
```

pilot/README.md lines 47-49 say every definite exclusion is 'listed in `skipped`' and name 'not an oracle request' first. The code `continue`s before skip(), so a Completed whose Requested action is not oracle.request@ appears neither in the stage-1 count nor in tally.json's skipped list. A reviewer comparing the Intake's Completed count with the tally's candidate + skipped counts (as round one did: 117 Completed vs 103 oracle requests) cannot account for the difference from the published files. Also note the order: a non-oracle Completed whose Requested is older than LOOKBACK refuses the run (line 168 runs before line 169), which is a false refusal the operator can only clear by raising LOOKBACK. Three specialists reported it; merged. Fix: skip('not an oracle request') instead of continue, and apply the action filter before the lookback refusal.

**Reproduction**

Window with Completed(R, 0, ..) whose Requested action is 'premium.job@premium-1' paid in IMD. Run the builder: the stage-1 log shows neither a candidate nor a skip for R; tally.json skipped is empty. Expected per README: {intakeRequestId: R, skipped: 'not an oracle request'}.

### 11. Info: 'One bonus per oracle request' is enforced per run only: two Intake requests naming the same oracle request whose Completed events fall in different windows are both paid

`pilot/build-tally.mjs:193`

```
  if (seen.has(q.oracleId)) { q.skipped = "duplicate: another Intake request names the same oracle request"; continue; }
```

The round-one fix for finding 10 deduplicates by oracleId with a Set that lives for one run, so it covers only Completed events inside one window (across both INTAKES). The builder never reads earlier epochs' requests.json and the contract has no oracle-request key, so if the plane ever answers two Intake requests with one oracle request and the two Completed events land in different windows, each epoch pays the full 1.5 imdUSD for the same frozen document (the attestation check compares the ORACLE id, not the Intake id, so both pass). Plane-written-data robustness note; every oracle id in the dry-run window is distinct. Three specialists reported it; merged. Fix: seed `seen` from the oracleIds of every earlier non-vetoed epoch's published requests.json (via their EpochProposed uris), or document that dedupe is per window.

**Reproduction**

Completed(R1, 0, .., '/oracle/requests/U') at block 150 (epoch 0, window 1..200) and Completed(R2, 0, .., '/oracle/requests/U') at block 250 (epoch 1, window 201..300), both IMD-paid status 0, U's document crediting agents {1,2}. Epoch 0: shares 0.75e18 each; epoch 1: `seen` is empty, shares 0.75e18 each again. Expected (README 'once per oracle request'): R2 skipped as a duplicate. Actual: 3.0 imdUSD minted for one oracle request.

---

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