{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"ef1f6915-b9df-441d-b2e5-db5e9d9e9b35","kind":"audit","nodes":[{"acceptedSubmissionHash":"b5ddeebdd8cd2eb737578f303b6169dddc02fb5238ffddec94b30e41d04f5e74","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"3a4c64bad39c6665d3bbf290de7c8f299b7417dd82f452f707c4829283cbe06a","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"a61567f73e825cb7860f0c1e4ded20775157bf9a9b0586a95324861b2aebc454","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"a3c91eb8f6b9ec28aae51455f1cc9ab577aedacf291d851261dfd51163bf4c4c","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"f5c051d254280fd050295a139d73df452f0861a7270fd03ec7283a5c4585a170","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Audit commit cb6ca724441c9aebf9a38cb4d7426218e0712723 against report 1053af02-a430-49b3-a914-75620c17c801. Reproduce the prior verdict-shopping, swapped-receipt, UUID-reservation and 200k-gas findings against the new code. Adversarially check discardReviewReceipt and releaseReservation for fabricated mismatches, unauthorized use, cross-request verdicts and replay; verify receipt-first gas with a realistic ERC-1271 signer and one-attempt callback. Check listing/ad escrow regressions. State unresolved findings and live-Intake assumptions. Do not deploy.","parentJobId":null,"planHash":"40a8553bba93b274df19960c015f9d0fca356793d377dad6dd232aa734942261","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"ef1f6915-b9df-441d-b2e5-db5e9d9e9b35","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"52258","feedbackHash":"77f5202464e91337e51022baa5917dfab52a75b3312bfb0cf2bdb4c3834199a0","nodeKey":"audit_economics","submissionHash":"b5ddeebdd8cd2eb737578f303b6169dddc02fb5238ffddec94b30e41d04f5e74","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51043","feedbackHash":"2a54d594f88e27517e5971ef6a6ce5200551da11b495a697a5e4e4df6311f99a","nodeKey":"audit_flow","submissionHash":"3a4c64bad39c6665d3bbf290de7c8f299b7417dd82f452f707c4829283cbe06a","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51482","feedbackHash":"147f563cdbb3c8248e79d64dd26a0edcea5517dd41c4dee7e6a5631c5de4c78d","nodeKey":"audit_judge","submissionHash":"a61567f73e825cb7860f0c1e4ded20775157bf9a9b0586a95324861b2aebc454","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52257","feedbackHash":"cba0062e8073f16a12dafdb6f95dc1da760446036e3f904f7ba8d6e8e8cd8e23","nodeKey":"audit_math","submissionHash":"a3c91eb8f6b9ec28aae51455f1cc9ab577aedacf291d851261dfd51163bf4c4c","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50966","feedbackHash":"ab7007f39021ddce61e3e409d2ca04039dd470abcc06665938821f3ab2ae5375","nodeKey":"audit_permissions","submissionHash":"f5c051d254280fd050295a139d73df452f0861a7270fd03ec7283a5c4585a170","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"eaf9bf816d0afdf625a52ba1795341e1367f07fad726540922d0ba5c2fc9f6dc","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"2565f234b0a569e9","findings":[{"citation":"resolved","description":"Audit 1053af02 finding 1 (requester discards a retained NotPassed and re-asks) is closed for answers that have already landed: expireReview now refuses any ask with a stored receipt. The remaining path is ordering-based. After job.reviewDeadline (default 1 day, minimum 1 hour) the requester may expire a Pending ask with no receipt. A signed negative answer that the Intake delivers after that deadline is visible in the public mempool as the Intake's callback transaction. The requester can front-run it with expireReview, the Intake's single attempt then reverts UnknownOrSettled, the false verdict is never recorded on chain, job.review becomes Unanswered and requestReview is permitted again at the normal price. Nothing on chain distinguishes this from an honest abandonment of an unanswered ask. The project documents 'expiry and delivery race by transaction order' as accepted, so this is reported as low, not as a regression of the medium finding; the window only exists when the panel answers after the local deadline and the chain exposes pending transactions. Preconditions: unbound or bound ask, now > reviewDeadline, Intake callback not yet mined, requester watches the mempool. Fix that preserves requester abandonment: make expiry two-step (announce, then finalize no earlier than N blocks later) so an in-flight delivery can still be mined between the two steps and, once stored, blocks the finalize; or let the owner alone close stale asks; or snapshot a minimum reviewTimeout comfortably above the oracle's validForSeconds plus expected panel latency.","line":609,"path":"src/imd/HookJobRequester.sol","reproduction":"setTimeouts(7 days, 1 hours). Creator opens a job, Intake completes it, creator calls requestReview -> askId (unbound). vm.warp(now + 1 hours + 1). Oracle signs Attestation{answer=abi.encode(false), issuedAt=now, expiresAt=now+1h, panel 7/5/6} for askId. Tx 1 (creator): expireReview(jobId) succeeds -> review=Unanswered, reviewOf[askId]=0. Tx 2 (Intake): complete(askId, abi.encode(askId, a, sig)) returns called=false (callback reverted UnknownOrSettled); getReviewReceipt(askId).oracleRequestId == 0; no NotPassed, no event carrying the answer. Tx 3 (creator): requestReview(jobId) succeeds with a new Intake ID and review=Pending. Expected: a signed negative answer that the Intake delivers cannot be selected away by the requester; actual: it is lost and a new ask is bought. Scratch test test/scratch/ExpiryRace.t.sol::testRequesterFrontRunsLateNegativeDeliveryAndReasks passes on this code, demonstrating the sequence.","severity":"low","snippet":"        if (_reviewReceipts[intakeRequestId].oracleRequestId != bytes32(0)) {","title":"Residual verdict shopping: requester can front-run a late NotPassed delivery with expireReview and buy a fresh ask"},{"citation":"resolved","description":"Adversarial check of the recovery path. Authorization: discardReviewReceipt and releaseReservation are onlyOwner on every path; creator, stranger and the Intake are refused (confirmed by ReviewRecovery tests and by reading StoreAdmin). Replay: after discard reviewOf is deleted so a second discard, bindReview, onReviewResult and onReviewFailure for that Intake ID all revert UnknownOrSettled; after release the mapping is cleared so a second release reverts ReservationNotReleasable. Cross-request: discard never reserves or consumes any UUID and bindReview can only finalize the receipt stored on the same Intake ID, so a receipt cannot be moved to answer another ask. Fabricated mismatch: the only on-chain requirement is that the five supplied fields are non-degenerate and differ from the receipt in at least one field. The owner can pass the receipt's own fields with toBlock+1 and close a correctly paired NotPassed receipt as Unanswered, after which the requester may buy another ask. The ReviewReceiptDiscarded event then records that invented context under the name authenticatedOracleRequestId. This is inside the documented owner-trust boundary (AUDIT_1053AF02.md, IMD_INTEGRATION.md 'Owner recovery'), so it is informational, not a defect; it is recorded here because the task asked for fabricated mismatches to be checked and because the event field name may lead off-chain readers to treat owner input as evidence. If the requester and owner are the same party (store operator reviewing their own jobs) this collapses the verdict-shopping protection entirely. Mitigations that keep the design: emit the context as 'claimed' rather than 'authenticated' in the static reader, require a multisig owner, and consider a minimum age or a timelock on discard so a receipt is visible before it is closed.","line":450,"path":"src/imd/HookJobRequester.sol","reproduction":"Creator opens a job, Intake completes it, requestReview -> askId (unbound). Intake delivers a valid signed Attestation a with answer=false; getReviewReceipt(askId).oracleRequestId == a.requestId. Owner calls discardReviewReceipt(askId, keccak256(body), a.requestId, a.questionHash, a.chainId, a.fromBlock, a.toBlock + 1). Expected under an evidence-based reading: refused because the receipt is the genuine answer. Actual: succeeds, discardedReviewReceipts[askId]=true, job.review=Unanswered, ReviewReceiptDiscarded emitted with authenticatedOracleRequestId == a.requestId and toBlock == a.toBlock+1; creator's requestReview(jobId) then succeeds. Calling it with a.toBlock unchanged reverts ReviewReceiptMatches, matching testFuzzOwnerCannotDiscardReceiptWithMatchingContext.","severity":"info","snippet":"        if (_receiptMatches(receipt, oracleRequestId, questionHash, questionChainId, fromBlock, toBlock)) {","title":"discardReviewReceipt accepts any owner-supplied differing context; a fabricated one-field mismatch closes a genuine negative receipt (documented owner trust, unverifiable on chain)"},{"citation":"resolved","description":"Once a receipt is stored, expireReview reverts ReviewAlreadyReceived and requestReview reverts NotReviewable, so the only transitions out of Pending are bindReview or discardReviewReceipt by the owner. This is the intended consequence of closing the verdict-shopping path and is documented ('Owner availability for confirmation/recovery is required'). It is listed as an unresolved liveness assumption rather than a defect: a lost owner key, an unresponsive multisig, or a receipt whose UUID was later consumed through another ask (bindReview then reverts DuplicateRequestId and only a differing-context discard remains) leaves the requester's fee spent with no verdict and no retry. The duplicate case also forces the owner to supply some context that differs from the receipt even when the protocol produced none, which is the same unverifiable input described in the previous finding.","line":347,"path":"src/imd/HookJobRequester.sol","reproduction":"askA and askB both unbound; Intake delivers the same signed attestation a (UUID X) to askB first, then to askA. Owner binds askA -> consumed[X]=true. Owner bindReview(askB, bodyB, X, ...) reverts DuplicateRequestId(X). Creator expireReview(jobB) after the deadline reverts ReviewAlreadyReceived(askB); creator requestReview(jobB) reverts NotReviewable(jobB). jobB remains Pending indefinitely unless the owner calls discardReviewReceipt(askB, ...) with a context that differs from a. Expected: a requester-side or time-based exit exists for an ask whose receipt can provably never finalize; actual: none.","severity":"info","snippet":"                || (job.review != ReviewStatus.None && job.review != ReviewStatus.Unanswered)","title":"Receipt-holding ask has no requester exit: without owner action a paid ask stays Pending forever"},{"citation":"resolved","description":"Independent re-measurement of audit 1053af02 finding 2. The repository's figures were reproduced (ECDSA 125,266; local ThresholdReceiptSigner 5-of-7 170,359; registry with 22 extra cold reads 187,513; bound-path figures 135,137 and 152,310). A new model with the structure of Safe v1.4.1 (proxy delegatecall to a singleton, FallbackManager call into a CompatibilityFallbackHandler, encodeMessageDataForSafe with domainSeparator callback, checkSignatures over a sentinel owner linked list with one cold SLOAD and one ecrecover per signature, 5-of-7, 325-byte signature) was deployed cold and driven through MockIntake.complete with vm.cool on the requester, proxy and handler: isValidSignature alone costs about 49,370 gas, the receipt path needs 170,609 forwarded gas, and the exact-200,000 one-attempt call succeeds and stores the receipt. Headroom is about 29k, consistent with the documented 60k cold-signer ceiling. Observations: the receipt path is dominated by four zero-to-nonzero SSTOREs (about 88k); any ERC-1271 signer over roughly 74k cold (for example a Safe whose owner set includes a nested contract signer, or a registry with more than about 25 cold policy reads) still loses the one-shot answer and the fee, as the 64-read fixture shows. The published Ethereum oracle signer 0x5598aa9146215bc13eb26f2c692ad1461fd32982 currently has no code (cast code on a public Ethereum RPC on 2026-10-16 returned 0x), so the live path today is plain ECDSA at about 125k; Robinhood Chain was not checked. No defect; recorded as verification evidence and as the requalification trigger if the signer gains code.","line":552,"path":"src/imd/HookJobRequester.sol","reproduction":"Scratch test test/scratch/SafeLikeReceiptGas.t.sol::testSafeLikeFiveOfSevenReceiptFirstGas: setOracleSigner(SafeLikeProxy); unbound ask; attestation answer=false signed by 5 of 7 members over the Safe message hash keccak256(0x1901 || safe.domainSeparator() || keccak256(abi.encode(SAFE_MSG_TYPEHASH, keccak256(abi.encode(digest))))); binary search over call{gas: cap} with vm.snapshotState/vm.cool each trial gives 170,609; intake.complete(askId, abi.encode(askId, a, sig)) with STIPEND=200_000 returns true and getReviewReceipt(askId).oracleRequestId == a.requestId. Expected and actual agree.","severity":"info","snippet":"            _reviewReceipts[requestId] = StoredReviewReceipt({","title":"Receipt-first callback gas verified with a Safe-shaped 5-of-7 ERC-1271 signer: 170,609 forwarded gas, fits the 200k one-attempt call"},{"citation":"resolved","description":"Everything in the review flow was exercised only against test/helpers/MockIntake.sol. The following are assumptions about api.imdnotdead.fun's Intake v2 and oracle that no local test can discharge and that the prior report also left open: (1) job.open callback ABI onJobResult(bytes32,bytes) and failure ABI onJobFailure(bytes32,uint8,bytes32,uint16,uint16,bytes); a different shape reverts inside the Intake's try and the job can only be expired. (2) The Intake delivers exactly once, asynchronously, with at least 200,000 gas forwarded, and does not call the failure function after a reverted result callback; the mock settles on first attempt either way. If the live Intake does call onReviewFailure after a failed delivery, the recovery sequence in testFuzzReleaseUnusedReservationAfterExpiryOrFailureThenFinalizeRetry becomes the normal path and a replayed old signature into the retry passes AttestationPredatesAsk only if issued within 300 s before the retry's askedAt. (3) An authenticated post-delivery source for Intake-ID to oracle-UUID mapping, canonical questionHash, questionChainId and resolved fromBlock/toBlock exists; the contract offers no fallback and a receipt never finalizes without it. (4) Oracle UUIDs are unique per request; releaseReservation exists only for protocol-side reuse and must be called before the retry's single callback. (5) Oracle issuedAt is within 300 s behind the ask's askedAt and expiresAt (validForSeconds 3600) covers delivery latency; a panel slower than one hour yields AttestationExpired and a lost fee. (6) The published addresses: Intake 0xa43e6f75ee006411f79ac1c84120606c2330de82 has code on Ethereum and the oracle signer 0x5598aa9146215bc13eb26f2c692ad1461fd32982 has none, as checked once on 2026-10-16 over a public RPC; Robinhood Chain and the IMD token addresses were not checked. (7) The owner authenticates every binding and recovery truthfully and remains available. Store side: HookListingRegistry.sol, FeaturedPlacements.sol, ExactStoreToken.sol and StoreAdmin.sol are byte-identical to the audited commit 64e4068 (git diff shows no src/store changes); all 197 tests including both escrow invariant suites pass, and a re-read of purchase/cancel/settle/withdraw found conservation (totalAvailable + totalEscrowed + totalProceeds) preserved on every path, so no listing or ad-escrow regression was found.","line":296,"path":"src/imd/HookJobRequester.sol","reproduction":"Not a reproducible defect; this entry records the scope boundary. Concrete check performed: `cast code 0x5598aa9146215bc13eb26f2c692ad1461fd32982 --rpc-url <public Ethereum RPC>` returned 0x; `cast code 0xa43e6f75ee006411f79ac1c84120606c2330de82` returned bytecode; `git diff 64e4068 cb6ca72 --stat` lists no file under src/store; `forge test` at cb6ca72: 197 passed, 0 failed.","severity":"info","snippet":"    function onJobResult(bytes32 requestId, bytes calldata result) external onlyIntake {","title":"Live-Intake assumptions that remain unverified at this commit"}],"hash":"3a4c64bad39c6665d3bbf290de7c8f299b7417dd82f452f707c4829283cbe06a","nodeId":"acbb8e2d-2686-45cf-912b-4d3e45af5cee","outcome":"completed","summary":"The review is complete and the findings file is written and validated.\n\n**Outcome.** No new critical, high or medium defect was found in the review-receipt and recovery flow at commit cb6ca72. The four prior findings from report 1053af02 were reproduced against the new code and hold: a retained NotPassed can no longer be expired or re-asked, swapped receipts fail their true bindings and are closed by owner discard without stranding the genuine UUID, an unused terminal reservation can be released and the retry finalizes, and receipt-first callbacks fit the 200k stipend. All 197 project tests pass.\n\n**Findings written to `.imd-findings.json`** (one low, four informational):\n\n- **Low, residual verdict shopping.** After the local deadline, a requester who sees a late negative delivery in the mempool can front-run it with `expireReview`, lose the false verdict with no trace, and buy a fresh ask. The project documents this as a transaction-order race, so it is not a regression of the medium finding, but it remains a selection path. Reproduced in a scratch test.\n- **Info, fabricated mismatch.** `discardReviewReceipt` accepts any valid five-field context differing in one field. The owner can pass the receipt's own fields with toBlock plus one and close a genuine NotPassed. This sits inside the documented owner-trust boundary. The event names that input \"authenticated\", which readers should not treat as evidence.\n- **Info, no requester exit.** A receipt whose UUID was consumed through another ask can neither be bound, expired nor re-asked. Only an owner discard with a differing context frees it.\n- **Info, gas verified.** A Safe-shaped 5-of-7 ERC-1271 signer, built as proxy plus fallback handler plus owner linked list, needs 170,609 forwarded gas on the receipt path. The cold one-attempt 200k call succeeds. The published Ethereum oracle signer currently has no code, so the live path is plain ECDSA.\n- **Info, live-Intake assumptions.** Callback ABI, single asynchronous delivery, authenticated post-delivery UUID and question mapping, UUID uniqueness, panel latency versus the one-hour signature life, and unchecked Robinhood Chain addresses remain open.\n\n**Store side.** The listing registry and ad escrow sources are byte-identical to the audited commit. Conservation holds on every path and both invariant suites pass. No regression found.\n\nScratch tests live under `test/scratch/` and nothing else in the tree was changed. Nothing was deployed.","treeHash":null,"usage":{"cachedInputTokens":2044787,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":50328,"runtime":"claude","turns":42,"wallClockMs":742875}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"22c9b760d0a15149","findings":[{"citation":"resolved","description":"discardReviewReceipt only closes a receipt whose five-field context DIFFERS from the owner-supplied true context, and bindReview refuses a UUID that is already consumed or reserved by another ask (line 390). If an unbound ask holds a receipt whose context IS its true context, but the receipt's UUID has since been consumed (or reserved) through another ask, neither call succeeds: bind reverts DuplicateRequestId, discard reverts ReviewReceiptMatches, releaseReservation reverts (consumed), the requester cannot expireReview (ReviewAlreadyReceived), cannot requestReview (NotReviewable: Pending), and onReviewFailure also reverts (ReviewAlreadyReceived). The paid ask and its job stay Pending forever with no recovery path for anyone. Reachable when the live oracle assigns the same UUID to two live asks (the repo's own tests model protocol UUID reuse, and docs list UUID reuse policy as unverified) or when the owner binds another ask to that UUID first by mistake. Minimal fix preserving the design: in discardReviewReceipt, also allow discard (ignoring the context-match rule) when consumed[receipt.oracleRequestId] is true or intakeReviewOfOracle[receipt.oracleRequestId] is a different ask, because such a receipt can provably never finalize; keep the event marking why.","line":450,"path":"src/imd/HookJobRequester.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {HookJobRequester} from \"src/imd/HookJobRequester.sol\";\nimport {IIntake} from \"src/imd/IIntake.sol\";\nimport {OracleAttestation} from \"src/imd/OracleAttestation.sol\";\n\n/// @dev Minimal ERC-20 for the proof: returns true, no callbacks.\ncontract ProofToken {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n    }\n\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        if (allowance[from][msg.sender] != type(uint256).max) allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\n/// @dev Minimal Intake v2 stand-in: pulls the price, calls back once under the 200k stipend.\ncontract ProofIntake is IIntake {\n    struct Req {\n        address target;\n        bytes4 selector;\n        bool settled;\n    }\n\n    uint256 public price;\n    uint256 internal nonce;\n    mapping(bytes32 => Req) public reqs;\n\n    constructor(uint256 price_) {\n        price = price_;\n    }\n\n    function priceOf(bytes32, address) external view returns (uint256) {\n        return price;\n    }\n\n    function request(bytes32, bytes calldata, Callback calldata callback, address asset, uint256 amount)\n        external\n        payable\n        returns (bytes32 id)\n    {\n        return _request(callback, asset, amount);\n    }\n\n    function requestWithFailure(bytes32, bytes calldata, Callback calldata callback, bytes4, address asset, uint256 amount)\n        external\n        payable\n        returns (bytes32 id)\n    {\n        return _request(callback, asset, amount);\n    }\n\n    function _request(Callback calldata callback, address asset, uint256 amount) internal returns (bytes32 id) {\n        require(amount == price, \"wrong amount\");\n        require(ProofToken(asset).transferFrom(msg.sender, address(this), amount), \"pull failed\");\n        id = keccak256(abi.encode(address(this), ++nonce));\n        reqs[id] = Req(callback.target, callback.selector, false);\n    }\n\n    function complete(bytes32 id, bytes calldata args) external returns (bool called) {\n        Req storage r = reqs[id];\n        require(r.target != address(0) && !r.settled, \"not pending\");\n        r.settled = true;\n        (called,) = r.target.call{gas: 200_000}(bytes.concat(r.selector, args));\n    }\n}\n\n/// @notice Fails on the current code: an unbound receipt whose true context matches but whose UUID\n/// was consumed through another ask has no recovery path. Passes once the owner can close it.\ncontract StuckReceiptProofTest is Test {\n    bytes32 internal constant JOB_ACTION = \"job.open@test\";\n    bytes32 internal constant ORACLE_ACTION = \"oracle.request@oracle-1\";\n    uint256 internal constant PRICE = 0.5 ether;\n\n    ProofToken internal imd;\n    ProofIntake internal intake;\n    HookJobRequester internal requester;\n    address internal owner = makeAddr(\"owner\");\n    address internal creator = makeAddr(\"creator\");\n    uint256 internal signerKey = uint256(keccak256(\"proof oracle key\"));\n\n    function setUp() public {\n        vm.chainId(31337);\n        vm.warp(1_800_000_000);\n        imd = new ProofToken();\n        intake = new ProofIntake(PRICE);\n        requester = new HookJobRequester(owner, address(intake), address(imd), vm.addr(signerKey), PRICE);\n        vm.startPrank(owner);\n        requester.setJobTemplate(\n            JOB_ACTION,\n            '{\"steps\":[\"forge build\",\"forge test\",\"adversarial review\"],\"specHash\":\"',\n            '\",\"request\":\"<brief>',\n            '</brief>\"}'\n        );\n        requester.setReviewTemplate(ORACLE_ACTION, '{\"question\":\"', '\"}', 5, 4);\n        vm.stopPrank();\n        imd.mint(creator, 10 ether);\n        vm.prank(creator);\n        imd.approve(address(requester), type(uint256).max);\n    }\n\n    function _unboundAsk() internal returns (bytes32 jobId, bytes32 askId) {\n        vm.prank(creator);\n        jobId = requester.openJob(keccak256(\"spec\"), \"A swap cap hook\");\n        assertTrue(intake.complete(jobId, abi.encode(jobId, bytes(\"ipfs://result\"))));\n        vm.prank(creator);\n        askId = requester.requestReview(jobId);\n    }\n\n    function _attestation(bytes32 askId, bytes32 uuid, bool passed)\n        internal\n        view\n        returns (OracleAttestation.Attestation memory a)\n    {\n        a = OracleAttestation.Attestation({\n            requestId: uuid,\n            chainId: 31337,\n            questionHash: keccak256(abi.encode(\"question\", askId)),\n            answerType: OracleAttestation.ANSWER_BOOL,\n            answer: abi.encode(passed),\n            figure: 0,\n            fromBlock: 1,\n            toBlock: 2,\n            blockHash: bytes32(uint256(3)),\n            panelJobId: keccak256(\"panel job\"),\n            panelSize: 7,\n            quorum: 5,\n            agreed: 6,\n            issuedAt: uint64(block.timestamp),\n            expiresAt: uint64(block.timestamp + 1 hours)\n        });\n    }\n\n    function _deliver(bytes32 askId, OracleAttestation.Attestation memory a) internal returns (bool) {\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerKey, requester.attestationDigest(a));\n        return intake.complete(askId, abi.encode(askId, a, abi.encodePacked(r, s, v)));\n    }\n\n    function testOwnerCanCloseReceiptWhoseUuidWasConsumedElsewhere() public {\n        (, bytes32 askA) = _unboundAsk();\n        (bytes32 jobB, bytes32 askB) = _unboundAsk();\n        bytes32 uuid = bytes32(bytes16(keccak256(\"shared protocol UUID\")));\n        OracleAttestation.Attestation memory a = _attestation(askA, uuid, true);\n        OracleAttestation.Attestation memory b = _attestation(askB, uuid, false);\n        assertTrue(_deliver(askA, a), \"receipt A retained\");\n        assertTrue(_deliver(askB, b), \"receipt B retained\");\n\n        // Owner authenticates A's context and finalizes it, consuming the shared UUID.\n        bytes32 bodyA = requester.getReviewAsk(askA).bodyHash;\n        vm.prank(owner);\n        requester.bindReview(askA, bodyA, uuid, a.questionHash, a.chainId, a.fromBlock, a.toBlock);\n        assertTrue(requester.consumed(uuid));\n\n        // B's receipt matches B's true context but its UUID is gone: bind is impossible by design.\n        bytes32 bodyB = requester.getReviewAsk(askB).bodyHash;\n        vm.prank(owner);\n        vm.expectRevert(abi.encodeWithSelector(HookJobRequester.DuplicateRequestId.selector, uuid));\n        requester.bindReview(askB, bodyB, uuid, b.questionHash, b.chainId, b.fromBlock, b.toBlock);\n\n        // The requester has no path either.\n        vm.warp(requester.getReviewAsk(askB).deadline + 1);\n        vm.prank(creator);\n        vm.expectRevert(abi.encodeWithSelector(HookJobRequester.ReviewAlreadyReceived.selector, askB));\n        requester.expireReview(jobB);\n\n        // Expected: the owner can close the dead receipt so the requester may retry.\n        // Actual on current code: ReviewReceiptMatches, and jobB is Pending forever.\n        vm.prank(owner);\n        requester.discardReviewReceipt(askB, bodyB, uuid, b.questionHash, b.chainId, b.fromBlock, b.toBlock);\n        assertEq(uint8(requester.getJob(jobB).review), uint8(HookJobRequester.ReviewStatus.Unanswered));\n        vm.prank(creator);\n        requester.requestReview(jobB);\n    }\n}","reproduction":"State: two delivered jobs A and B, each with an unbound pending ask (askA, askB). Inputs: oracle delivers to askA an attestation a with UUID X; oracle delivers to askB an attestation b with the same UUID X but B's own questionHash (both callbacks succeed and store receipts, since an unbound receipt does not reserve X). Owner calls bindReview(askA, bodyA, X, a.questionHash, a.chainId, a.fromBlock, a.toBlock): job A finalized, consumed[X]=true. Then for askB: bindReview(askB, bodyB, X, b.questionHash, ...) reverts DuplicateRequestId(X); discardReviewReceipt(askB, bodyB, X, b.questionHash, b.chainId, b.fromBlock, b.toBlock) reverts ReviewReceiptMatches(askB); releaseReservation(X) reverts ReservationNotReleasable; at deadline+365 days creator's expireReview(jobB) reverts ReviewAlreadyReceived(askB); requestReview(jobB) reverts NotReviewable(jobB); intake's onReviewFailure(askB,...) reverts ReviewAlreadyReceived. Expected: the owner has some recovery path to close askB as Unanswered so the requester can retry. Actual: getJob(jobB).review stays Pending permanently. Reproduced in test/scratch/Audit.t.sol testStuckReceiptWhenUuidConsumedByAnotherAsk.","severity":"low","snippet":"        if (_receiptMatches(receipt, oracleRequestId, questionHash, questionChainId, fromBlock, toBlock)) {\n            revert ReviewReceiptMatches(intakeRequestId);\n        }","title":"Unbound receipt whose UUID is consumed or reserved elsewhere can never be finalized, discarded, expired or re-asked (permanent Pending)"},{"citation":"resolved","description":"After a bound ask is expired or failed without a receipt, its UUID reservation remains in intakeReviewOfOracle until the owner manually calls releaseReservation. The requester can re-ask immediately (job.review is Unanswered), and if the protocol reuses the UUID for the retry (the exact scenario the remediation of finding 4 models), the retry's one-attempt onReviewResult reverts here with DuplicateRequestId even though every condition releaseReservation checks (holder terminal, no receipt, not consumed) already holds at that moment. The Intake settles the attempt, the retry fee is spent, and the later owner release cannot restore it; the retry then sits Pending until the requester expires it and pays a third time. The docs acknowledge the ordering requirement but the contract does not enforce or help it. Minimal fix preserving the owner-release design: in onReviewResult (and bindReview's duplicate check at line 390), treat a reservation as not blocking when its holder is terminal (reviewOf[holder]==0), has no receipt and the UUID is unconsumed, i.e. the same predicate as releaseReservation, so the stale reservation is auto-superseded; keep releaseReservation for explicit cleanup.","line":548,"path":"src/imd/HookJobRequester.sol","reproduction":"State: delivered job J, ask A1 bound by owner to UUID X, no answer by reviewDeadline. Inputs: at deadline+1 creator calls expireReview(J) (A1 terminal, X still reserved by A1); creator calls requestReview(J) -> A2 (unbound); oracle delivers attestation with requestId X for A2 through the Intake's single 200k attempt BEFORE the owner calls releaseReservation(X). Expected: since X's holder A1 is terminal with no receipt and unconsumed (releaseReservation(X) would succeed), the receipt for A2 is retained. Actual: onReviewResult reverts DuplicateRequestId(X), MockIntake.complete returns false and marks the request settled, getReviewReceipt(A2).oracleRequestId == 0, intake balance shows the retry fee spent, job stays Pending; owner's subsequent releaseReservation(X) succeeds but the attempt is gone. Reproduced in test/scratch/Audit.t.sol testRetryCallbackLostWhileReservationIsReleasable.","severity":"low","snippet":"        if (reservedFor != bytes32(0) && reservedFor != requestId) revert DuplicateRequestId(a.requestId);","title":"Retry's single callback is rejected by a stale reservation that already satisfies the release predicate; fee lost before the owner can release"},{"citation":"resolved","description":"Finding 1 of report 1053af02 was fixed by refusing expiry once a receipt exists. The residual: once block.timestamp > reviewDeadline, a requester who sees the Intake's pending onReviewResult transaction carrying answer=false (public mempool on Ethereum/Sepolia; not on private-sequencer rollups) can submit expireReview in the same block ahead of it. expireReview deletes reviewOf, so the arriving callback reverts UnknownOrSettled inside the Intake's try, the signed NotPassed is lost without trace on chain, and the requester re-asks (paying again) for a fresh panel. A late Passed would simply be left to land. The requester chooses which verdicts survive, at the price of one fee per discarded late answer. ReviewRecovery.t.sol's testLateNotPassedReceiptCannotBeFrontRunByRequesterExpiry only covers the order receipt-then-expiry, not expiry-then-receipt in one block. Possible mitigations that keep requester-driven expiry: have onReviewResult accept a signed answer for an ask that was expired but whose job has not yet been re-asked (job.reviewRequestId == requestId and job.review == Unanswered), retaining it as a receipt and flipping the job back to Pending; or make expiry owner-assisted / add a delay between expireReview and requestReview so a late callback can land first. This is a design tradeoff for the owner to decide.","line":612,"path":"src/imd/HookJobRequester.sol","reproduction":"State: delivered job J with unbound pending ask A; oracle attestation a (answer=false) for A is broadcast after reviewDeadline. Inputs, same block, block.timestamp = deadline+1: (1) creator calls expireReview(J) -> succeeds, review=Unanswered, reviewOf[A] deleted; (2) Intake completes A with (A, a, sig) -> onReviewResult reverts UnknownOrSettled(A), complete returns false, no receipt stored. (3) creator calls requestReview(J) -> A2; a second panel answers true; owner binds -> getJob(J).review == Passed. Expected: a NotPassed answer that was already signed for the paid ask cannot be selectively discarded by the requester. Actual: requester's ordering decides; job ends Passed. Reproduced in test/scratch/Audit.t.sol testRequesterFrontRunsLateNotPassedWithExpiry.","severity":"low","snippet":"        delete reviewOf[intakeRequestId];\n        job.review = ReviewStatus.Unanswered;","title":"Requester can still verdict-shop a late answer by front-running the callback with expireReview"},{"citation":"resolved","description":"Documented in AUDIT_1053AF02.md and retained here as an explicit trust assumption, not a bypass: the contract cannot check that the owner-supplied true context is authentic, only that it is well-formed, matches the paid body hash and differs from the receipt. (a) Owner supplies any well-formed context differing in one field (e.g. questionHash ^ 1) for a correctly delivered NotPassed receipt: the receipt is marked discarded, the job becomes Unanswered and the requester may re-ask, which is verdict shopping by owner/requester collusion with no on-chain evidence other than the ReviewReceiptDiscarded event's authenticatedOracleRequestId. (b) Owner binds ask A to the context of a receipt that was job B's answer (misdelivered into A): _receiptMatches passes and job A is finalized with B's verdict (cross-request verdict). (c) Owner can front-run the Intake callback with bindReview to a wrong but well-formed context, making the bound-path callback revert ReviewAttestationMismatch and burning the requester's single attempt and fee. None of these are code defects given the stated design; they define what the owner key can do and should be stated to requesters and monitored via ReviewBound/ReviewReceiptDiscarded events. A monitoring-only hardening that keeps the design: emit the receipt's full context alongside the authenticated one in ReviewReceiptDiscarded (currently only receivedOracleRequestId is emitted) so an off-chain watcher can audit the claimed mismatch field by field.","line":453,"path":"src/imd/HookJobRequester.sol","reproduction":"(a) State: unbound ask A with receipt a (passed=false, delivered correctly). Input: owner calls discardReviewReceipt(A, bodyA, a.requestId, a.questionHash ^ bytes32(uint256(1)), a.chainId, a.fromBlock, a.toBlock). Expected (from a requester's viewpoint): a correct NotPassed is terminal. Actual: call succeeds, getJob(jobA).review == Unanswered, requester's requestReview(jobA) succeeds and a new panel may answer true. ReviewRecovery.t.sol testFuzzOwnerCanRecoverMismatchInAnyContextField exercises exactly this input path. (b) State: ask A (job A) holds misdelivered receipt b (job B's answer, passed=true). Input: owner calls bindReview(A, bodyA, b.requestId, b.questionHash, b.chainId, b.fromBlock, b.toBlock). Actual: job A finalized Passed from B's attestation.","severity":"info","snippet":"        discardedReviewReceipts[intakeRequestId] = true;\n        delete reviewOf[intakeRequestId];\n        jobs[jobRequestId].review = ReviewStatus.Unanswered;","title":"Owner trust: a fabricated mismatch in discardReviewReceipt erases a genuine NotPassed and re-opens the ask; bind can also graft another request's verdict"}],"hash":"a3c91eb8f6b9ec28aae51455f1cc9ab577aedacf291d851261dfd51163bf4c4c","nodeId":"47805322-ef1c-4528-9e79-fe9c6d5b7145","outcome":"completed","summary":"The review is complete and the findings file is written. The source tree is unchanged, with only the ignored scratch tests and the findings file added.\n\n**Outcome.** The four actionable findings from report 1053af02 are fixed as claimed at commit cb6ca72: a retained NotPassed receipt cannot be expired or re-asked, a swapped receipt can be closed by the owner without stranding the genuine UUID, an undelivered reservation can be released, and the receipt-first callback fits the one-attempt budget. All 197 existing tests pass. I found no new high or critical defect, and no regression in the listing or ad escrow. Four findings are recorded in `.imd-findings.json`, three Low and one Info.\n\n**Findings, each reproduced in test/scratch/Audit.t.sol:**\n\n- **Low, permanent Pending.** An unbound receipt whose context matches its true context, but whose UUID has been consumed or reserved through another ask, has no exit. Bind reverts as duplicate, discard reverts as matching, release reverts as consumed, and the requester can neither expire nor re-ask. Trigger: the oracle hands the same UUID to two live asks and the owner binds the other one first. A self-contained proof under test/scratch fails now and passes once discard accepts a provably dead receipt.\n- **Low, retry fee lost.** After expiry or failure of a bound ask, the stale reservation blocks the retry's single callback with DuplicateRequestId even though the release predicate already holds. The owner's later release cannot restore the spent attempt. Treating a releasable reservation as non-blocking inside the callback would close this without removing owner release.\n- **Low, residual verdict shopping.** Once the deadline passes, a requester who sees a late NotPassed callback in a public mempool can expire first in the same block. The callback then reverts as settled and the requester re-asks for a fresh panel. The existing front-run test only covers the receipt-then-expiry order. Mainnets are policy-rejected today, so this stays Low.\n- **Info, owner trust.** A fabricated one-field mismatch erases a correct NotPassed and reopens the ask, a misdelivered receipt can be bound as another job's verdict, and a pre-emptive wrong bind burns the requester's one attempt. These match the documented design, so they are stated as trust assumptions, not bypasses.\n\n**Gas.** I built a Safe-style signer with a proxy, delegatecall singleton, fallback handler and a 5-of-7 linked-list owner check. Cold verification cost and the receipt-first minimum both fit the stipend.\n\n| Measurement | Gas |\n|---|---:|\n| Safe-like isValidSignature, cold | 52,540 |\n| Receipt-first minimum forwarded gas | 171,741 |\n\n**Unresolved and assumed.** The live Intake callback ABI, action IDs, canonical question hashing and UUID reuse policy remain unverified, as the repository itself states. Whether UUIDs can repeat across live asks decides how reachable the first finding is. No Slither or Mythril run was available, and the twelve specialty passes were applied as one reviewer's passes, not as independent agents.","treeHash":null,"usage":{"cachedInputTokens":1742055,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":37724,"runtime":"claude","turns":27,"wallClockMs":681534}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"dedd4428da8fa41e","findings":[{"citation":"resolved","description":"Merged from audit_economics/audit_math/audit_flow/audit_permissions. The 1053af02 fix only blocks expiry once a receipt is stored. After reviewDeadline, a requester who sees the Intake's pending callback carrying answer=false (calldata) calls expireReview first: reviewOf is deleted, the one-shot onReviewResult reverts UnknownOrSettled, no receipt is kept, job becomes Unanswered and requestReview is allowed again. The requester keeps Passed answers and discards NotPassed ones for one fee per attempt. Fix: keep accepting a late, validly signed callback for an expired-but-not-re-asked Intake ID as a receipt (job back to Pending), or make expiry two-step/owner-assisted, or add a grace period longer than attestation validity.","line":609,"path":"src/imd/HookJobRequester.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {HookJobRequester} from \"src/imd/HookJobRequester.sol\";\nimport {IIntake} from \"src/imd/IIntake.sol\";\nimport {OracleAttestation} from \"src/imd/OracleAttestation.sol\";\n\ncontract PocToken {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n    }\n\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        if (allowance[from][msg.sender] != type(uint256).max) allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\n/// @dev One-shot Intake stand-in: 200k gas callback, settled even when the callback reverts.\ncontract PocIntake is IIntake {\n    struct Req {\n        address target;\n        bytes4 selector;\n        bool settled;\n        bytes body;\n    }\n\n    mapping(bytes32 => Req) public reqs;\n    uint256 internal nonce;\n    uint256 public price = 0.5 ether;\n\n    function priceOf(bytes32, address) external view returns (uint256) {\n        return price;\n    }\n\n    function request(bytes32, bytes calldata body, Callback calldata cb, address asset, uint256 amount)\n        external\n        payable\n        returns (bytes32 id)\n    {\n        return _req(body, cb, asset, amount);\n    }\n\n    function requestWithFailure(bytes32, bytes calldata body, Callback calldata cb, bytes4, address asset, uint256 amount)\n        external\n        payable\n        returns (bytes32 id)\n    {\n        return _req(body, cb, asset, amount);\n    }\n\n    function _req(bytes calldata body, Callback calldata cb, address asset, uint256 amount) internal returns (bytes32 id) {\n        PocToken(asset).transferFrom(msg.sender, address(this), amount);\n        id = keccak256(abi.encode(address(this), ++nonce));\n        reqs[id] = Req(cb.target, cb.selector, false, body);\n    }\n\n    function bodyOf(bytes32 id) external view returns (bytes memory) {\n        return reqs[id].body;\n    }\n\n    function complete(bytes32 id, bytes calldata args) external returns (bool called) {\n        Req storage r = reqs[id];\n        require(r.target != address(0) && !r.settled, \"not pending\");\n        r.settled = true;\n        (called,) = r.target.call{gas: 200_000}(bytes.concat(r.selector, args));\n    }\n}\n\n/// @notice Audit finding: a requester who sees a late NotPassed answer coming (Intake completion in\n/// the mempool, or the public panel result) front-runs it with `expireReview`, so the one-shot\n/// callback reverts, the negative verdict is never recorded, and the requester re-asks.\ncontract ExpiryFrontRunShoppingTest is Test {\n    PocToken internal imd;\n    PocIntake internal intake;\n    HookJobRequester internal requester;\n    address internal owner = makeAddr(\"owner\");\n    address internal creator = makeAddr(\"creator\");\n    uint256 internal signerKey = uint256(keccak256(\"poc oracle key\"));\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        imd = new PocToken();\n        intake = new PocIntake();\n        requester = new HookJobRequester(owner, address(intake), address(imd), vm.addr(signerKey), 0.5 ether);\n        vm.startPrank(owner);\n        requester.setJobTemplate(\"job\", '{\"steps\":\"forge build, forge test, adversarial review\",\"spec\":\"', '\",\"b\":\"', '\"}');\n        requester.setReviewTemplate(\"review\", '{\"q\":\"did job ', ' pass?\"}', 5, 4);\n        vm.stopPrank();\n        imd.mint(creator, 10 ether);\n        vm.prank(creator);\n        imd.approve(address(requester), type(uint256).max);\n    }\n\n    function _attestation(bytes32 askId, bool passed) internal view returns (OracleAttestation.Attestation memory) {\n        return OracleAttestation.Attestation({\n            requestId: bytes32(bytes16(keccak256(abi.encode(\"uuid\", askId)))),\n            chainId: 31337,\n            questionHash: keccak256(intake.bodyOf(askId)),\n            answerType: 0,\n            answer: abi.encode(passed),\n            figure: 0,\n            fromBlock: 1,\n            toBlock: 2,\n            blockHash: bytes32(uint256(3)),\n            panelJobId: keccak256(\"panel\"),\n            panelSize: 7,\n            quorum: 5,\n            agreed: 6,\n            issuedAt: uint64(block.timestamp),\n            expiresAt: uint64(block.timestamp + 1 hours)\n        });\n    }\n\n    function _signed(bytes32 askId, OracleAttestation.Attestation memory a) internal view returns (bytes memory) {\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerKey, requester.attestationDigest(a));\n        return abi.encode(askId, a, abi.encodePacked(r, s, v));\n    }\n\n    function testRequesterFrontRunsLateNotPassedWithExpiryAndReasks() public {\n        vm.prank(creator);\n        bytes32 jobId = requester.openJob(keccak256(\"spec\"), \"hook brief\");\n        assertTrue(intake.complete(jobId, abi.encode(jobId, bytes(\"ipfs://result\"))));\n        vm.prank(creator);\n        bytes32 askId = requester.requestReview(jobId);\n\n        // The oracle's answer is produced after the 1-day local deadline. It is a valid, fresh,\n        // correctly signed NotPassed for this exact ask; the Intake's completion tx is pending.\n        vm.warp(block.timestamp + 1 days + 1);\n        bytes memory pendingDelivery = _signed(askId, _attestation(askId, false));\n\n        // Requester sees `answer = false` in the pending calldata and front-runs with expiry.\n        vm.prank(creator);\n        (bool expired,) = address(requester).call(abi.encodeCall(HookJobRequester.expireReview, (jobId)));\n        expired; // ignored: a fix may refuse this\n\n        // The Intake's one attempt: the genuine negative answer must be retained or recorded.\n        bool delivered = intake.complete(askId, pendingDelivery);\n        assertTrue(delivered, \"genuine NotPassed was discarded by requester-chosen expiry\");\n        HookJobRequester.ReviewStatus status = requester.getJob(jobId).review;\n        assertTrue(status != HookJobRequester.ReviewStatus.Unanswered, \"requester may re-ask after seeing NotPassed\");\n    }\n}","reproduction":null,"severity":"medium","snippet":"        if (_reviewReceipts[intakeRequestId].oracleRequestId != bytes32(0)) {","title":"Residual verdict shopping: requester front-runs a late NotPassed delivery with expireReview and re-asks"},{"citation":"resolved","description":"Merged (audit_math, audit_flow, audit_economics wedge note). If an unbound ask's true-context receipt has UUID X and X is consumed (or reserved) by another ask, bindReview reverts DuplicateRequestId (line 390), discardReviewReceipt reverts ReviewReceiptMatches, releaseReservation reverts, expireReview reverts ReviewAlreadyReceived and requestReview reverts NotReviewable. The honest owner has no path; only a fabricated mismatch closes it. Fix: allow discard when consumed[receipt.oracleRequestId] or the UUID is reserved by a different ask.","line":450,"path":"src/imd/HookJobRequester.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {HookJobRequester} from \"src/imd/HookJobRequester.sol\";\nimport {IIntake} from \"src/imd/IIntake.sol\";\nimport {OracleAttestation} from \"src/imd/OracleAttestation.sol\";\n\n/// @dev Minimal ERC-20 for the proof: returns true, no callbacks.\ncontract ProofToken {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n    }\n\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        if (allowance[from][msg.sender] != type(uint256).max) allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\n/// @dev Minimal Intake v2 stand-in: pulls the price, calls back once under the 200k stipend.\ncontract ProofIntake is IIntake {\n    struct Req {\n        address target;\n        bytes4 selector;\n        bool settled;\n    }\n\n    uint256 public price;\n    uint256 internal nonce;\n    mapping(bytes32 => Req) public reqs;\n\n    constructor(uint256 price_) {\n        price = price_;\n    }\n\n    function priceOf(bytes32, address) external view returns (uint256) {\n        return price;\n    }\n\n    function request(bytes32, bytes calldata, Callback calldata callback, address asset, uint256 amount)\n        external\n        payable\n        returns (bytes32 id)\n    {\n        return _request(callback, asset, amount);\n    }\n\n    function requestWithFailure(bytes32, bytes calldata, Callback calldata callback, bytes4, address asset, uint256 amount)\n        external\n        payable\n        returns (bytes32 id)\n    {\n        return _request(callback, asset, amount);\n    }\n\n    function _request(Callback calldata callback, address asset, uint256 amount) internal returns (bytes32 id) {\n        require(amount == price, \"wrong amount\");\n        require(ProofToken(asset).transferFrom(msg.sender, address(this), amount), \"pull failed\");\n        id = keccak256(abi.encode(address(this), ++nonce));\n        reqs[id] = Req(callback.target, callback.selector, false);\n    }\n\n    function complete(bytes32 id, bytes calldata args) external returns (bool called) {\n        Req storage r = reqs[id];\n        require(r.target != address(0) && !r.settled, \"not pending\");\n        r.settled = true;\n        (called,) = r.target.call{gas: 200_000}(bytes.concat(r.selector, args));\n    }\n}\n\n/// @notice Fails on the current code: an unbound receipt whose true context matches but whose UUID\n/// was consumed through another ask has no recovery path. Passes once the owner can close it.\ncontract StuckReceiptProofTest is Test {\n    bytes32 internal constant JOB_ACTION = \"job.open@test\";\n    bytes32 internal constant ORACLE_ACTION = \"oracle.request@oracle-1\";\n    uint256 internal constant PRICE = 0.5 ether;\n\n    ProofToken internal imd;\n    ProofIntake internal intake;\n    HookJobRequester internal requester;\n    address internal owner = makeAddr(\"owner\");\n    address internal creator = makeAddr(\"creator\");\n    uint256 internal signerKey = uint256(keccak256(\"proof oracle key\"));\n\n    function setUp() public {\n        vm.chainId(31337);\n        vm.warp(1_800_000_000);\n        imd = new ProofToken();\n        intake = new ProofIntake(PRICE);\n        requester = new HookJobRequester(owner, address(intake), address(imd), vm.addr(signerKey), PRICE);\n        vm.startPrank(owner);\n        requester.setJobTemplate(\n            JOB_ACTION,\n            '{\"steps\":[\"forge build\",\"forge test\",\"adversarial review\"],\"specHash\":\"',\n            '\",\"request\":\"<brief>',\n            '</brief>\"}'\n        );\n        requester.setReviewTemplate(ORACLE_ACTION, '{\"question\":\"', '\"}', 5, 4);\n        vm.stopPrank();\n        imd.mint(creator, 10 ether);\n        vm.prank(creator);\n        imd.approve(address(requester), type(uint256).max);\n    }\n\n    function _unboundAsk() internal returns (bytes32 jobId, bytes32 askId) {\n        vm.prank(creator);\n        jobId = requester.openJob(keccak256(\"spec\"), \"A swap cap hook\");\n        assertTrue(intake.complete(jobId, abi.encode(jobId, bytes(\"ipfs://result\"))));\n        vm.prank(creator);\n        askId = requester.requestReview(jobId);\n    }\n\n    function _attestation(bytes32 askId, bytes32 uuid, bool passed)\n        internal\n        view\n        returns (OracleAttestation.Attestation memory a)\n    {\n        a = OracleAttestation.Attestation({\n            requestId: uuid,\n            chainId: 31337,\n            questionHash: keccak256(abi.encode(\"question\", askId)),\n            answerType: OracleAttestation.ANSWER_BOOL,\n            answer: abi.encode(passed),\n            figure: 0,\n            fromBlock: 1,\n            toBlock: 2,\n            blockHash: bytes32(uint256(3)),\n            panelJobId: keccak256(\"panel job\"),\n            panelSize: 7,\n            quorum: 5,\n            agreed: 6,\n            issuedAt: uint64(block.timestamp),\n            expiresAt: uint64(block.timestamp + 1 hours)\n        });\n    }\n\n    function _deliver(bytes32 askId, OracleAttestation.Attestation memory a) internal returns (bool) {\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerKey, requester.attestationDigest(a));\n        return intake.complete(askId, abi.encode(askId, a, abi.encodePacked(r, s, v)));\n    }\n\n    function testOwnerCanCloseReceiptWhoseUuidWasConsumedElsewhere() public {\n        (, bytes32 askA) = _unboundAsk();\n        (bytes32 jobB, bytes32 askB) = _unboundAsk();\n        bytes32 uuid = bytes32(bytes16(keccak256(\"shared protocol UUID\")));\n        OracleAttestation.Attestation memory a = _attestation(askA, uuid, true);\n        OracleAttestation.Attestation memory b = _attestation(askB, uuid, false);\n        assertTrue(_deliver(askA, a), \"receipt A retained\");\n        assertTrue(_deliver(askB, b), \"receipt B retained\");\n\n        // Owner authenticates A's context and finalizes it, consuming the shared UUID.\n        bytes32 bodyA = requester.getReviewAsk(askA).bodyHash;\n        vm.prank(owner);\n        requester.bindReview(askA, bodyA, uuid, a.questionHash, a.chainId, a.fromBlock, a.toBlock);\n        assertTrue(requester.consumed(uuid));\n\n        // B's receipt matches B's true context but its UUID is gone: bind is impossible by design.\n        bytes32 bodyB = requester.getReviewAsk(askB).bodyHash;\n        vm.prank(owner);\n        vm.expectRevert(abi.encodeWithSelector(HookJobRequester.DuplicateRequestId.selector, uuid));\n        requester.bindReview(askB, bodyB, uuid, b.questionHash, b.chainId, b.fromBlock, b.toBlock);\n\n        // The requester has no path either.\n        vm.warp(requester.getReviewAsk(askB).deadline + 1);\n        vm.prank(creator);\n        vm.expectRevert(abi.encodeWithSelector(HookJobRequester.ReviewAlreadyReceived.selector, askB));\n        requester.expireReview(jobB);\n\n        // Expected: the owner can close the dead receipt so the requester may retry.\n        // Actual on current code: ReviewReceiptMatches, and jobB is Pending forever.\n        vm.prank(owner);\n        requester.discardReviewReceipt(askB, bodyB, uuid, b.questionHash, b.chainId, b.fromBlock, b.toBlock);\n        assertEq(uint8(requester.getJob(jobB).review), uint8(HookJobRequester.ReviewStatus.Unanswered));\n        vm.prank(creator);\n        requester.requestReview(jobB);\n    }\n}","reproduction":null,"severity":"low","snippet":"        if (_receiptMatches(receipt, oracleRequestId, questionHash, questionChainId, fromBlock, toBlock)) {","title":"Receipt whose UUID was consumed/reserved through another ask can never be finalized or closed; job stuck Pending"},{"citation":"resolved","description":"Merged (economics, math, permissions). After a bound ask expires/fails, its UUID stays reserved until owner releaseReservation. The requester can re-ask immediately; if the protocol reuses the UUID, the retry's unbound receipt-first callback reverts here although releaseReservation's predicate already holds (holder terminal, no receipt, unconsumed). The single attempt and fee are lost. Fix: on the unbound path skip the reservation check, or treat a reservation as non-blocking when reviewOf[holder]==0, no receipt and unconsumed.","line":548,"path":"src/imd/HookJobRequester.sol","reproduction":null,"severity":"low","snippet":"        if (reservedFor != bytes32(0) && reservedFor != requestId) revert DuplicateRequestId(a.requestId);","title":"Stale reservation of a terminal ask rejects the retry's one-shot callback before the owner can release it"},{"citation":"resolved","description":"Merged with the owner-trust notes from all specialists. Discard only requires a well-formed context that differs in any field. It does not refuse oracleRequestId == receipt UUID (a same-request field mismatch is not a misdelivery) nor UUIDs consumed/reserved elsewhere (which bindReview at line 390 refuses). A toBlock+1 typo (or deliberate owner choice) closes a genuine NotPassed and lets the requester re-ask. Deliberate abuse is a documented owner-trust assumption; the missing consistency checks are the defect. Fix: revert when oracleRequestId == receipt.oracleRequestId, or when it is consumed/reserved by another ask (keep the stuck-receipt exception above).","line":450,"path":"src/imd/HookJobRequester.sol","reproduction":null,"severity":"low","snippet":"        if (_receiptMatches(receipt, oracleRequestId, questionHash, questionChainId, fromBlock, toBlock)) {","title":"discardReviewReceipt accepts a 'true context' with the receipt's own UUID or a consumed UUID, so an owner typo irreversibly erases a genuine verdict"},{"citation":"resolved","description":"Not re-measured by this judge (time): specialists report receipt-first gas ~170.6k with a Safe-shaped 5-of-7 ERC-1271 signer under the 200k one-attempt call, ~29k headroom; signers over ~74k cold still lose the answer and fee. discard/release are onlyOwner; after discard reviewOf is deleted so replay reverts. No src/store changes since the audited commit per specialists. Unverified live assumptions: Intake callback ABI and single 200k delivery, authenticated UUID/context source for owner binding, UUID uniqueness, issuedAt/expiry windows, honest and available owner.","line":548,"path":"src/imd/HookJobRequester.sol","reproduction":null,"severity":"info","snippet":"        if (reservedFor != bytes32(0) && reservedFor != requestId) revert DuplicateRequestId(a.requestId);","title":"Verification notes: gas, authorization, escrow, live-Intake assumptions"}],"hash":"a61567f73e825cb7860f0c1e4ded20775157bf9a9b0586a95324861b2aebc454","nodeId":"b87009b9-965f-4755-86a4-b08688602485","outcome":"completed","summary":"I've written `.imd-findings.json` with five entries, down from 17 specialist findings. Duplicates are merged and the overlapping owner-trust notes are folded into the related entries. I ran the two specialist proofs and kept both: each fails on commit cb6ca72 for the reason its finding gives. For the two other low findings I read the code and confirmed the logic, but ran no test of my own. I also changed no repository files (`test/scratch/` was removed afterwards), and nothing was deployed.\n\n1. **Medium — verdict shopping still works before an answer is stored** (`src/imd/HookJobRequester.sol:609`). Once the review deadline has passed, the requester can see a negative answer waiting to be mined and call `expireReview` first. The Intake's one callback then reverts, nothing is recorded, and the requester can ask again for another paid fee. Four specialists reported this; their proof fails with \"genuine NotPassed was discarded by requester-chosen expiry\".\n2. **Low — an ask can be stuck Pending forever** (line 450). If an ask holds a correct answer whose UUID has already been used by another ask, every exit reverts: bind, discard, release, expire and re-ask. The owner can only close it by inventing a mismatch. The proof fails with `ReviewReceiptMatches`.\n3. **Low — a stale reservation burns the retry's only callback** (line 548). After a bound ask expires or fails, its UUID stays reserved until the owner calls `releaseReservation`. If the retry reuses that UUID and its answer arrives first, it reverts with `DuplicateRequestId` and the fee is lost. This happens even though the release conditions are already met at that moment.\n4. **Low — discard accepts contexts that bind would refuse** (line 450). `discardReviewReceipt` accepts the answer's own UUID with a single field changed, such as `toBlock + 1`, and it accepts UUIDs already used by another ask. So a one-field owner typo permanently erases a genuine negative answer, and the requester can re-ask. Deliberate misuse by the owner is a documented trust assumption, so it is not reported as a defect; the missing checks are.\n5. **Info — verification notes and live-Intake assumptions.**\n   - **Gas:** I did not re-measure it. Specialists report the 200k one-attempt callback fits a realistic multi-signature contract signer, using about 170.6k gas.\n   - **Access and replay:** `discardReviewReceipt` and `releaseReservation` are owner-only, and replaying either reverts, per the specialists.\n   - **Listing/ad escrow:** specialists report no changes under `src/store` and no regression; I did not re-check this.\n   - **Still unverified on the live Intake:** the callback ABI and single 200k delivery, an authenticated source for the UUID and question context the owner binds, UUID uniqueness, the issue/expiry time windows, and an honest owner who stays available.","treeHash":null,"usage":{"cachedInputTokens":182758,"inputTokens":8,"model":"claude-opus-5-5","outputTokens":4103,"runtime":"claude","turns":4,"wallClockMs":63928}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"76f57db4177175be","findings":[{"citation":"resolved","description":"onReviewResult rejects any attestation whose UUID is reserved by a different Intake ID, even when that holder is already terminal (reviewOf[holder]==0), has no receipt and the UUID is unconsumed, i.e. exactly the state releaseReservation accepts. The remediation for report finding 4 made release a manual owner step that must land strictly before the retry's single callback. A requester who re-asks right after a failure/expiry (the normal UI path) and whose answer is dispatched before the owner acts loses the one-shot callback and the Intake fee (0.5 IMD at the fixture price), and must pay a third time. The guard can safely perform the release inline: when reservedFor != requestId but reviewOf[reservedFor]==0, _reviewReceipts[reservedFor].oracleRequestId==0 and !consumed[a.requestId], delete intakeReviewOfOracle[a.requestId] (emit ReviewReservationReleased) and continue; the retry still needs the owner's exact binding before any verdict, so no security property is weakened. Documented in AUDIT_1053AF02.md as 'cannot restore that attempt', but it is an avoidable fee loss on the ordinary retry path rather than an inherent limit.","line":548,"path":"src/imd/HookJobRequester.sol","reproduction":"1) openJob, deliver. 2) requestReview -> ask1; owner bindReview(ask1, UA,...); Intake calls onReviewFailure(ask1, 2, 'disagreed') -> job Unanswered; intakeReviewOfOracle[UA]==ask1, consumed[UA]==false. 3) requester requestReview -> ask2 (same body => protocol redispatches the same question/UUID UA). 4) Intake delivers a valid, fresh, correctly signed attestation with requestId UA to ask2 under 200k gas before the owner calls releaseReservation(UA). Expected: answer retained as a receipt (or settled). Actual: revert DuplicateRequestId(UA); MockIntake.complete returns false, request settled, getReviewReceipt(ask2).oracleRequestId==0, Intake balance unchanged (fee kept), a later releaseReservation(UA) succeeds but complete(ask2,...) now reverts 'not pending'. Probe: test/scratch/Probe.t.sol testStaleReservationBurnsRetryCallback; proof below fails on this code.","severity":"low","snippet":"        if (reservedFor != bytes32(0) && reservedFor != requestId) revert DuplicateRequestId(a.requestId);","title":"Stale UUID reservation of a closed, receipt-less ask burns the retry's one-shot callback and its fee"},{"citation":"resolved","description":"The fix for report finding 1 blocks expireReview once a receipt is stored, but a pending ask is still expirable the moment the deadline passes, and (by the 5f43f94d remediation) fresh answers are accepted after the deadline. A requester who observes the Intake writer's onReviewResult(…answer=false…) transaction before inclusion (public mempool chains such as Ethereum/Sepolia; not Base's private sequencer) front-runs it with expireReview: the callback reverts UnknownOrSettled, no receipt is retained, the job becomes Unanswered and the requester may re-ask for a new panel run, repeating until a Passed verdict lands. Cost is one Intake fee per attempt; the Intake keeps the fee. The existing test testLateNotPassedReceiptCannotBeFrontRunByRequesterExpiry only covers the opposite ordering (delivery first, then expiry). Mitigation that preserves the agreed design: make expiry two-step for asks past their deadline (request expiry, finalize after N blocks; a receipt landing in the window blocks finalization), or let the owner, not the requester, close an answer-less ask after the deadline.","line":607,"path":"src/imd/HookJobRequester.sol","reproduction":"1) openJob, deliver, requestReview -> ask (bound by owner). 2) warp to reviewDeadline+1. 3) Oracle signs a fresh attestation (issuedAt=now, answer=false) for ask; Intake writer broadcasts complete(ask, …). 4) Requester's expireReview(job) is included first: review=Unanswered, reviewOf[ask] deleted. 5) complete(ask,…) returns false (UnknownOrSettled), request settled, getReviewReceipt(ask).oracleRequestId==0, consumed[UUID]==false. 6) requestReview(job) succeeds; a Passed answer to the retry sets review=Passed. Expected: a fresh answer already in flight after the deadline cannot be discarded by the requester. Probe: test/scratch/Probe.t.sol testLateNegativeAnswerFrontRunByExpiry; proof below fails on this code.","severity":"low","snippet":"        if (block.timestamp <= job.reviewDeadline) revert DeadlineNotReached(job.reviewDeadline);","title":"Late NotPassed answer can still be pre-empted by requester expiry (residual verdict shopping)"},{"citation":"resolved","description":"The only on-chain checks are expectedBodyHash==ask.bodyHash (public), non-zero fields, fromBlock<=toBlock, and inequality with the receipt. A dishonest or colluding owner therefore supplies e.g. toBlock+1 and closes a correct NotPassed receipt as Unanswered; the requester re-asks and can shop for Passed. This is explicitly documented as an owner-trust risk (AUDIT_1053AF02.md, IMD_INTEGRATION.md), so it is recorded here as a trust assumption with a concrete reproduction rather than a defect: the Passed signal is only as strong as the owner. Consider emitting the discarded receipt's full context alongside the claimed one (already done) and operating the owner as a multisig with public justification per discard; a stricter on-chain alternative (e.g. requiring the claimed context's UUID to later be bound and consumed by the same job's retry) would be a design change.","line":450,"path":"src/imd/HookJobRequester.sol","reproduction":"openJob, deliver, requestReview -> ask (unbound). Intake delivers a valid attestation with answer=false whose five context fields are the ask's genuine ones; receipt stored. Owner calls discardReviewReceipt(ask, bodyHash, UA, questionHash, chainId, fromBlock, toBlock) -> revert ReviewReceiptMatches (correct). Owner calls it again with toBlock+1 -> succeeds: review=Unanswered, discardedReviewReceipts[ask]=true. requestReview(job) succeeds and a Passed answer to the retry sets review=Passed. Probe: test/scratch/Probe.t.sol testOwnerFabricatedMismatchDiscardsCorrectNegativeReceipt.","severity":"info","snippet":"        if (_receiptMatches(receipt, oracleRequestId, questionHash, questionChainId, fromBlock, toBlock)) {\n            revert ReviewReceiptMatches(intakeRequestId);\n        }","title":"discardReviewReceipt cannot authenticate the 'true context': owner can discard a correct negative receipt by supplying any differing field"},{"citation":"resolved","description":"If ask A holds a receipt with its genuine UUID UA and the owner (by mistake) binds another pending ask B to UA first, A is wedged: bindReview(A, UA) reverts DuplicateRequestId (reserved by B), discardReviewReceipt(A, true context UA) reverts ReviewReceiptMatches, releaseReservation(UA) reverts ReservationNotReleasable (B pending), and A's requester cannot expireReview (ReviewAlreadyReceived). Recovery requires B's requester to expire B after B's deadline (B never receives UA because it is A's), then release, then bind A. The alternative is the owner fabricating a mismatch for A, which the discard guard is meant to forbid. Precondition is an owner binding error, so this is a liveness/operational note: a bindReview that reserves a UUID already present in another pending ask's stored receipt could revert (cheap check of a receipt-UUID index) to prevent the wedge at the source.","line":390,"path":"src/imd/HookJobRequester.sol","reproduction":"openJob/deliver x2 -> jobs A,B. requestReview(A)->askA, Intake delivers attestation UA (answer=true) to askA -> receipt. requestReview(B)->askB; owner bindReview(askB, bodyB, UA, …) succeeds (reserves UA for askB). Then: bindReview(askA, bodyA, UA,…) -> DuplicateRequestId(UA); discardReviewReceipt(askA, bodyA, UA,…) -> ReviewReceiptMatches(askA); releaseReservation(UA) -> ReservationNotReleasable(UA); warp 2 days, creator expireReview(A) -> ReviewAlreadyReceived(askA). Probe: test/scratch/Probe.t.sol testCorrectReceiptStuckWhenUuidBoundElsewhere.","severity":"info","snippet":"        if (intakeReviewOfOracle[oracleRequestId] != bytes32(0) || consumed[oracleRequestId]) {","title":"Correct receipt whose UUID was bound to another pending ask has no honest recovery path until that other ask expires"},{"citation":"resolved","description":"Report finding 2 (receipt path >200k with a heavier ERC-1271 signer) is resolved for the modelled signers. Re-measured on this commit with the repository's harness (cold consumer and signer, explicit gas cap, one-attempt MockIntake.complete at exactly 200,000): ECDSA 125,266; repository 5-of-7 threshold signer 170,359; +22-cold-read stressor 187,513 (all pass). Additionally measured with a Safe-shaped signer (proxy -> delegatecall singleton -> fallback handler appended-sender call -> handler calls back domainSeparator() and checkSignatures() with 5 ecrecovers over an owners linked list): isolated cold isValidSignature minimum 50,412 gas; receipt-first minimum forwarded gas 170,607; the cold one-attempt 200k callback succeeds and a receipt is retained. Headroom is ~29k, below the 60k-signer ceiling the docs state. Unresolved: the real production registry/Safe bytecode, EIP-7702 delegation paths and chain gas schedules were not executed; the 64-read stressor still fails closed and loses the fee, as documented.","line":544,"path":"src/imd/HookJobRequester.sol","reproduction":"test/scratch/Probe.t.sol testSafeShapedSignerReceiptGas (logs the three numbers above); forge test --match-path test/ReviewReceiptGas.t.sol -vv for the repository fixtures. No failing input: informational verification result.","severity":"info","snippet":"        bytes32 digest = _verifyAttestation(a, signature);","title":"Receipt-first callback gas re-verified: ~170.6k forwarded gas with a Safe-shaped 5-of-7 ERC-1271 signer; production registry remains unqualified"}],"hash":"b5ddeebdd8cd2eb737578f303b6169dddc02fb5238ffddec94b30e41d04f5e74","nodeId":"a1f3f8a0-e2b9-4b91-ad46-97d4ba2c4eb7","outcome":"completed","summary":"The review is complete. The findings file holds five entries, two of them with failing proof tests.\n\n**Outcome.** All four actionable findings from report 1053af02 are fixed on commit cb6ca72 as described. The full suite passes (197 tests). I found no critical or high defect. Two low residuals remain on the review flow, plus three informational notes. The store and ad escrow contracts were not touched by this commit and showed no regression.\n\n**Prior findings reproduced against the new code**\n\n- **Verdict shopping (was Medium).** A retained NotPassed receipt can no longer be expired or re-asked. Residual: once the review deadline passes, a requester who sees the Intake's negative callback in a public mempool can expire first. The callback then reverts, no receipt is stored, and the job is re-askable. Not exploitable on a private-sequencer chain. Reported as low at `src/imd/HookJobRequester.sol:607` with a proof.\n- **Swapped receipt (was Low).** Discard works only on an unbound, pending ask with the exact body hash and a context that differs from the receipt. The genuine ask can still bind and consume its own UUID. Confirmed.\n- **UUID reservation blocks retry (was Low).** Release works as documented. Residual: if the retry's answer arrives before the owner releases, the one-shot callback reverts with DuplicateRequestId even though the holder is terminal and receipt-less, and the fee is lost. Reported as low at line 548 with a proof. The guard can release inline with no loss of security.\n- **200k gas (was Low).** Re-measured with the repo harness and with a Safe-shaped signer I built (proxy, singleton, fallback handler, five ecrecovers over an owners list). Receipt-first minimum forwarded gas is about 170.6k with a 50.4k cold signer. The cold one-attempt callback succeeds. The production registry remains unqualified.\n\n**Adversarial checks on discard and release.** Non-owners are refused. A matching context cannot be discarded. A discarded receipt cannot be bound, replayed or re-delivered to its old Intake ID. A dishonest owner can still discard a correct negative receipt by supplying any differing field. That is documented as an owner-trust assumption and recorded as info. A second info note covers an owner mis-binding that wedges a correct receipt until the other requester expires.\n\n**Live-Intake assumptions still open.** Asynchronous callback delivery after the request returns, the 200k stipend, the `onJobResult(bytes32,bytes)` shape, UUID reuse policy on retries, canonical question hashing, and real registry gas were all taken from the mocks, not verified against a live Intake.\n\nScratch probes and proofs live under `test/scratch/`. Nothing outside `.imd-findings.json` and that directory was written.","treeHash":null,"usage":{"cachedInputTokens":2186338,"inputTokens":546,"model":"claude-fable-5-1","outputTokens":51747,"runtime":"claude","turns":37,"wallClockMs":826465}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"759c614fdc84ff66","findings":[{"citation":"resolved","description":"The 1053af02 fix only blocks expiry once a receipt is stored (line 609). Before storage, expireReview (requester-only, after reviewDeadline) deletes reviewOf and sets Unanswered, so the Intake's single callback for the same ask then reverts UnknownOrSettled at line 519 and the signed answer is lost; requestReview accepts Unanswered and the requester can pay again. The requester learns the bool before it is recorded: it is in the calldata of the Intake's pending completion transaction (answer field of the attestation) and on the public panel job page. So whenever an answer lands after reviewDeadline (default 1 day, owner can lower to 1 hour), the requester lets a true answer land and front-runs a false one with expireReview. That is the requester choosing by verdict, which THREAT_MODEL.md line 43 says the fix prevents. The test named testLateNotPassedReceiptCannotBeFrontRunByRequesterExpiry only covers receipt-first ordering. Fix while keeping the requester's ability to abandon a dead ask: after expiry, keep accepting a late, validly signed callback for that Intake ID as a retained receipt and move the job back to Pending-with-receipt, blocking re-ask until the owner confirms or discards it. Alternatives: make expiry owner-only, or require reviewDeadline plus a grace period longer than the attestation lifetime (validForSeconds) and the Intake's delivery SLA.","line":612,"path":"src/imd/HookJobRequester.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {HookJobRequester} from \"src/imd/HookJobRequester.sol\";\nimport {IIntake} from \"src/imd/IIntake.sol\";\nimport {OracleAttestation} from \"src/imd/OracleAttestation.sol\";\n\ncontract PocToken {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n    }\n\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        if (allowance[from][msg.sender] != type(uint256).max) allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\n/// @dev One-shot Intake stand-in: 200k gas callback, settled even when the callback reverts.\ncontract PocIntake is IIntake {\n    struct Req {\n        address target;\n        bytes4 selector;\n        bool settled;\n        bytes body;\n    }\n\n    mapping(bytes32 => Req) public reqs;\n    uint256 internal nonce;\n    uint256 public price = 0.5 ether;\n\n    function priceOf(bytes32, address) external view returns (uint256) {\n        return price;\n    }\n\n    function request(bytes32, bytes calldata body, Callback calldata cb, address asset, uint256 amount)\n        external\n        payable\n        returns (bytes32 id)\n    {\n        return _req(body, cb, asset, amount);\n    }\n\n    function requestWithFailure(bytes32, bytes calldata body, Callback calldata cb, bytes4, address asset, uint256 amount)\n        external\n        payable\n        returns (bytes32 id)\n    {\n        return _req(body, cb, asset, amount);\n    }\n\n    function _req(bytes calldata body, Callback calldata cb, address asset, uint256 amount) internal returns (bytes32 id) {\n        PocToken(asset).transferFrom(msg.sender, address(this), amount);\n        id = keccak256(abi.encode(address(this), ++nonce));\n        reqs[id] = Req(cb.target, cb.selector, false, body);\n    }\n\n    function bodyOf(bytes32 id) external view returns (bytes memory) {\n        return reqs[id].body;\n    }\n\n    function complete(bytes32 id, bytes calldata args) external returns (bool called) {\n        Req storage r = reqs[id];\n        require(r.target != address(0) && !r.settled, \"not pending\");\n        r.settled = true;\n        (called,) = r.target.call{gas: 200_000}(bytes.concat(r.selector, args));\n    }\n}\n\n/// @notice Audit finding: a requester who sees a late NotPassed answer coming (Intake completion in\n/// the mempool, or the public panel result) front-runs it with `expireReview`, so the one-shot\n/// callback reverts, the negative verdict is never recorded, and the requester re-asks.\ncontract ExpiryFrontRunShoppingTest is Test {\n    PocToken internal imd;\n    PocIntake internal intake;\n    HookJobRequester internal requester;\n    address internal owner = makeAddr(\"owner\");\n    address internal creator = makeAddr(\"creator\");\n    uint256 internal signerKey = uint256(keccak256(\"poc oracle key\"));\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        imd = new PocToken();\n        intake = new PocIntake();\n        requester = new HookJobRequester(owner, address(intake), address(imd), vm.addr(signerKey), 0.5 ether);\n        vm.startPrank(owner);\n        requester.setJobTemplate(\"job\", '{\"steps\":\"forge build, forge test, adversarial review\",\"spec\":\"', '\",\"b\":\"', '\"}');\n        requester.setReviewTemplate(\"review\", '{\"q\":\"did job ', ' pass?\"}', 5, 4);\n        vm.stopPrank();\n        imd.mint(creator, 10 ether);\n        vm.prank(creator);\n        imd.approve(address(requester), type(uint256).max);\n    }\n\n    function _attestation(bytes32 askId, bool passed) internal view returns (OracleAttestation.Attestation memory) {\n        return OracleAttestation.Attestation({\n            requestId: bytes32(bytes16(keccak256(abi.encode(\"uuid\", askId)))),\n            chainId: 31337,\n            questionHash: keccak256(intake.bodyOf(askId)),\n            answerType: 0,\n            answer: abi.encode(passed),\n            figure: 0,\n            fromBlock: 1,\n            toBlock: 2,\n            blockHash: bytes32(uint256(3)),\n            panelJobId: keccak256(\"panel\"),\n            panelSize: 7,\n            quorum: 5,\n            agreed: 6,\n            issuedAt: uint64(block.timestamp),\n            expiresAt: uint64(block.timestamp + 1 hours)\n        });\n    }\n\n    function _signed(bytes32 askId, OracleAttestation.Attestation memory a) internal view returns (bytes memory) {\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerKey, requester.attestationDigest(a));\n        return abi.encode(askId, a, abi.encodePacked(r, s, v));\n    }\n\n    function testRequesterFrontRunsLateNotPassedWithExpiryAndReasks() public {\n        vm.prank(creator);\n        bytes32 jobId = requester.openJob(keccak256(\"spec\"), \"hook brief\");\n        assertTrue(intake.complete(jobId, abi.encode(jobId, bytes(\"ipfs://result\"))));\n        vm.prank(creator);\n        bytes32 askId = requester.requestReview(jobId);\n\n        // The oracle's answer is produced after the 1-day local deadline. It is a valid, fresh,\n        // correctly signed NotPassed for this exact ask; the Intake's completion tx is pending.\n        vm.warp(block.timestamp + 1 days + 1);\n        bytes memory pendingDelivery = _signed(askId, _attestation(askId, false));\n\n        // Requester sees `answer = false` in the pending calldata and front-runs with expiry.\n        vm.prank(creator);\n        (bool expired,) = address(requester).call(abi.encodeCall(HookJobRequester.expireReview, (jobId)));\n        expired; // ignored: a fix may refuse this\n\n        // The Intake's one attempt: the genuine negative answer must be retained or recorded.\n        bool delivered = intake.complete(askId, pendingDelivery);\n        assertTrue(delivered, \"genuine NotPassed was discarded by requester-chosen expiry\");\n        HookJobRequester.ReviewStatus status = requester.getJob(jobId).review;\n        assertTrue(status != HookJobRequester.ReviewStatus.Unanswered, \"requester may re-ask after seeing NotPassed\");\n    }\n}","reproduction":"1) creator: openJob, Intake completes it, then requestReview -> askId (unbound). 2) warp to reviewDeadline+1. The oracle produces a fresh NotPassed attestation for askId's own UUID, correctly signed, and the Intake broadcasts complete(askId, ...). 3) creator front-runs with expireReview(jobId). 4) The Intake's one-shot call to onReviewResult reverts UnknownOrSettled, MockIntake-style settlement marks it done, and the fee is spent. Expected: the genuine NotPassed is retained or recorded and the job cannot be re-asked. Actual: review == Unanswered, no receipt, and requestReview(jobId) succeeds, so the requester can retry until Passed. The proof fails with 'genuine NotPassed was discarded by requester-chosen expiry'.","severity":"medium","snippet":"        delete reviewOf[intakeRequestId];\n        job.review = ReviewStatus.Unanswered;","title":"Verdict shopping persists before receipt: requester front-runs a late NotPassed delivery with expireReview and re-asks"},{"citation":"resolved","description":"Prior finding 4 is only partly fixed. releaseReservation is manual and owner-only, but the retry that needs it is created by the requester without permission, immediately after expireReview or onReviewFailure. If the protocol reuses the oracle UUID for the retry (the reuse case prior finding 4 was about), the retry is still unbound. Its receipt-first callback reaches line 548, finds the UUID reserved by the dead holder, and reverts DuplicateRequestId. The Intake's single attempt is spent, no receipt is stored, and a later releaseReservation cannot restore it. The job stays Pending until the requester expires it again and pays another fee. That contradicts the rationale at line 550 ('a swapped answer must not prevent its actual ask from binding'): here a stale reservation prevents the actual ask from even retaining its answer, before the owner can react. Storing an unbound receipt neither reserves nor consumes anything, and bindReview (line 390) already refuses a UUID that is still reserved. So the fix is to apply the reservation check only on the bound path (or only when the holder is still live, reviewOf[reservedFor] != 0) and leave the conflict for the owner to resolve at bind time with release then bind. The consumed check at line 546 stays.","line":548,"path":"src/imd/HookJobRequester.sol","reproduction":"1) creator: requestReview(job) -> ask1. Owner: bindReview(ask1, ..., uuid X, q, 31337, 1, 2). No delivery arrives. 2) warp past reviewDeadline. creator: expireReview(job), then requestReview(job) -> retry (unbound). 3) The Intake delivers a fresh, validly signed attestation for retry with the reused uuid X. Expected: the receipt is retained, and the owner then calls releaseReservation(X) followed by bindReview(retry, X, ...) to finalize. Actual: MockIntake.complete returns false (DuplicateRequestId), getReviewReceipt(retry).oracleRequestId == 0, and releaseReservation(X) afterwards succeeds but complete(retry) reverts 'not pending'. The answer and fee are lost and the job sits in Pending. Verified locally in test/scratch/StaleReservation.t.sol, which uses the project's HookJobRequesterFixture.","severity":"low","snippet":"        if (reservedFor != bytes32(0) && reservedFor != requestId) revert DuplicateRequestId(a.requestId);","title":"Stale UUID reservation still drops the retry's one-shot receipt: unbound path rejects UUIDs reserved by a terminal ask"},{"citation":"resolved","description":"Discard is meant to close a misdelivery, meaning an answer for another oracle request, using the ask's context authenticated 'exactly as for binding'. It only requires that some field differs from the receipt and that values are nonzero (lines 445-452). (a) It accepts oracleRequestId equal to the receipt's UUID with only toBlock, fromBlock, questionHash or chainId changed. A receipt whose UUID equals the ask's authenticated UUID is that request's own signed answer, not a misdelivery. (b) Unlike bindReview (line 390), it accepts a UUID already consumed by or reserved for another ask, which cannot be this ask's true context. bindReview reverts on any mismatch so that 'a mistyped confirmation can be corrected' (IMD_INTEGRATION.md). The same typo passed to discard is irreversible: the retained NotPassed is closed, the job becomes Unanswered and the requester may re-ask. That reopens the verdict-shopping outcome through owner error rather than only through deliberate owner abuse (which is documented). Fix while keeping recovery: in discardReviewReceipt, revert when oracleRequestId == receipt.oracleRequestId (a same-request field mismatch is a binding or canonicalization problem, not a swap), and revert when intakeReviewOfOracle[oracleRequestId] != 0 or consumed[oracleRequestId], mirroring bindReview.","line":450,"path":"src/imd/HookJobRequester.sol","reproduction":"(a) The creator's ask A receives a genuine NotPassed receipt (uuid X, q, chain 31337, window 1..2). Owner calls discardReviewReceipt(A, bodyA, X, q, 31337, 1, 3), a toBlock typo. Expected: revert, because UUID X is A's own answer. Actual: succeeds, job.review == Unanswered, and the creator's requestReview(job) succeeds. (b) Job B is finalized with uuid Y (consumed[Y] == true). For ask A with a NotPassed receipt X, bindReview(A, bodyA, Y, ...) reverts DuplicateRequestId(Y), but discardReviewReceipt(A, bodyA, Y, qA, 31337, 1, 2) succeeds and sets Unanswered. Both verified locally in test/scratch/DiscardChecks.t.sol (testDiscardAcceptsSameUuidWindowOffByOne, testDiscardAcceptsConsumedUuidOfOtherAsk).","severity":"low","snippet":"        if (_receiptMatches(receipt, oracleRequestId, questionHash, questionChainId, fromBlock, toBlock)) {","title":"discardReviewReceipt accepts a 'true context' bindReview would refuse, so a one-field typo irreversibly erases a genuine verdict"}],"hash":"f5c051d254280fd050295a139d73df452f0861a7270fd03ec7283a5c4585a170","nodeId":"a01f3199-e434-4cf6-8e08-16a559439e17","outcome":"completed","summary":"I found three defects in commit cb6ca72, one medium and two low; they are in `.imd-findings.json`. The full suite passes (197 tests). The medium finding has a self-contained proof test, and I confirmed it fails on the current code for the reason given. I checked the two lows with scratch tests that use the project's test fixture; they carry no proof test. Nothing was deployed and no repository files were changed.\n\n## Findings\n\n1. **Medium: a requester can still shop for a verdict before the answer is stored** (`src/imd/HookJobRequester.sol:612`). The fix only blocks expiry once an answer has been stored.\n   - **How it happens:** the oracle's answer arrives after the review deadline. The requester sees `false` in the Intake's pending transaction or on the public panel page. They call `expireReview` first, so the Intake's single callback reverts and the answer is lost. They then call `requestReview` again.\n   - **Why it matters:** the existing test `testLateNotPassedReceiptCannotBeFrontRunByRequesterExpiry` only covers the order where the answer arrives first.\n   - **Fix:** keep accepting a late, validly signed answer for an expired ask, and block re-asking until the owner confirms or discards it. Alternatively, make expiry owner-only or add a longer grace period.\n\n2. **Low: the stale-reservation problem from the previous audit is only partly fixed** (line 548). Sequence:\n   - The owner binds ask 1 to UUID X, nothing is delivered, and the requester expires it and immediately re-asks.\n   - If the protocol reuses X for the retry, the retry's answer arrives before the owner has called `releaseReservation` and reverts with `DuplicateRequestId`.\n   - The single attempt and its fee are lost; releasing X afterwards cannot recover them.\n   - **Fix:** apply the reservation check only to asks that are already bound. Storing an unbound answer reserves nothing, and `bindReview` already refuses a reserved UUID.\n\n3. **Low: `discardReviewReceipt` accepts contexts that `bindReview` would refuse** (line 450).\n   - **Same UUID, one field changed:** a discard succeeds with the answer's own UUID and, say, `toBlock` 2 changed to 3. That answer is the ask's own, not a misdelivery.\n   - **UUID already used by another job:** a discard also succeeds with a UUID that another job has consumed or reserved.\n   - **Impact:** a wrong value in `bindReview` simply reverts and can be retried. The same wrong value in a discard permanently closes a genuine NotPassed and lets the requester re-ask.\n   - **Fix:** refuse a discard when the supplied UUID equals the stored answer's UUID, or when it is reserved or consumed elsewhere.\n\n## Previous audit's findings retested\n- **Verdict shopping on a stored NotPassed:** fixed.\n- **Swapped answers:** the owner can now close the wrong ask, and the genuine ask can still finalize.\n- **Stuck UUID reservation:** release works when the owner acts before delivery; finding 2 is what remains.\n- **200k gas:**\n  - **Measurements:** I reproduced the documented 170,359 gas for the 5-of-7 ERC-1271 signer. Putting the signer behind one forwarding proxy raises it to 175,303. Two hops, roughly a Safe-style wallet, raise it to 180,247.\n  - **Verdict:** all fit within 200k, so I have no gas finding. That last figure is an estimate; I did not run an actual Safe.\n  - **Not a guarantee:** the contract does not enforce the documented 60k limit on signature checking, so a heavier signer can still lose the single attempt.\n\n## Release, discard and owner powers\n- **`releaseReservation`:** I found no unauthorized use, cross-request answer or replay path. It is owner-only and refuses consumed UUIDs, live holders and holders with a stored answer. Replaying an old signed answer into a retry is blocked by the `issuedAt` check.\n- **Owner trust:** the owner can still record a wrong verdict by binding or discarding with wrong values, replacing the Intake, or replacing the signer. These are documented trust assumptions, not findings.\n\n## Listing and ad escrow\n","treeHash":null,"usage":{"cachedInputTokens":1845763,"inputTokens":44,"model":"claude-opus-5-5","outputTokens":33565,"runtime":"claude","turns":24,"wallClockMs":509597}}],"verification":[]}