{"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":"1053af02-a430-49b3-a914-75620c17c801","kind":"audit","nodes":[{"acceptedSubmissionHash":"f7296278070d1cedafc1391428e85a320c1ff46902aba9a76e0f426c4b2c734e","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":"ae46f8bb33ac3c2046f9af645512b5f325f210684fab6a99680474d484450733","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":"a995d069fca086f5df294fe22808164c9cc8498b64835a170e7d0300dd370d5c","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":"d4806f7db0bc8199e5d34f27c30a6827f318f43aa03a008c52176eb9714b0921","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":"410032fd561f91bb9d2bed2a696383f1e19125d5aa1750f73b78ba49f0bfe999","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":"Re-audit commit 64e40684 after the 5f43f94d fixes. Check the receipt-first one-shot callback, exact owner confirmation, cross-request answer misbinding, replay/expiry, 200k-gas callback limit, and listing/ad escrow regressions. Identify any remaining unsafe or unworkable assumptions; do not deploy.","parentJobId":null,"planHash":"dd74e626e39240c96045719cd9fab0fb8bc97b2a607f9d4a4caad55444dc177e","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"1053af02-a430-49b3-a914-75620c17c801","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":"52660","feedbackHash":"9d595361e625e2c86cc929e6ca2ca59b4c8b0aa1406fc0c10e6c3a2df5237183","nodeKey":"audit_economics","submissionHash":"f7296278070d1cedafc1391428e85a320c1ff46902aba9a76e0f426c4b2c734e","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52552","feedbackHash":"7700cb85abd297720c06fd5ae19dd103f68fcaf89916c7756b099e1fccd6b87f","nodeKey":"audit_flow","submissionHash":"ae46f8bb33ac3c2046f9af645512b5f325f210684fab6a99680474d484450733","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51040","feedbackHash":"63625e661153136fd2561410990d0dc8669c3a27706eb9e5f9fe204b7687a184","nodeKey":"audit_judge","submissionHash":"a995d069fca086f5df294fe22808164c9cc8498b64835a170e7d0300dd370d5c","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52468","feedbackHash":"40590129e1c1f6105afc5846ada08eb56cbe448c6973d31c7c624240649260e2","nodeKey":"audit_math","submissionHash":"d4806f7db0bc8199e5d34f27c30a6827f318f43aa03a008c52176eb9714b0921","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51484","feedbackHash":"5e126dbabd45cc4d9fa12aff9705cbe57e2aec8e811a5fe4c09ace167c98727c","nodeKey":"audit_permissions","submissionHash":"410032fd561f91bb9d2bed2a696383f1e19125d5aa1750f73b78ba49f0bfe999","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"d82cf1ebcd54c322cd714072d06668e954b264d3853c325a45aa77c9243397b8","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"6ed09c2eb1ff6aec","findings":[{"citation":"resolved","description":"Receipt-first flow stores a signature-verified answer (including its bool value, publicly readable through getReviewReceipt) while the ask stays Pending until the owner calls bindReview. expireReview only checks review == Pending, msg.sender == requester and block.timestamp > reviewDeadline; it does not consider _reviewReceipts[intakeRequestId]. The requester therefore sees the value before the owner binds, and can erase only unfavourable answers: expireReview deletes reviewOf, sets review = Unanswered, and requestReview then accepts a fresh paid ask. Since the owner must do off-chain authentication of the Intake-to-UUID mapping (docs: per-ask operational step) and reviewTimeout can be as low as 1 hour (default 1 day), a NotPassed receipt that is not bound before the deadline can be dropped, or the owner's pending bindReview can be front-run. Over repeated asks, Passed is reachable while every NotPassed is silently discarded; getJob shows no trace of the negative answer. This is a trust asymmetry: the party that benefits from the verdict controls whether a verified answer counts. Fix: once a receipt exists, block requester expiry (let only the owner reject a receipt as mispaired, or require binding/rejection within an owner window), or record the retained value in the Job and keep it visible; at minimum do not let requestReview reopen a job whose last ask holds an unrejected receipt.","line":494,"path":"src/imd/HookJobRequester.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport \"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 Tok {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function mint(address to, uint256 a) external { balanceOf[to] += a; }\n    function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }\n    function transferFrom(address f, address t, uint256 a) external returns (bool) {\n        allowance[f][msg.sender] -= a; balanceOf[f] -= a; balanceOf[t] += a; return true;\n    }\n    function transfer(address t, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[t] += a; return true; }\n}\n\ncontract Intake {\n    uint256 n;\n    function priceOf(bytes32, address) external pure returns (uint256) { return 1e18; }\n    function _take(address asset, uint256 amount) internal returns (bytes32) {\n        Tok(asset).transferFrom(msg.sender, address(this), amount);\n        return keccak256(abi.encode(++n));\n    }\n    function request(bytes32, bytes calldata, IIntake.Callback calldata, address asset, uint256 amount)\n        external payable returns (bytes32) { return _take(asset, amount); }\n    function requestWithFailure(bytes32, bytes calldata, IIntake.Callback calldata, bytes4, address asset, uint256 amount)\n        external payable returns (bytes32) { return _take(asset, amount); }\n}\n\ncontract VerdictShoppingTest is Test {\n    HookJobRequester r;\n    Intake intake;\n    Tok tok;\n    address owner = makeAddr(\"owner\");\n    address creator = makeAddr(\"creator\");\n    uint256 key = 0xA11CE;\n    address signer;\n\n    function setUp() public {\n        vm.warp(1_000_000);\n        signer = vm.addr(key);\n        intake = new Intake();\n        tok = new Tok();\n        r = new HookJobRequester(owner, address(intake), address(tok), signer, 1e18);\n        vm.startPrank(owner);\n        r.setJobTemplate(\"job.open\", \"forge build forge test adversarial review \", \" \", \" \");\n        r.setReviewTemplate(\"oracle.request\", \"{\\\"job\\\":\\\"\", \"\\\"}\", 3, 2);\n        vm.stopPrank();\n        tok.mint(creator, 10e18);\n        vm.prank(creator);\n        tok.approve(address(r), type(uint256).max);\n    }\n\n    function _att(bytes32 uuid, bool passed) internal view returns (OracleAttestation.Attestation memory a) {\n        a.requestId = uuid;\n        a.chainId = 31337;\n        a.questionHash = keccak256(\"q\");\n        a.answerType = 0;\n        a.answer = abi.encode(passed);\n        a.fromBlock = 1;\n        a.toBlock = 2;\n        a.panelJobId = keccak256(\"panel\");\n        a.panelSize = 3;\n        a.quorum = 2;\n        a.agreed = 3;\n        a.issuedAt = uint64(block.timestamp);\n        a.expiresAt = uint64(block.timestamp + 3600);\n    }\n\n    function _sig(OracleAttestation.Attestation memory a) internal view returns (bytes memory) {\n        (uint8 v, bytes32 rr, bytes32 s) = vm.sign(key, this.digest(a));\n        return abi.encodePacked(rr, s, v);\n    }\n\n    function digest(OracleAttestation.Attestation calldata a) external view returns (bytes32) {\n        return r.attestationDigest(a);\n    }\n\n    /// A retained, signature-verified NotPassed answer must not be erasable by the requester\n    /// so they can buy a fresh ask and shop for a Passed verdict.\n    function testRequesterCannotDiscardRetainedNotPassedAnswer() public {\n        vm.prank(creator);\n        bytes32 jobId = r.openJob(bytes32(uint256(1)), \"hook\");\n        vm.prank(address(intake));\n        r.onJobResult(jobId, \"code\");\n        vm.prank(creator);\n        bytes32 askId = r.requestReview(jobId);\n\n        OracleAttestation.Attestation memory a = _att(keccak256(\"uuid-1\"), false);\n        bytes memory sig = _sig(a);\n        vm.prank(address(intake));\n        r.onReviewResult(askId, a, sig);\n        assertFalse(r.getReviewReceipt(askId).passed, \"verified NotPassed answer retained\");\n\n        // Owner is still authenticating the mapping off-chain; the 1-day deadline passes.\n        vm.warp(r.getReviewAsk(askId).deadline + 1);\n        vm.prank(creator);\n        (bool expired,) = address(r).call(abi.encodeCall(r.expireReview, (jobId)));\n        if (expired) {\n            // Requester now buys a fresh ask and the job is reviewable again.\n            vm.prank(creator);\n            (bool reasked,) = address(r).call(abi.encodeCall(r.requestReview, (jobId)));\n            assertFalse(reasked, \"verdict shopping: NotPassed answer discarded and a new ask opened\");\n        }\n        assertTrue(\n            uint8(r.getJob(jobId).review) != uint8(HookJobRequester.ReviewStatus.Unanswered),\n            \"retained answer erased to Unanswered\"\n        );\n    }\n}","reproduction":"Job delivered; requester calls requestReview (ask A). Intake delivers a validly signed attestation with answer=false, panelSize 3, quorum 2, agreed 3 -> ReviewReceived, getReviewReceipt(A).passed == false, status Pending. Warp to ask.deadline+1 before owner binds. Requester calls expireReview(job): expected revert (a verified answer is retained); actual success, review = Unanswered. Requester calls requestReview(job) again: expected NotReviewable; actual a new ask opens. Test test/scratch/VerdictShopping.t.sol fails with 'verdict shopping: NotPassed answer discarded and a new ask opened'.","severity":"medium","snippet":"    function expireReview(bytes32 jobRequestId) external {","title":"Requester can discard a retained, verified NotPassed answer via expireReview and re-buy a review (verdict shopping)"}],"hash":"410032fd561f91bb9d2bed2a696383f1e19125d5aa1750f73b78ba49f0bfe999","nodeId":"9fc5c6de-9e3e-460d-bc0a-2dfc46d1db43","outcome":"completed","summary":"I found one defect I could reproduce, rated medium. It is written to `.imd-findings.json` with a Foundry test (`test/scratch/VerdictShopping.t.sol`) that fails on commit 64e40684. Nothing was deployed and no project files were changed.\n\n**Finding (medium): a requester can throw away a verified \"not passed\" answer and buy a new review** (`src/imd/HookJobRequester.sol:494`, `expireReview`)\n- In the receipt-first flow, a checked answer is stored as a receipt but stays pending until the owner calls `bindReview`. Its yes/no value can be read by anyone through `getReviewReceipt`.\n- `expireReview` doesn't check whether a receipt exists. Once the review deadline has passed (1 day by default, can be set as low as 1 hour), the requester can expire an answer they don't like. They can also do it just before the owner's pending `bindReview` goes through.\n- After that, `requestReview` accepts a new paid request. Repeated asks let the requester drop every \"not passed\" answer and keep the first \"passed\" one, and `getJob` shows nothing of the dropped answers.\n- The test shows it: a signed `answer=false` receipt arrives, time moves past the deadline, `expireReview` succeeds, and `requestReview` opens a new request.\n- Possible fixes:\n  - Once a receipt exists, stop the requester from expiring it; only the owner could reject it as wrongly paired.\n  - Or record the stored value on the job so it stays visible.\n  - Or, at minimum, don't let `requestReview` reopen a job whose last request holds a receipt nobody has rejected.\n\n**Areas I checked that hold up, apart from behaviour the docs already state:**\n- **One-shot callbacks:** only the Intake can call them, and a second callback or failure can't overwrite or clear a receipt.\n- **Owner confirmation:** it checks the paid body's hash and all five signed fields exactly.\n- **Answers misbound across requests:** a stored receipt doesn't reserve the oracle ID, and binding refuses an ID that is already reserved or used. A swapped or duplicate delivery leaves that request stuck until the requester expires it, with the fee lost; the docs already say so.\n- **Replay and expiry:** used IDs are refused, and timestamps are widened before the 300-second tolerance is added.\n- **Listing/ad escrow:** cancellation, settlement and the accounting totals are unchanged, and ads staying visible after a transfer or archive is documented.\n\n**Owner powers (trust assumptions, not counted as findings):** `setIntake` leaves callbacks for requests sent to the old Intake rejected, and a new Intake could settle jobs it never received.\n\n**What I didn't verify:** I didn't measure callback gas under the 200,000-gas limit myself. I relied on the repo's existing tests, so gas use with a live signer contract is still unconfirmed.","treeHash":null,"usage":{"cachedInputTokens":677130,"inputTokens":24,"model":"claude-opus-5-5","outputTokens":10339,"runtime":"claude","turns":13,"wallClockMs":139377}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"df74f6c887684f20","findings":[{"citation":"resolved","description":"Merged from three specialists (audit_economics, audit_permissions, audit_math). In the receipt-first order the 5f43f94d fix recommends, onReviewResult stores a verified answer as a ReviewReceipt (lines 442-453) and leaves job.review Pending until the owner's bindReview. expireReview (lines 494-504) checks only review == Pending, msg.sender == requester and block.timestamp > reviewDeadline; it never consults _reviewReceipts[job.reviewRequestId]. The receipt's bool is public through getReviewReceipt, and reviewDeadline is askedAt + reviewTimeout (default 1 day, minimum 1 hour) measured from the ask, not from the receipt. So the party being judged can read a negative answer, wait out (or mempool-front-run) the owner's confirmation, erase it to Unanswered and pay 0.5 IMD for a fresh ask. Passed receipts get confirmed; NotPassed receipts get discarded. The docs present abandonment of a retained answer as allowed, but the asymmetry means a NotPassed verdict is only durable when the owner happens to confirm first, which defeats the terminal-NotPassed rule at line 315 and the property tested by testReviewNotPassedIsStoredNotReverted. Reproduced with both supplied proofs (Proof_f3885f4fc792 and Proof_20286c6ea28b fail on this commit for the stated reason). Minimal fix preserving the design: in expireReview revert when _reviewReceipts[job.reviewRequestId].oracleRequestId != 0 (or finalize the receipt instead of discarding it), and let only the owner close a receipt-bearing ask whose independently verified mapping shows the receipt belongs to another ask; alternatively start the abandonment clock at receivedAt. The attached proof passes under either variant.","line":496,"path":"src/imd/HookJobRequester.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport \"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 Tok {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function mint(address to, uint256 a) external { balanceOf[to] += a; }\n    function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }\n    function transferFrom(address f, address t, uint256 a) external returns (bool) {\n        allowance[f][msg.sender] -= a; balanceOf[f] -= a; balanceOf[t] += a; return true;\n    }\n    function transfer(address t, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[t] += a; return true; }\n}\n\ncontract Intake {\n    uint256 n;\n    function priceOf(bytes32, address) external pure returns (uint256) { return 1e18; }\n    function _take(address asset, uint256 amount) internal returns (bytes32) {\n        Tok(asset).transferFrom(msg.sender, address(this), amount);\n        return keccak256(abi.encode(++n));\n    }\n    function request(bytes32, bytes calldata, IIntake.Callback calldata, address asset, uint256 amount)\n        external payable returns (bytes32) { return _take(asset, amount); }\n    function requestWithFailure(bytes32, bytes calldata, IIntake.Callback calldata, bytes4, address asset, uint256 amount)\n        external payable returns (bytes32) { return _take(asset, amount); }\n}\n\ncontract VerdictShoppingTest is Test {\n    HookJobRequester r;\n    Intake intake;\n    Tok tok;\n    address owner = makeAddr(\"owner\");\n    address creator = makeAddr(\"creator\");\n    uint256 key = 0xA11CE;\n    address signer;\n\n    function setUp() public {\n        vm.warp(1_000_000);\n        signer = vm.addr(key);\n        intake = new Intake();\n        tok = new Tok();\n        r = new HookJobRequester(owner, address(intake), address(tok), signer, 1e18);\n        vm.startPrank(owner);\n        r.setJobTemplate(\"job.open\", \"forge build forge test adversarial review \", \" \", \" \");\n        r.setReviewTemplate(\"oracle.request\", \"{\\\"job\\\":\\\"\", \"\\\"}\", 3, 2);\n        vm.stopPrank();\n        tok.mint(creator, 10e18);\n        vm.prank(creator);\n        tok.approve(address(r), type(uint256).max);\n    }\n\n    function _att(bytes32 uuid, bool passed) internal view returns (OracleAttestation.Attestation memory a) {\n        a.requestId = uuid;\n        a.chainId = 31337;\n        a.questionHash = keccak256(\"q\");\n        a.answerType = 0;\n        a.answer = abi.encode(passed);\n        a.fromBlock = 1;\n        a.toBlock = 2;\n        a.panelJobId = keccak256(\"panel\");\n        a.panelSize = 3;\n        a.quorum = 2;\n        a.agreed = 3;\n        a.issuedAt = uint64(block.timestamp);\n        a.expiresAt = uint64(block.timestamp + 3600);\n    }\n\n    function _sig(OracleAttestation.Attestation memory a) internal view returns (bytes memory) {\n        (uint8 v, bytes32 rr, bytes32 s) = vm.sign(key, this.digest(a));\n        return abi.encodePacked(rr, s, v);\n    }\n\n    function digest(OracleAttestation.Attestation calldata a) external view returns (bytes32) {\n        return r.attestationDigest(a);\n    }\n\n    /// A retained, signature-verified NotPassed answer must not be erasable by the requester\n    /// so they can buy a fresh ask and shop for a Passed verdict.\n    function testRequesterCannotDiscardRetainedNotPassedAnswer() public {\n        vm.prank(creator);\n        bytes32 jobId = r.openJob(bytes32(uint256(1)), \"hook\");\n        vm.prank(address(intake));\n        r.onJobResult(jobId, \"code\");\n        vm.prank(creator);\n        bytes32 askId = r.requestReview(jobId);\n\n        OracleAttestation.Attestation memory a = _att(keccak256(\"uuid-1\"), false);\n        bytes memory sig = _sig(a);\n        vm.prank(address(intake));\n        r.onReviewResult(askId, a, sig);\n        assertFalse(r.getReviewReceipt(askId).passed, \"verified NotPassed answer retained\");\n\n        // Owner is still authenticating the mapping off-chain; the 1-day deadline passes.\n        vm.warp(r.getReviewAsk(askId).deadline + 1);\n        vm.prank(creator);\n        (bool expired,) = address(r).call(abi.encodeCall(r.expireReview, (jobId)));\n        if (expired) {\n            // Requester now buys a fresh ask and the job is reviewable again.\n            vm.prank(creator);\n            (bool reasked,) = address(r).call(abi.encodeCall(r.requestReview, (jobId)));\n            assertFalse(reasked, \"verdict shopping: NotPassed answer discarded and a new ask opened\");\n        }\n        assertTrue(\n            uint8(r.getJob(jobId).review) != uint8(HookJobRequester.ReviewStatus.Unanswered),\n            \"retained answer erased to Unanswered\"\n        );\n    }\n}","reproduction":"Job Delivered; creator calls requestReview -> askId (owner has not bound). Intake delivers a correctly signed attestation with answer=abi.encode(false), panel 3/2/3 (or 7/5/6), issuedAt=now, expiresAt=now+1h: onReviewResult stores the receipt, getReviewReceipt(askId).passed == false, job.review == Pending. vm.warp(getJob(jobId).reviewDeadline + 1); as creator call expireReview(jobId). Expected: revert (a verified answer is retained) or finalization to NotPassed. Actual: succeeds, review == Unanswered (4), reviewOf[askId] == 0, owner's bindReview(askId, bodyHash, uuid, questionHash, chainId, 1, 2) now reverts UnknownOrSettled(askId), and requestReview(jobId) is accepted again for another 0.5 IMD. Run: forge test --match-path test/scratch/Proof_f3885f4fc792.t.sol -> FAIL 'verdict shopping: NotPassed answer discarded and a new ask opened'; test/scratch/Proof_20286c6ea28b.t.sol -> FAIL 'next call did not revert as expected'.","severity":"medium","snippet":"        if (job.review != ReviewStatus.Pending) revert UnknownOrSettled(jobRequestId);","title":"Requester can discard a retained, signature-verified NotPassed answer via expireReview and buy a new ask (verdict shopping)"},{"citation":"resolved","description":"Merged from audit_flow, audit_economics and audit_math. The unbound path of onReviewResult writes a five-slot ReviewReceipt (five cold SSTOREs, ~110k) on top of signature verification, decoding and a second full EIP-712 hash for the ReviewReceived event (attestationDigest(a) at line 454 recomputes what _verifyAttestation already hashed). Measured on this commit from a contract acting as the Intake, forwarding an exact gas cap, with vm.cool on the requester, signer and registry (binary search): receipt path 145,701 gas with a bare ECDSA key; 157,472 with a minimal ERC-1271 registry (one ecrecover, two cold SLOADs); +10 extra cold SLOADs in the registry 180,374; +16: 194,120; +18: 198,702 (fits); +22: 207,866 (exceeds 200,000). The bound path for the same cases costs 89,994 / 101,768 / 152,162, so a registry that works on a pre-bound ask fails on the receipt-first path the docs now recommend. The audit_flow figure of 179k/189k did not reproduce under this method; the other two specialists' numbers match mine. When the callback runs out of gas inside the Intake's try, no receipt is stored, the Intake records its single attempt, the ask fee is kept, and the only recovery is expireReview after the deadline plus a new paid ask. The existing tests pass only because OracleRegistryMock is minimal; the docs say live registry gas 'still needs verification' but state no budget. Not a code defect by itself; it is an unverified live assumption with a concrete, measurable failure mode. Suggested: document a registry budget (isValidSignature <= ~40k on a cold account), add a test with a registry burning ~40k, and recover headroom by emitting the digest already computed in _verifyAttestation instead of rehashing, and/or dropping receivedAt (never read on chain) from the packed slot.","line":442,"path":"src/imd/HookJobRequester.sol","reproduction":"test/scratch/Judge.t.sol::testHeavyRegistryLosesOneShotReceipt: deploy Registry(signer, 22) whose isValidSignature sums 22 cold mapping slots then ecrecover-compares; owner setOracleSigner(registry). Job delivered, requestReview -> askA (unbound). vm.cool(requester), vm.cool(registry); Intake delivers a valid bool attestation with call{gas: 200000}. Expected (fix's claim): receipt stored, ReviewReceived emitted. Actual: complete() returns false, getReviewReceipt(askA).oracleRequestId == 0, a second complete() reverts 'not pending', fee spent. The same registry on a pre-bound ask (askB) delivers successfully and sets Passed. testGasReceiptPathEcdsa / testGasReceiptPathRegistry print the minimum forwarded gas figures above (145,701 ECDSA receipt; 89,994 ECDSA bound; registry+18 cold reads 198,702; +22 207,866).","severity":"low","snippet":"            _reviewReceipts[requestId] = ReviewReceipt({","title":"Receipt-first callback leaves about 42k gas of the 200k stipend for a production ERC-1271 registry; an over-budget callback loses the one-shot answer and the fee"},{"citation":"resolved","description":"From audit_flow. Once an unbound ask holds any receipt, bindReview accepts only a binding whose five context fields equal the receipt's (lines 362-370), and the receipt is immutable (onReviewResult reverts ReviewAlreadyReceived at line 411; onReviewFailure likewise at 486). The receipt carries no on-chain tie to the ask; the docs say exactly that the Intake-id-to-UUID pairing cannot be assumed and name 'wrong delivery pairing' as a residual that may lose a paid answer. The consequence the docs do not state: after a swap the owner's independently authenticated true mapping is the one binding the contract refuses forever (ReviewAttestationMismatch), while confirming the swapped fields is accepted and records the other job's answer (Passed/NotPassed, panelJobId, panel counts) as this job's verdict. Confirming the swapped fields also reserves and consumes that UUID, so the other job's genuine ask can then never bind its own UUID (DuplicateRequestId). The only non-owner-error recovery is requester expireReview plus a new paid ask, the fee loss the fix set out to remove. The 'cannot finalize under their independently correct bindings' claim in THREAT_MODEL.md is true but incomplete. Fix preserving the design: an owner-only, event-emitting discard of an unconfirmed receipt (receipt stays readable as history), so the ask can be closed by the owner rather than relying on the requester to expire and repay; or let the owner close such an ask as Unanswered without a fee.","line":370,"path":"src/imd/HookJobRequester.sol","reproduction":"test/scratch/Judge.t.sol::testSwappedReceiptStrandsTrueMappingAndFinalizesOtherAnswer: two delivered jobs A and B, requestReview on both -> askA, askB. Intake delivers B's correctly signed attestation (UUID_B, answer=true) under askA. bindReview(askA, bodyHashA, UUID_A, qhA, 31337, 1, 2) as owner -> reverts ReviewAttestationMismatch(askA) (A's genuine answer was NotPassed). A second complete(askA) reverts 'not pending' (one shot). bindReview(askA, bodyHashA, UUID_B, qhB, 31337, 1, 2) succeeds: getJob(A).review == Passed. Then bindReview(askB, bodyHashB, UUID_B, ...) reverts DuplicateRequestId(UUID_B). Expected: the owner can pin A's authenticated mapping or discard the misdelivered receipt without a new fee; actual: true mapping permanently refused, swapped fields finalize, B stranded too.","severity":"low","snippet":"        ) revert ReviewAttestationMismatch(intakeRequestId);","title":"A misdelivered (swapped) receipt permanently blocks the ask's true binding and can be confirmed into the other question's verdict; there is no owner path to discard it"},{"citation":"resolved","description":"From audit_flow. bindReview reserves intakeReviewOfOracle[oracleRequestId] (line 376) and nothing clears it: not expiry, not failure, not the owner. If the ask expires undelivered and the requester pays for a retry, and the protocol reuses the same UUID for the retried question (IMD_INTEGRATION.md lists UUID repetition across retries as an unverified pilot item), both routes for the retry's genuine signed answer revert DuplicateRequestId: onReviewResult at line 438 (reservedFor != requestId) and bindReview at line 359. consumed[UUID] stays false and no receipt exists, so the reservation is provably unused, yet the retry can never finalize and its fee is lost. Precondition is unverified but not excluded, which is why this is low. Fix preserving the design: an owner-only releaseReservation(oracleRequestId) allowed only when consumed[oracleRequestId] == false and the holder ask is terminal (reviewOf[holder] == 0 and no receipt), with an event; consumed UUIDs stay untouchable.","line":359,"path":"src/imd/HookJobRequester.sol","reproduction":"test/scratch/Judge.t.sol::testReservationOfUndeliveredUuidBlocksRetry: job delivered; askA = requestReview; owner bindReview(askA, bodyHash, X, qh, 31337, 1, 2); no delivery. vm.warp(reviewDeadline+1); creator expireReview(job); askA2 = requestReview(job). Intake delivers a valid attestation carrying X to askA2: onReviewResult reverts DuplicateRequestId(X). Owner bindReview(askA2, bodyHash2, X, ...) reverts DuplicateRequestId(X). consumed(X) == false, getReviewReceipt(askA2).oracleRequestId == 0. Expected: an undelivered reservation on a terminal ask can be released; actual: permanent refusal.","severity":"low","snippet":"        if (intakeReviewOfOracle[oracleRequestId] != bytes32(0) || consumed[oracleRequestId]) {","title":"Owner pre-binding reserves an oracle UUID permanently even when nothing is ever delivered; a protocol-side UUID repeat on a paid retry makes the retry unfinalizable"},{"citation":"resolved","description":"From audit_flow. The receipt branch of bindReview (lines 387-398) consumes the UUID and finalizes using only the stored receipt; no signer, freshness or current-authority check runs at confirmation. A receipt taken before setOracleSigner (including a rotation made because the key was compromised) still mints Passed/NotPassed afterwards, whereas an undelivered answer for a pre-bound ask fails BadSignature after the same rotation. AUDIT_5F43F94D.md, THREAT_MODEL.md and IMD_INTEGRATION.md all state this as deliberate historical-answer behavior, so it is reported as a trust assumption with concrete state rather than a code defect: an operator rotating a compromised signer to freeze in-flight verdicts will not freeze retained receipts. If that matters operationally, an owner-only revokeReceipt(intakeRequestId) (event + history kept) would close the gap without changing the normal flow.","line":387,"path":"src/imd/HookJobRequester.sol","reproduction":"test/scratch/Judge.t.sol::testReceiptFinalizesAfterSignerRotation: deliver a valid receipt for askA (unbound); owner setOracleSigner(newSigner); owner bindReview(askA, receipt fields) succeeds and getJob(A).review == Passed. Contrast: askB pre-bound to its UUID, then the same rotation, then delivery of a valid attestation signed by the old key: complete() returns false (BadSignature inside the Intake's try) and B stays Pending.","severity":"info","snippet":"        if (receipt.oracleRequestId != bytes32(0)) {","title":"Signer rotation or ERC-1271 revocation does not stop a retained receipt from being confirmed into a verdict (documented, but incident-response relevant)"},{"citation":"resolved","description":"From audit_economics. requestReview is allowed once a job is Delivered or Expired and refused forever once review is Passed or NotPassed. The template in test/helpers/ImdTemplates.sol (documented in IMD_INTEGRATION.md) defines the bool as 'Missing, pending or failed review means false'. An Expired job (completion notice lost, or the unconfirmed onJobResult(bytes32,bytes) ABI reverting inside the Intake's try, both named live assumptions) that is asked before the IMD-side review concludes receives an honest signed false, recorded as NotPassed permanently; onJobResult is refused for Expired jobs and requestReview reverts NotReviewable. The requester has paid for the job and the ask and the static site shows 'did not pass' for the life of the contract. Terminal NotPassed is the agreed economic rule (it is what prevents verdict shopping), so this is reported as a design caveat, not a defect: changing it (allowing a new paid ask after NotPassed with history kept, or dropping the 'pending means false' definition in favour of the existing onReviewFailure/Unanswered path) is a scope decision for the requester. Note the interaction with the medium finding: today the only way around a timing-induced NotPassed is the receipt-first expiry race, which is the wrong incentive.","line":315,"path":"src/imd/HookJobRequester.sol","reproduction":"test/scratch/Judge.t.sol::testExpiredJobFalseVerdictIsTerminal: openJob -> J Open; vm.warp(+7 days + 1); creator expireJob(J) -> Expired. requestReview(J) accepted (0.5 IMD pulled). Intake delivers a valid attestation with answer=false; owner bindReview -> review == NotPassed. requestReview(J) reverts NotReviewable(J); onJobResult(J, ...) from the Intake reverts UnknownOrSettled(J). Expected per the requester: a corrected verdict is purchasable once the review completes; actual: permanent NotPassed.","severity":"info","snippet":"                || (job.review != ReviewStatus.None && job.review != ReviewStatus.Unanswered)","title":"NotPassed is terminal while the shipped review question defines 'missing or pending review' as false, so a timing-induced honest false can never be corrected"}],"hash":"a995d069fca086f5df294fe22808164c9cc8498b64835a170e7d0300dd370d5c","nodeId":"3c8f3455-8b08-4ad1-a4fa-10f5b5b11c9e","outcome":"completed","summary":"The review is complete. The findings are in `.imd-findings.json` at the repository root, and no tracked file was changed.\n\n**Outcome.** The ten specialist findings collapse into six, all reproduced against commit 64e40684 with Foundry tests under `test/scratch/`. The full existing suite passes, and the store, listing and ad escrow contracts are byte-for-byte unchanged since the fix commit, so no escrow regression exists.\n\n**Kept findings, by severity:**\n\n- **Medium: verdict shopping through expireReview.** Three specialists reported the same defect. The function at `src/imd/HookJobRequester.sol:496` never checks for a retained receipt, so the requester can read a verified NotPassed answer, wait out the deadline, erase it to Unanswered and buy a new ask. Both supplied proofs fail on this commit for exactly that reason. I attached the permissions specialist's proof, which passes under either fix variant (refuse expiry, or finalize the receipt instead).\n- **Low: 200k stipend headroom.** Merged from three specialists. My cold-account measurement gives 145,701 gas for the receipt path with a bare key and 157,472 with a minimal ERC-1271 registry. A registry doing 18 extra cold reads still fits, 22 does not, and the failure loses the one-shot answer and fee. The flow specialist's 179k figure did not reproduce; the other two matched mine.\n- **Low: swapped receipt strands the true binding.** A misdelivered receipt makes the owner's authenticated mapping permanently refused, while confirming the swapped fields is accepted and also strands the other job. No owner-side discard exists.\n- **Low: permanent UUID reservation.** An undelivered pre-binding can never be released, so a protocol-side UUID repeat on a paid retry blocks both delivery and binding.\n- **Info: signer rotation does not revoke retained receipts.** Documented as deliberate in three places, so kept as a trust assumption with the incident-response caveat.\n- **Info: terminal NotPassed with a \"pending means false\" question.** The agreed economic rule, reported as a design caveat that interacts with the medium finding.\n\n**Dropped or downgraded.** Nothing was dropped outright. The rotation and terminal-NotPassed items were downgraded to info because the repository documents both as intended behavior, and the gas finding was downgraded from medium because it is an unverified live assumption rather than a defect in the mock-tested code.","treeHash":null,"usage":{"cachedInputTokens":811648,"inputTokens":290,"model":"claude-fable-5-1","outputTokens":26914,"runtime":"claude","turns":21,"wallClockMs":423143}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"809ae05b6282abad","findings":[{"citation":"resolved","description":"bindReview (and _answerReview via the receipt branch) treats a retained ReviewReceipt as authoritative for the ask's context: once an unbound ask has any receipt, the ONLY binding the contract accepts is one whose five fields equal the receipt's. The receipt itself carries no on-chain tie to the ask (the Intake id-to-oracle-UUID pairing is exactly what the design says cannot be assumed). Consequence: a misdelivered receipt (Intake delivers job B's signed answer to job A's ask; THREAT_MODEL.md and AUDIT_5F43F94D.md admit 'wrong pairing' as a residual) permanently rejects A's authenticated true mapping - bindReview(A, A's real UUID/hash/window) reverts ReviewAttestationMismatch forever because the receipt is immutable and ReviewAlreadyReceived blocks any second delivery - while confirming the receipt's (swapped) fields is ACCEPTED and records the other job's answer as A's verdict (job.review = Passed/NotPassed from receipt.passed, plus panelJobId/panelSize/agreed of the other question's answer). There is no owner-only reject/clear path, so a swapped receipt forces a choice between a wrong verdict and a stranded paid ask (requester expireReview + repay, the exact fee loss the 5f43f94d fix set out to remove). The docs' claim that swapped receipts 'cannot finalize under their independently correct bindings' is true but incomplete: they CAN finalize, and the contract actively prevents the correct binding.","line":362,"path":"src/imd/HookJobRequester.sol","reproduction":"1) creator opens+delivers job A and job B, requestReview on both -> askA, askB pending. 2) intake.complete(askA, encode(askA, attestationB, sigB)) - the swap. 3) expectRevert ReviewAttestationMismatch: bindReview(askA, keccak256(bodyOf(askA)), UUID_A, hash_A, chain, 1, 2). 4) bindReview(askA, keccak256(bodyOf(askA)), UUID_B, hash_B, chain, 1, 2) SUCCEEDS: getJob(A).review == Passed (B's answer) although A's genuine answer was NotPassed. Executed in test/scratch/Probe.t.sol::testProbeSwappedReceiptFinalizesOtherAsksAnswer (passes today). Expected: the owner's independently authenticated mapping is the only thing that can be pinned and a receipt is never itself authority, per IMD_INTEGRATION.md; actual: the authenticated mapping is permanently rejected and the unauthenticated one finalizes. Fix: add an owner-only, event-emitting 'discard/override unconfirmed receipt' (or make finalization from a receipt require an explicit override flag on bindReview) so the true mapping can be pinned; keep the receipt readable as history.","severity":"medium","snippet":"        ReviewReceipt storage receipt = _reviewReceipts[intakeRequestId];\n        if (\n            receipt.oracleRequestId != bytes32(0)\n                && (receipt.oracleRequestId != oracleRequestId\n                    || receipt.questionHash != questionHash\n                    || receipt.questionChainId != questionChainId\n                    || receipt.fromBlock != fromBlock\n                    || receipt.toBlock != toBlock)\n        ) revert ReviewAttestationMismatch(intakeRequestId);","title":"Misdelivered ('swapped') signed answer becomes a receipt that permanently blocks the ask's true mapping and can be confirmed into a verdict for the wrong question"},{"citation":"resolved","description":"The unbound-receipt path writes five fresh storage slots (5 x 22,100 cold SSTORE = 110,500) plus signature verification, decoding and an event, inside the Intake's fixed 200,000-gas callback call. Measured on this code with the requester and signer made cold (EIP-2929): 179,069 gas for the ECDSA path and 188,974 gas for the ERC-1271 path using the repository's own trivial mock registry (one ecrecover + compare). The authors' tests therefore pass only because the mock registry is minimal; the real registry (the docs recommend an ERC-1271 signer registry) can spend at most ~11k beyond the mock before the callback reverts. A revert inside the Intake's call is terminal for the ask: no receipt is stored, the one-shot attempt is recorded spent, the fee is kept, and a retry costs another fee (there is no retry, refund or reserve path). This is a concrete, quantified bound rather than the general 'gas must be verified in a pilot' note, and the ERC-1271 configuration is the one the docs recommend for signer rotation.","line":442,"path":"src/imd/HookJobRequester.sol","reproduction":"Executed in test/scratch/Probe.t.sol: (a) testProbeMeasureReceiptPathGas: with vm.cool(requester) and vm.cool(signer), a direct onReviewResult receipt call costs 179,069 gas (ECDSA); testProbeMeasureErc1271ReceiptGas with OracleRegistryMock (custom, minimal) costs 188,974 gas. (b) testProbeHeavyRegistryBreaksTheOneShotCallback: a registry whose isValidSignature burns ~90k gas makes intake.complete return false; getReviewReceipt stays zero, the job stays Pending, and the mock Intake refuses any second complete() ('not pending'), so the signed answer and the paid fee are lost although the registry can be fixed afterwards. Expected: the receipt path fits the 200k stipend with the production signer/registry; actual: ~95% of the stipend is consumed before the registry does meaningful work on a cold account. Fix: shrink the retained state (e.g., store keccak256 of the context plus a compact slot pack, or store the answer digest and require the signed attestation again at confirmation), and validate with the real registry under cold access.","severity":"medium","snippet":"            _reviewReceipts[requestId] = ReviewReceipt({\n                oracleRequestId: a.requestId,\n                questionHash: a.questionHash,\n                questionChainId: a.chainId,\n                panelJobId: a.panelJobId,\n                fromBlock: a.fromBlock,\n                toBlock: a.toBlock,\n                receivedAt: uint64(block.timestamp),\n                panelSize: a.panelSize,\n                agreed: a.agreed,\n                passed: passed\n            });","title":"Receipt-first callback consumes ~179k-189k of the 200,000-gas stipend on a cold account, leaving ~11k headroom for a real ERC-1271 signer registry; over-budget callbacks lose the paid one-shot answer "},{"citation":"resolved","description":"The receipt branch of bindReview consumes the UUID and finalizes the verdict using only the stored receipt: no signer check, no freshness check, no current-authority re-check. A receipt taken before a signer rotation (including a rotation performed because the key was compromised, or an ERC-1271 registry revocation) still mints Passed/NotPassed afterwards. This differs from the pre-bound path: an identical callback delivered after the rotation fails _verifyAttestation with BadSignature and can never settle that ask, so rotation defeats an undelivered answer but not a receipt. The docs (AUDIT_5F43F94D.md 'Remaining live assumptions', THREAT_MODEL.md 'Delayed confirmation...') assert this as deliberate historical-answer behavior; it is reported here as a material operational caveat with concrete state, not as a code contradiction, because an operator rotating a compromised signer to freeze in-flight verdicts would be wrong about the effect on retained receipts.","line":387,"path":"src/imd/HookJobRequester.sol","reproduction":"Executed in test/scratch/Probe.t.sol::testProbeConfirmAfterSignerRotationMintsVerdict: deliver a valid receipt for ask A; owner calls setOracleSigner(stranger) (revocation/rotation); bindReview(A, bodyHash, receipt fields) succeeds and getJob(A).review == Passed. To observe the contrast, pre-bind an ask to the same UUID, rotate, and deliver: intake.complete reverts BadSignature. Expected (incident response): after a rotation, answers signed by the rotated-out key can no longer become verdicts; actual: only undelivered ones are stopped; retained receipts still finalize. Fix: keep the documented behavior but add monitoring/ops guidance, or add an owner-only revokeReceipt(intakeRequestId) that marks a retained receipt unusable (event + history preserved) so rotation can freeze unconfirmed answers without changing the normal confirmation flow.","severity":"low","snippet":"        if (receipt.oracleRequestId != bytes32(0)) {\n            _consume(oracleRequestId);","title":"Confirmation mints a verdict from an answer verified under a signer that has since been rotated out (rotation cannot revoke an unconfirmed receipt)"},{"citation":"resolved","description":"bindReview permanently reserves an oracle UUID to an ask (intakeReviewOfOracle) even if no answer is ever delivered for that ask, and nothing - not failure, not expiry, not settlement, not the owner - can clear the reservation. The docs list 'whether live Intake IDs or oracle UUIDs repeat across actions, retries or protocol migrations' as an UNVERIFIED pilot item while simultaneously stating 'a retry needs its own independently authenticated UUID even for identical wording'. If the protocol does repeat a UUID for the retry of a failed/expired question (the exact case a paid retry exists for), every path for the retry's genuine, correctly signed answer is refused: onReviewResult for the retry ask reverts DuplicateRequestId (line 438) and bindReview for the retry with that UUID reverts DuplicateRequestId (line 359), so the retry can never finalize and its fee is lost. Concrete precondition: ask A bound to UUID X (owner pre-binding) whose delivery never arrives; A expires; requester pays for retry A2; the retry's oracle request carries X again (repetition unverified but unexcluded).","line":359,"path":"src/imd/HookJobRequester.sol","reproduction":"With test/ReviewBinding.t.sol-style fixtures: _reviewPending() gives a bound ask A (UUID X). vm.warp(deadline+1); expireReview(job); requestReview(job) -> A2. Then (i) vm.prank(intake); expectRevert DuplicateRequestId(X); onReviewResult(A2, attestation carrying X, valid signature); (ii) vm.prank(owner); expectRevert DuplicateRequestId(X); bindReview(A2, bodyHash, X, hash, chain, 1, 2). Both revert although X was never delivered or consumed (consumed[X] == false, getReviewReceipt(A2) == 0). Expected: a provably undelivered reservation on a terminal ask can be released so a legitimate retry can use the protocol's answer; actual: permanent refusal, retry fee stranded. Fix: add an owner-only releaseReservation(oracleRequestId) callable only when consumed[oracleRequestId] == false and reviewOf[reservation holder] == 0 (terminal ask), with an event; do not touch consumed UUIDs.","severity":"low","snippet":"        if (intakeReviewOfOracle[oracleRequestId] != bytes32(0) || consumed[oracleRequestId]) {\n            revert DuplicateRequestId(oracleRequestId);\n        }","title":"UUID reservations from paid asks are permanent with no release path; a protocol-side UUID repeat on a retry makes that retry permanently unfinalizable (both callback and binding revert DuplicateReques"}],"hash":"ae46f8bb33ac3c2046f9af645512b5f325f210684fab6a99680474d484450733","nodeId":"3581e863-f88e-4611-a198-91f8f6b9a940","outcome":"completed","summary":"The re-audit is complete. The deliverable is written to `.imd-findings.json` with four substantiated findings, each reproduced by an executed probe; all snippets were verified to match the tree verbatim at their anchor lines, and the shipped suite still passes (178 tests, plus my 5 probes in `test/scratch/`). No source file was modified.\n\n**Medium: a swapped (misdelivered) signed answer can be confirmed into a wrong-question verdict and permanently blocks the ask's true mapping.** Once an unbound ask holds any receipt, `bindReview` accepts only the receipt's five fields (src/imd/HookJobRequester.sol:362). In my probe, delivering job B's answer to job A's ask made A's real mapping revert `ReviewAttestationMismatch` forever, while confirming the swapped fields was accepted and recorded B's \"Passed\" as job A's verdict. There is no owner reject or override path, so a misdelivery forces either a wrong verdict or a stranded paid ask.\n\n**Medium: the receipt path nearly exhausts the 200k callback stipend.** Measured cold: 179,069 gas for the ECDSA path and 188,974 for the ERC-1271 path with the repository's minimal mock registry, leaving about 11k headroom for a real registry. A registry burning 90k made the callback fail, and the one-shot answer plus fee were permanently lost. The fix should shrink the five-slot write and be validated against the production registry.\n\n**Low: confirmation mints a verdict from a rotated-out signer.** A receipt taken before `setOracleSigner` still finalizes afterwards (reproduced), unlike the pre-bound path where the same answer fails `BadSignature`. The docs call this deliberate; an operator rotating a compromised key would be wrong about its effect.\n\n**Low: UUID reservations are permanent.** If the protocol repeats a UUID on a paid retry (docs list UUID uniqueness as unverified), both the callback and the binding revert `DuplicateRequestId`, so the retry can never finalize and its fee is lost.\n\nNo defects were found in the receipt/expiry/replay mechanics themselves (consume-once, expiry terminality, and snapshots all hold), and no regression in the listing/ad escrow: the fix commit touched only the requester, and the escrow's conservation, original-payer refund, and current-owner cancellation properties still hold under its invariant suites. No finding rated critical or high, so no Foundry proof files were attached.","treeHash":null,"usage":{"cachedInputTokens":4153344,"inputTokens":259654,"model":"claude-fable-5-1","outputTokens":100538,"runtime":"claude","turns":60,"wallClockMs":740491}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"c9743cd9807c4c3a","findings":[{"citation":"resolved","description":"The 5f43f94d fix made the one-shot callback retain a signature-verified answer in _reviewReceipts when the ask is not yet bound, and left the ask Pending until the owner confirms. expireReview only checks review == Pending, msg.sender == requester and block.timestamp > reviewDeadline; it does not check whether a receipt already exists. reviewDeadline is askedAt + reviewTimeout (default 1 day, minimum 1 hour), measured from the ask, not from the receipt. So the owner's window to confirm a negative answer is whatever is left of reviewTimeout after the answer lands, and the requester can wait it out (or front-run the owner's bindReview in the mempool) and call expireReview. That deletes reviewOf[askId], sets review = Unanswered and re-enables requestReview, while the NotPassed receipt stays orphaned in storage. bindReview then reverts UnknownOrSettled, and the ReviewReceived event is followed by ReviewUnanswered(\"expired\"), so the static page shows \"no verified review result\" instead of NotPassed. A requester can repeat this for 0.5 IMD per attempt until an answer they like arrives, and a NotPassed verdict can never be forced on chain by the oracle plus owner; only Passed verdicts are durable. The docs (AUDIT_5F43F94D.md, IMD_INTEGRATION.md) describe abandonment of a retained answer as allowed and tell the requester to check first, but the asymmetry is not stated: a requester who received a negative answer has every incentive to abandon it. This does not create a false Passed and is a design tradeoff, so it is low. Fix options, in order of least design change: (1) in expireReview, revert when _reviewReceipts[job.reviewRequestId].oracleRequestId != 0 (a retained answer is not an unanswered ask); to keep a misdelivered receipt from stranding the job forever, let the owner, not the requester, close such an ask (ReviewUnanswered) when the independently verified mapping shows the receipt belongs to another ask; or (2) start the abandonment clock at receivedAt rather than askedAt (require block.timestamp > receivedAt + reviewTimeout when a receipt exists) so the owner always has a full reviewTimeout to confirm after the answer arrives.","line":496,"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 {OracleAttestation} from \"src/imd/OracleAttestation.sol\";\nimport {IIntake} from \"src/imd/IIntake.sol\";\n/// @dev Minimal ERC-20 returning true, 18 decimals.\ncontract Token {\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        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: records the ask, pulls the price, delivers once under 200k gas.\ncontract Intake is IIntake {\n    struct Req {\n        address target;\n        bytes4 selector;\n        bool settled;\n    }\n\n    mapping(bytes32 => Req) public reqs;\n    uint256 internal nonce;\n    uint256 public price;\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 cb, address asset, uint256 amount)\n        external\n        payable\n        returns (bytes32 id)\n    {\n        return _req(cb, asset, amount);\n    }\n\n    function requestWithFailure(bytes32, bytes calldata, Callback calldata cb, bytes4, address asset, uint256 amount)\n        external\n        payable\n        returns (bytes32 id)\n    {\n        return _req(cb, asset, amount);\n    }\n\n    function _req(Callback calldata cb, address asset, uint256 amount) internal returns (bytes32 id) {\n        require(Token(asset).transferFrom(msg.sender, address(this), amount));\n        id = keccak256(abi.encode(address(this), ++nonce));\n        reqs[id] = Req(cb.target, cb.selector, false);\n    }\n\n    function complete(bytes32 id, bytes calldata args) external returns (bool ok) {\n        Req storage r = reqs[id];\n        require(r.target != address(0) && !r.settled, \"not pending\");\n        r.settled = true;\n        (ok,) = r.target.call{gas: 200_000}(bytes.concat(r.selector, args));\n    }\n}\n\ncontract RetainedNegativeDiscardTest is Test {\n    uint256 constant PRICE = 0.5 ether;\n    Token imd;\n    Intake intake;\n    HookJobRequester requester;\n    address owner = makeAddr(\"owner\");\n    address creator = makeAddr(\"creator\");\n    uint256 signerKey = uint256(keccak256(\"oracle key\"));\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        imd = new Token();\n        intake = new Intake(PRICE);\n        requester = new HookJobRequester(owner, address(intake), address(imd), vm.addr(signerKey), PRICE);\n        vm.startPrank(owner);\n        requester.setJobTemplate(\"job.open@test\", \"forge build forge test adversarial review <brief>\", \"\", \"</brief>\");\n        requester.setReviewTemplate(\"oracle.request@test\", \"did \", \" 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 a) {\n        a = OracleAttestation.Attestation({\n            requestId: keccak256(abi.encode(\"uuid\", askId)),\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\"),\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    /// A signed, verified NotPassed answer is retained for the ask. After `reviewTimeout` the\n    /// requester discards it before the owner confirms, so the negative verdict never lands and a\n    /// fresh paid ask can be opened. Expected: an ask that already holds a verified answer cannot be\n    /// abandoned by its requester. Actual: expireReview succeeds and the receipt is orphaned.\n    function testRetainedNegativeAnswerCanBeDiscardedByRequester() public {\n        vm.prank(creator);\n        bytes32 jobId = requester.openJob(keccak256(\"spec\"), \"a hook\");\n        assertTrue(intake.complete(jobId, abi.encode(jobId, bytes(\"ipfs://result\"))));\n        vm.prank(creator);\n        bytes32 askId = requester.requestReview(jobId);\n\n        OracleAttestation.Attestation memory a = _attestation(askId, false);\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerKey, requester.attestationDigest(a));\n        assertTrue(intake.complete(askId, abi.encode(askId, a, abi.encodePacked(r, s, v))), \"receipt retained\");\n        assertEq(requester.getReviewReceipt(askId).oracleRequestId, a.requestId);\n        assertFalse(requester.getReviewReceipt(askId).passed);\n\n        vm.warp(uint256(requester.getJob(jobId).reviewDeadline) + 1);\n        vm.prank(creator);\n        vm.expectRevert(); // an ask holding a verified answer should not be abandonable\n        requester.expireReview(jobId);\n\n        // If expiry were refused, the owner's later confirmation lands the negative verdict.\n        vm.prank(owner);\n        requester.bindReview(\n            askId, requester.getReviewAsk(askId).bodyHash, a.requestId, a.questionHash, a.chainId, a.fromBlock, a.toBlock\n        );\n        assertEq(uint8(requester.getJob(jobId).review), uint8(HookJobRequester.ReviewStatus.NotPassed));\n    }\n}","reproduction":"State: owner configured templates with reviewTimeout = 1 day (default). Creator opens a job, the Intake delivers it, creator calls requestReview -> askId (unbound). Intake delivers a correctly signed attestation with answer = abi.encode(false), panel 7/5/6, issuedAt = now, expiresAt = now + 1 hour: the callback succeeds, getReviewReceipt(askId).oracleRequestId != 0 and .passed == false, job.review == Pending. Owner has not yet called bindReview. Warp to job.reviewDeadline + 1 (askedAt + 1 day + 1). Creator calls expireReview(jobId). Expected: revert, because the ask already holds a verified answer awaiting confirmation. Actual: succeeds; review == Unanswered, reviewOf[askId] == 0, the receipt remains readable, bindReview(askId, ...) now reverts UnknownOrSettled(askId), and requestReview(jobId) is accepted again for another 0.5 IMD. The scratch test test/scratch/RetainedNegativeDiscard.t.sol fails on the current code with 'next call did not revert as expected' and passes once expireReview refuses an ask that holds a receipt (the owner's bindReview then records NotPassed).","severity":"low","snippet":"        if (job.review != ReviewStatus.Pending) revert UnknownOrSettled(jobRequestId);","title":"Requester can discard a retained, verified NotPassed answer after reviewTimeout, so negative verdicts are never durable"},{"citation":"resolved","description":"The fix's claim that the unbound receipt path fits the Intake's 200,000-gas stipend holds on this commit, but only the mock registry's cost was exercised, and the remaining budget for a production signer is not stated anywhere. Measured on commit 64e40684 by forwarding an exact gas cap to onReviewResult from a contract set as the Intake, with vm.cool on the requester, the signer and the registry (binary search over the cap): unbound receipt with an ECDSA key needs ~145.7k-148.7k; unbound receipt with a two-slot ERC-1271 registry (one ecrecover plus two cold SLOADs, like the test mock) needs ~157.5k; each further cold SLOAD inside isValidSignature adds ~2.4k; with 16 extra cold reads it needs ~195.6k and still fits; with 18 extra cold reads (about 43k above a bare registry) it exceeds 200k, the callback runs out of gas inside the Intake's try, no receipt is stored, the Intake records its single attempt as settled, and the review fee is kept. The bound path is far cheaper (~90k ECDSA, ~102k with the mock registry, ~140k with 16 extra reads) because it does not write the five receipt slots, so a signer that works in the pre-bound flow can silently fail in the receipt-first flow that the docs now recommend. A registry that consults a role table plus a per-key expiry, is behind a proxy (one extra cold account access and a DELEGATECALL, ~5k), or reads a pause flag and a nonce can reach that budget. The other one-shot loss conditions are already documented (delivery after expiresAt, >300s forward clock skew) but this budget is not. Suggested: state a registry gas budget (<= ~40k for isValidSignature) in IMD_INTEGRATION.md, add a test with a registry burning ~40k to pin it, and recover headroom cheaply: the ReviewReceived event recomputes attestationDigest(a) (a second full EIP-712 hash, ~3-4k) that could be dropped or replaced by the already-computed digest, and receivedAt is never read on chain and could be omitted from the packed slot.","line":74,"path":"src/imd/HookJobRequester.sol","reproduction":"Deploy a registry R implementing isValidSignature(bytes32,bytes) that performs N cold SLOADs (e.g. sums mapping(uint256=>uint256) entries 0..N-1 written in its constructor) and then ecrecover-compares to the oracle key; owner calls setOracleSigner(R). Creator opens and gets a delivered job, calls requestReview (unbound). The Intake delivers a correctly signed bool attestation with call{gas: 200000}, all requester, signer and registry storage cold. Expected per the fix's claim: the receipt is stored and ReviewReceived is emitted. Actual: with N = 16 the callback succeeds (minimum forwarded gas ~195.6k); with N = 18 it fails (minimum forwarded gas > 200k), getReviewReceipt(askId).oracleRequestId == 0, review stays Pending, the mock Intake's request is settled and cannot be redelivered, and the only recovery is expireReview after the deadline plus a new paid ask. The same N = 18 registry succeeds on a pre-bound ask (bound path ~140k), showing the regression is specific to the receipt-first path introduced by the fix.","severity":"info","snippet":"    /// Packed into five slots to fit the Intake's 200,000-gas callback stipend.","title":"200k-stipend headroom for the receipt-first callback is about 43-54k; an ERC-1271 registry costing more than ~43k loses the one-shot answer and the fee"}],"hash":"d4806f7db0bc8199e5d34f27c30a6827f318f43aa03a008c52176eb9714b0921","nodeId":"cb0dc032-6ba3-4e9f-9cf2-21abd3c16af6","outcome":"completed","summary":"The re-audit is complete and the findings are in `.imd-findings.json`. No tracked file was changed; the only writes are the findings file and one scratch test.\n\n**Outcome.** The 5f43f94d fixes hold up. I found no critical or high defect. The receipt-first callback, exact owner confirmation, cross-request misbinding guards, replay and expiry handling, and the ad escrow all behave as the fix documentation claims. Two findings are recorded, one low and one informational.\n\n**What I verified.**\n- Full suite on the pinned commit: 178 tests pass, including the two escrow invariant suites.\n- Store contracts are byte-identical to the previously audited commit, so there is no listing or ad escrow regression. The cancel refund math conserves `refund + earned == PRICE` exactly, and all `[startsAt, endsAt)` boundaries are consistent across purchase, cancel, settle and the active-view.\n- Receipt-first flow: a swapped or duplicated unbound delivery cannot reserve or consume a UUID, a wrong binding reverts before any state change, a bound UUID stays reserved after failure or expiry, and a consumed attestation cannot be re-presented to a second ask or a second consumer domain.\n- Timing boundaries: backward tolerance widens to 256 bits before adding, expiry is accepted at exactly `expiresAt`, abandonment is strictly after the deadline, and the EIP-712 two-half encoding is byte-equivalent to a single encode.\n- Gas, measured by forwarding an exact cap from a contract acting as the Intake with cold storage: the unbound receipt path needs about 149k with an ECDSA key and about 157k with a two-slot ERC-1271 registry. The stipend claim is true with roughly 25% headroom.\n\n**Findings.**\n- **Low, `expireReview` at line 496.** A requester can abandon an ask that already holds a verified NotPassed receipt once `reviewTimeout` has elapsed since the ask, not since the answer. The owner's confirmation window can therefore be near zero, and the requester can front-run `bindReview`. The negative verdict is orphaned and a new paid ask opens. No false Passed is possible, so this is a design tradeoff, but it means only positive verdicts are durable on chain. The scratch test under `test/scratch/` fails on the current code for this reason.\n- **Info, receipt struct comment at line 74.** The remaining budget for a production ERC-1271 registry in the receipt-first path is about 43k. A registry doing 18 or more extra cold storage reads pushes the one-shot callback past 200k, the fee is kept and the answer is lost, while the same registry works in the pre-bound path. The number is not documented anywhere and no test pins it.\n\n**Coverage limits.** All twelve specialty passes were applied as review lenses by one reviewer, not as independent agents. No live Intake, signer registry or chain was queried, so the Intake callback ABI, actual stipend and canonical question-hash mapping remain the open pilot items the repository already lists.","treeHash":null,"usage":{"cachedInputTokens":2226904,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":50985,"runtime":"claude","turns":47,"wallClockMs":788297}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"1ec9f666356a7ad7","findings":[{"citation":"resolved","description":"The 5f43f94d fix retains a signature-verified answer as a ReviewReceipt when the callback arrives before the owner's bindReview, and expireReview (lines 494-504) deliberately still lets the requester abandon a Pending ask after reviewDeadline even when a receipt is retained. The receipt's `passed` value is public through getReviewReceipt, so the party being judged can read a negative verdict and race the owner's confirmation. In the bound-first order the same answer is final the moment it arrives (onReviewResult consumes and sets NotPassed); in the recommended receipt-first order a NotPassed answer is final only if the owner confirms within reviewTimeout (default 1 day, minimum 1 hour), while confirmation is a manual off-chain authentication step whose live data source is itself still an open pilot item. The asymmetry turns the review into a free option: a Passed receipt gets confirmed, a NotPassed receipt gets expired (front-running the owner's bindReview in the mempool if needed) and the public status falls back to Unanswered / 'No verified review result' instead of 'did not pass'. The requester can then buy a new ask. The verdict integrity property the contract exists to provide (NotPassed is stored, not reverted; see testReviewNotPassedIsStoredNotReverted) only holds for answers the owner happens to confirm first. Fix that preserves the design: in expireReview, refuse (or finalize instead of discard) when _reviewReceipts[job.reviewRequestId].oracleRequestId != 0, or let the owner close a receipt-bearing ask; alternatively require owner co-signature to abandon a received ask.","line":496,"path":"src/imd/HookJobRequester.sol","reproduction":"State: a job Delivered; requester calls requestReview -> ask A (receipt-first, owner has not bound). Intake delivers a correctly signed attestation with answer=false under A within the signature window; onReviewResult stores the receipt (passed=false), emits ReviewReceived, job.review stays Pending. Requester reads getReviewReceipt(A).passed == false. Warp to reviewDeadline+1 and, as the requester, call expireReview(jobId) ahead of the owner's bindReview (same block or mempool front-run). Expected: a verified negative answer retained on chain cannot be erased by the requester; job ends NotPassed. Actual: expireReview succeeds, job.review = Unanswered (4), the owner's exact bindReview(A, bodyHash, uuid, questionHash, chainId, 1, 2) reverts UnknownOrSettled(A), and requestReview(jobId) is accepted again for another 0.5 IMD. Scratch test test/scratch/ReceiptSuppression.t.sol fails on this commit with '4 != 3' (Unanswered vs NotPassed); the same sequence with the owner binding first ends NotPassed and cannot be re-asked.","severity":"low","snippet":"        if (job.review != ReviewStatus.Pending) revert UnknownOrSettled(jobRequestId);","title":"Receipt-first order lets the requester discard a verified NotPassed answer before the owner confirms it"},{"citation":"resolved","description":"requestReview is allowed as soon as the job is Delivered or Expired and refused forever once job.review is Passed or NotPassed. The review template the owner is told to set (test/helpers/ImdTemplates.sol lines 16-18, documented in IMD_INTEGRATION.md) defines the answer as 'Missing, pending or failed review means false'. The two rules together make the verdict depend on when the question is asked, not only on the review outcome: an Expired job (completion notice lost, or the unconfirmed onJobResult(bytes32,bytes) ABI reverting inside the Intake's try, which the docs name as a live assumption) or a job whose completion precedes the independent adversarial-review step produces an honest signed false that is recorded as NotPassed permanently. The requester has paid 0.5 IMD for the job plus 0.5 IMD for the ask, the static site shows 'did not pass' forever, and there is no path to a corrected verdict: onJobResult is refused for Expired jobs and requestReview reverts NotReviewable. Combined with the previous finding, the only way a requester can avoid a timing-induced permanent NotPassed is to exploit the receipt-first expiry race, which is the wrong incentive. Fix preserving the design: allow a new paid ask after NotPassed (keeping the history of every answered ask), or gate requestReview on evidence that the review step has concluded, or drop the 'pending means false' definition in favour of an abstain/failure path (onReviewFailure already maps to Unanswered and allows a retry).","line":315,"path":"src/imd/HookJobRequester.sol","reproduction":"State: openJob -> job J Open; no completion arrives (e.g. live callback ABI differs). Warp 7 days + 1 s; requester expireJob(J) -> Expired. requestReview(J) is accepted (0.5 IMD pulled). The panel answers the shipped question honestly with false because the job page shows no completed review; Intake delivers the signed attestation (bound-first or receipt-first then bindReview). job.review = NotPassed, reviewPanelSize/agreed recorded. Later the job's review does complete on the IMD side. Expected: the requester can buy a fresh verdict reflecting the completed review. Actual: requestReview(J) reverts NotReviewable(J) for the life of the contract (existing tests testReviewOnlyForDeliveredOrExpiredJobsByRequester lines 662-681 and testReviewPassedOnlyFromSignedOracleAnswer lines 531-534 show both halves of this state).","severity":"low","snippet":"                || (job.review != ReviewStatus.None && job.review != ReviewStatus.Unanswered)","title":"A false verdict is terminal while the shipped question maps 'pending or missing review' to false"},{"citation":"resolved","description":"The unbound path of onReviewResult now writes a five-slot receipt (five cold SSTOREs, about 110k gas) in addition to the cold reads of reviewOf, the ReviewAsk, oracleSigner, consumed and intakeReviewOfOracle, the EIP-712 hashing twice (verify plus the ReviewReceived event's attestationDigest), and signature verification. Measured on this commit with vm.cool: 145,701 gas for a bare ECDSA key and 155,606 gas with the repository's OracleRegistryMock, so the real oracle signer registry (docs say the published signer may be 'a key or an ERC-1271 registry') has roughly 40-45k gas for its own work, including the 2,600 cold account access and the ERC-1271 staticcall overhead. The docs already say live registry gas 'still needs verification'; this quantifies the margin so the pilot has a pass/fail number. The delivery is one-shot: an out-of-gas callback is recorded as the Intake's single attempt, no receipt is written, the 0.5 IMD ask fee is gone, and the requester can only expire and pay again. Not a code defect on its own, but the bound path and receipt path should be budgeted explicitly (e.g. drop the second attestationDigest computation from the event, or emit the digest computed once in _verifyAttestation) before pointing oracleSigner at a registry whose isValidSignature touches more than ~15 cold slots.","line":434,"path":"src/imd/HookJobRequester.sol","reproduction":"test/scratch/RegistryGasMargin.t.sol: a registry whose isValidSignature recovers the signer and scans an address[] of keys in storage. With vm.cool on the requester and registry and the Intake calling under call{gas: 200000}: lists of 1, 6, 11 and 16 entries deliver successfully; lists of 21 entries and above (about 20 extra cold SLOADs, ~42k gas) make the callback run out of gas: intake.complete returns false, getReviewReceipt(askId).oracleRequestId == 0, the Intake's request is settled, and the fee stays spent. Expected: the stipend budget is documented with a measured margin and the live registry is checked against it; actual: the margin is implicit and about 40k gas on the receipt path.","severity":"info","snippet":"        _verifyAttestation(a, signature);","title":"Receipt-first callback leaves about 40k gas of the 200k stipend for a live ERC-1271 registry; a key list of ~20 slots loses the one-shot fee"}],"hash":"f7296278070d1cedafc1391428e85a320c1ff46902aba9a76e0f426c4b2c734e","nodeId":"1e622327-bc95-4d37-90cf-e7e234c0107e","outcome":"completed","summary":"All three snippets match the tree, the JSON parses, and no tracked file was modified. The review is complete.\n\n**Outcome.** The 5f43f94d fixes hold on every path the task named. The receipt-first one-shot callback, exact five-field owner confirmation, swapped and duplicated answers, replay of consumed or reserved UUIDs, expired or back-dated signatures, and the ad escrow cancel change all behave as the docs claim. All 178 existing tests pass. I found no critical or high defect, so no revision-opening proof was needed for one. Three lower-severity issues are written to `.imd-findings.json`.\n\n**Findings.**\n\n- **Low, verdict suppression.** In the recommended receipt-first order, a signed NotPassed answer sits as a receipt until the owner binds. The requester can read it, wait out the one-day review deadline, and call expireReview ahead of the owner's bindReview. The verdict becomes Unanswered and the job can be asked again. In the bound-first order the same answer is final on arrival. A scratch proof test fails on this commit and passes under either natural fix (refuse expiry while a receipt is retained, or finalize instead of discard).\n- **Low, terminal false verdict.** A NotPassed verdict can never be re-asked, while the shipped question template defines pending or missing review as false. An Expired job, or a completion notice that precedes the review step, yields an honest false that is permanent, with both fees spent.\n- **Info, callback gas margin.** The receipt path uses about 146k gas with a bare key and 156k with the repository's registry mock, leaving roughly 40k for a live ERC-1271 registry. A registry scanning about 20 cold slots exhausts the stipend and loses the one-shot fee. This is measured, not assumed, so the pilot has a number to check against.\n\n**Coverage.** Economic, invariant and flow-gap passes ran in full over the requester contract, the oracle consumer, the ad escrow and the listing registry. Listing and ad conservation, pro-rata refunds and the new owner-cancel path showed no regression. Trust assumptions already documented (owner rotation of Intake and signer, owner liveness for every verdict, no refund on a failed one-shot delivery) were confirmed and not re-reported. Nothing was deployed and no repository file outside `test/scratch/` and the findings file was touched.","treeHash":null,"usage":{"cachedInputTokens":2181667,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":45988,"runtime":"claude","turns":42,"wallClockMs":744604}}],"verification":[]}