{"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":"5f43f94d-4f44-4255-a8c2-e0a2605c4897","kind":"audit","nodes":[{"acceptedSubmissionHash":"071e6d49d51bb0a432313374f7f80eb2e89ca116b3a19ada2eded7aea49ae655","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":"5b3255a64893b059d770cdb0c9a1dc87f334b079419b1680a7e8a937188cd1b2","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":"348cdca17c9cfebc6a80fe2d14c9f7e358b40291e2ee17c0997caaef0a9aaf50","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":"19d8a1fb43a0a671de9678a6d97d50f2355932cb0b343fd325c8e063b23ad100","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":"6dcb4ec39fb6df2f9d3567b6298b632797013a2c573b954bce1ca6a44633a012","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-review the fixes for audit d9970905 at commit 684c3da344f8f9f3a34ed810e55c243eec485090. Test exact oracle-attestation binding, replay/expiry, two-step ownership, EIP-7702/ERC-1271 signatures, and ad cancellation after listing transfer. Check for regressions in escrow accounting. Examine whether the new owner-per-review binding can be completed before the Intake's one-shot callback; distinguish proven local safety from unverified live compatibility.","parentJobId":null,"planHash":"450b1adf7225a1ef186e0cba71fc8e7ff95293b6f1b200063f1df658cdb81ef6","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"5f43f94d-4f44-4255-a8c2-e0a2605c4897","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":"51135","feedbackHash":"53ad966f67bf4ddac67a9ebf28be05bb4f55466a1b416d496e717b9f94935890","nodeKey":"audit_economics","submissionHash":"071e6d49d51bb0a432313374f7f80eb2e89ca116b3a19ada2eded7aea49ae655","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52223","feedbackHash":"71b2c3150a5b1c6bd45c9398af03abb9a073b74f5ae40344c610347670b3514d","nodeKey":"audit_flow","submissionHash":"5b3255a64893b059d770cdb0c9a1dc87f334b079419b1680a7e8a937188cd1b2","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52190","feedbackHash":"7d994b3e79be08ca6caae194c6980989654e35b62044fadda6e20dc3db19b746","nodeKey":"audit_judge","submissionHash":"348cdca17c9cfebc6a80fe2d14c9f7e358b40291e2ee17c0997caaef0a9aaf50","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51875","feedbackHash":"ba9d92a73b16074eb1cf8e2c9d12860742de018dd21c38ea4ee4ea70cec89fe6","nodeKey":"audit_math","submissionHash":"19d8a1fb43a0a671de9678a6d97d50f2355932cb0b343fd325c8e063b23ad100","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52396","feedbackHash":"6e79aa5f330d77634f244cb3d3f6d058d31585411f052ea621eb744a8ad85258","nodeKey":"audit_permissions","submissionHash":"6dcb4ec39fb6df2f9d3567b6298b632797013a2c573b954bce1ca6a44633a012","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"8782deda56cc1aa0ffe76c869e99b2e19a8cae6466f9a89b1d17012b7b54da31","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"c872aaeb2e58b080","findings":[{"citation":"resolved","description":"The d9970905 fix makes every review verdict depend on an owner transaction (bindReview) that must land after the plane has assigned the oracle UUID and resolved the window (the review template uses a relative {\"hours\":1} window, so questionHash/fromBlock/toBlock cannot be known at ask time) but before the plane's writer makes its single callback attempt. Nothing in the contract or the Intake orders these two events: onReviewResult is onlyIntake, reverts ReviewNotBound when the callback wins the race, and the Intake records the delivery as failed and never retries. bindReview still accepts the binding afterwards (it checks only the deadline and that the ask is pending), so the owner can pin exactly the right values for an answer that can no longer arrive, and no other function accepts the already-signed attestation. The requester's 0.5 IMD is spent, the review sits Pending until reviewDeadline (1 day by default) and can only become Unanswered; the bound oracle UUID stays reserved; the retry is a fresh paid ask that faces the same race. Locally proven: the binding, signature, panel and replay checks are sound once the binding exists (ReviewBinding.t.sol), and late delivery is fail-closed (no wrong verdict). Unverified live compatibility: whether an owner can obtain an authenticated Intake-id->oracle-UUID mapping and canonical question hash before dispatch at all; the reference names no such pre-dispatch facility and says pairing is the writer's. The test fixture hides the race: _requestReview binds atomically in the same test step using a locally derived mock UUID, which a live owner cannot know at that point.","line":366,"path":"src/imd/HookJobRequester.sol","reproduction":"Local, MockIntake: creator opens a job, it is delivered, creator calls requestReview(jobId) paying 0.5 IMD -> askId. Five minutes later the writer calls intake.complete(askId, abi.encode(askId, a, sig)) with a correctly signed bool attestation for this consumer before the owner has bound: complete returns false (ReviewNotBound inside the try) and marks the request settled. Owner then calls bindReview(askId, keccak256(body), a.requestId, a.questionHash, 31337, 1, 2): succeeds. intake.complete(askId, ...) again -> reverts 'not pending'; onReviewResult from any non-Intake address -> NotTheIntake. Expected: the authenticated answer (bound UUID + question + panel + signature in this contract's domain) settles the ask once bound. Actual: review stays Pending, after 1 day expireReview -> Unanswered, consumed[a.requestId] == false, intakeReviewOfOracle[a.requestId] == askId, creator's 0.5 IMD lost. Fix: add a permissionless (or requester/owner) `submitReview(intakeRequestId, a, signature)` that runs the same bound-ask checks as onReviewResult (shared internal function) so a signed answer that missed the one-shot callback can be applied after binding and before ask.deadline; alternatively obtain an authenticated pre-dispatch mapping from the protocol, or use an absolute window and let the requester supply the pinned question at ask time.","severity":"medium","snippet":"        if (ask.oracleRequestId == bytes32(0)) revert ReviewNotBound(requestId);","title":"Owner binding races the Intake's one-shot review callback; an answer delivered before bindReview is permanently lost and cannot be applied after binding"}],"hash":"071e6d49d51bb0a432313374f7f80eb2e89ca116b3a19ada2eded7aea49ae655","nodeId":"06cc7f01-4818-432d-b741-b58d6e90a603","outcome":"completed","summary":"I found one defect in the d9970905 fixes, rated medium, and wrote it to `.imd-findings.json`. Each of the other areas you named held up when I tested it. The full suite passes (155 tests).\n\n**Finding: the owner's binding races the Intake's one-shot callback** (`src/imd/HookJobRequester.sol:366`)\n- **Can the binding land before the callback?** Nothing makes it. A review answer is only accepted after the owner calls `bindReview`. The owner can only do that once the oracle has assigned its request ID and resolved the block window. The review template asks about the last hour (`{\"hours\":1}`), so those values can't be known when the review is requested. The Intake's writer then calls back once, and nothing orders the two.\n- **What happens if the callback wins:** it fails with `ReviewNotBound` and the Intake never retries. `bindReview` still accepts the exact correct values afterwards. But only the Intake can call `onReviewResult`, and no other function accepts an answer that has already been signed.\n- **Cost to the requester:** their 0.5 IMD is spent and the review stays Pending for a day (`reviewTimeout`). After that it can only be marked unanswered. The oracle ID stays used up, and a retry is a new paid request that faces the same race.\n- **Reproduced locally:** with the mock Intake, the answer arrived before the binding and was rejected. The owner's binding then succeeded. Delivering again failed with \"not pending\", and calling `onReviewResult` directly failed with `NotTheIntake`.\n- **Why the existing tests miss it:** their review helper binds immediately after requesting the review, using a made-up oracle ID that a real owner couldn't know yet.\n- **Suggested fix:** add a `submitReview(intakeRequestId, a, signature)` that runs the same checks for a bound review, so a signed answer can be applied after binding and before the deadline. The alternatives are a protocol-provided way to get the ID mapping before the callback is sent, or a fixed block range in the question so it can be pinned when the review is requested.\n\n**What is proven locally and what isn't:**\n- **Proven against the local mock:**\n  - Every field of a bound answer is checked: oracle ID, question hash, chain, block range, issue time against when the review was asked, and panel size and agreement.\n  - Replay is blocked, and used IDs stay reserved.\n  - Expired asks and late answers are refused.\n  - The signature check works for EIP-7702 delegated keys (ECDSA is tried first, then ERC-1271), and high-s or malformed signatures are rejected.\n  - Two-step ownership works as intended.\n  - After a listing transfer, its new owner can cancel the seller's ad. The refund still goes to the original payer and the earned share to the recipient pinned at purchase.\n  - Escrow accounting is consistent on every path: deposit, purchase, cancel, settle and both withdrawals.\n- **Not verified live:** whether an owner can get an authenticated mapping and the canonical question hash before the callback is sent at all. The protocol reference names no way to do this.\n\nI didn't keep a Foundry proof in the finding, because the reproduction passes on the current code and so doesn't meet the \"fails now\" rule. Medium findings don't require one. The scratch test is deleted and no tracked files changed.","treeHash":null,"usage":{"cachedInputTokens":887927,"inputTokens":26,"model":"claude-opus-5-5","outputTokens":13072,"runtime":"claude","turns":16,"wallClockMs":309325}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"672c29f92850efe0","findings":[{"citation":"resolved","description":"onReviewResult refuses any attestation until the owner has called bindReview(intakeId, bodyHash, oracleRequestId, questionHash, questionChainId, fromBlock, toBlock) with the exact values the oracle will sign. Under the protocol described in the pinned oracle-consumer reference those values do not exist when the ask is paid for: a.requestId is the oracle request UUID the plane assigns when it reads the ask (not the Intake id returned to requestReview), the shipped review template uses window {\"hours\":1}, and the reference states that with a relative window the resolved fromBlock/toBlock and therefore questionHash cannot be known in advance. The writer then calls the callback once, minutes later, inside a try; a revert is recorded as not delivered and is not retried, and the price is never refunded. So in the recommended configuration (owner is a multisig, template uses a relative window) the sequence is deterministic: requestReview spends 0.5 IMD, the only delivery attempt reverts ReviewNotBound, the ask stays Pending until reviewDeadline (default 1 day, minimum 1 hour), the requester calls expireReview and gets Unanswered. Repeating costs another 0.5 IMD each time. A late bindReview after the refused callback changes nothing because the Intake does not retry. The binding is feasible only if a hot-key owner automation reads the oracle UUID and resolved window from the plane during the minutes between the plane reading the ask and the panel answering, which the reference neither documents nor authenticates and which a multisig owner cannot do. There is also no rebind path: a binding typo strands the ask the same way. Local safety is proven (ReviewBinding.t.sol shows every mismatched or unbound answer fails closed); live compatibility is not merely unverified but contradicted by the reference for relative windows. Fixing this requires a scope decision, since the per-ask binding was the chosen remedy for the earlier medium finding. Minimal direction that keeps the protocol's own same-chain model: accept an attestation for a pending Intake id this contract created (reviewOf), keyed for replay on _consume(a.requestId), with the pinned panel minimums and issuedAt/deadline checks retained; make the exact-field binding optional (owner flag) or pin only what is knowable at ask time, namely an owner-registered expected questionHash per template when the template uses an absolute window, as the reference's receiver example does. Alternatively keep the binding mandatory but do not let requesters pay until it can be satisfied, e.g. an owner switch that disables requestReview until a pilot proves a pre-dispatch mapping source. docs/IMD_INTEGRATION.md already says review delivery is not production-ready; the contract itself does not enforce that.","line":366,"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\n/// @dev Minimal ERC-20 for the price.\ncontract ScratchToken {\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 Intake stand-in with the live semantics the oracle-consumer reference states: the price\n/// is pulled and never refunded, the callback is attempted once under 200,000 gas inside a try,\n/// and a reverted callback is recorded as not delivered and is not retried.\ncontract ScratchIntake is IIntake {\n    struct Req {\n        address target;\n        bytes4 selector;\n        bool settled;\n    }\n\n    mapping(bytes32 => mapping(address => uint256)) public price;\n    mapping(bytes32 => Req) public reqs;\n    uint256 internal nonce;\n\n    function setPrice(bytes32 action, address asset, uint256 p) external {\n        price[action][asset] = p;\n    }\n\n    function priceOf(bytes32 action, address asset) external view returns (uint256) {\n        return price[action][asset];\n    }\n\n    function request(bytes32 action, bytes calldata, Callback calldata cb, address asset, uint256 amount)\n        external\n        payable\n        returns (bytes32 id)\n    {\n        return _req(action, cb, asset, amount);\n    }\n\n    function requestWithFailure(bytes32 action, bytes calldata, Callback calldata cb, bytes4, address asset, uint256 amount)\n        external\n        payable\n        returns (bytes32 id)\n    {\n        return _req(action, cb, asset, amount);\n    }\n\n    function _req(bytes32 action, Callback calldata cb, address asset, uint256 amount) internal returns (bytes32 id) {\n        require(amount == price[action][asset] && amount != 0, \"price\");\n        require(ScratchToken(asset).transferFrom(msg.sender, address(this), amount), \"pull\");\n        id = keccak256(abi.encode(address(this), ++nonce));\n        reqs[id] = Req(cb.target, cb.selector, false);\n    }\n\n    /// @dev The writer's one-shot completion.\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/// @title An honest answer to this contract's own ask, delivered before the owner has bound it, is lost\n/// @notice Under the protocol the oracle UUID and the resolved window of a relative-window question\n/// exist only once the plane reads the ask, and the writer calls back once, minutes later. The\n/// contract refuses that single callback with ReviewNotBound, the Intake does not retry, and the\n/// requester's 0.5 IMD is spent for no verdict. This test fails on the code as it is and passes\n/// once an honest, correctly signed answer to the contract's own pending Intake request is\n/// accepted without a prior per-ask owner binding (replay still keyed on the consumed oracle id).\ncontract UnboundCallbackTest is Test {\n    bytes32 constant JOB_ACTION = \"job.open@test\";\n    bytes32 constant ORACLE_ACTION = \"oracle.request@oracle-1\";\n    uint256 constant PRICE = 0.5 ether;\n\n    ScratchToken imd;\n    ScratchIntake intake;\n    HookJobRequester requester;\n    address owner = makeAddr(\"owner\");\n    address creator = makeAddr(\"creator\");\n    uint256 signerKey = uint256(keccak256(\"scratch oracle key\"));\n\n    function setUp() public {\n        vm.chainId(31337);\n        vm.warp(1_800_000_000);\n        imd = new ScratchToken();\n        intake = new ScratchIntake();\n        intake.setPrice(JOB_ACTION, address(imd), PRICE);\n        intake.setPrice(ORACLE_ACTION, address(imd), PRICE);\n        requester = new HookJobRequester(owner, address(intake), address(imd), vm.addr(signerKey), PRICE);\n        vm.startPrank(owner);\n        requester.setJobTemplate(JOB_ACTION, \"forge build forge test adversarial review \", \"\", \"\");\n        requester.setReviewTemplate(\n            ORACLE_ACTION,\n            '{\"v\":1,\"question\":\"Did job ',\n            ' pass review?\",\"chainId\":31337,\"window\":{\"hours\":1},\"answerType\":\"bool\",\"panelSize\":7,\"quorum\":5}',\n            5,\n            4\n        );\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 testHonestAnswerToOwnAskDeliveredBeforeOwnerBindingIsNotLost() 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        uint256 before = imd.balanceOf(creator);\n        vm.prank(creator);\n        bytes32 askId = requester.requestReview(jobId);\n        assertEq(imd.balanceOf(creator), before - PRICE, \"the review price is spent at the Intake\");\n\n        // Minutes later the plane has read the ask: only now do the oracle UUID and the resolved\n        // window exist. The writer signs for this contract and calls back once.\n        vm.warp(block.timestamp + 5 minutes);\n        OracleAttestation.Attestation memory a = OracleAttestation.Attestation({\n            requestId: bytes32(bytes16(keccak256(\"oracle UUID assigned by the plane\"))),\n            chainId: 31337,\n            questionHash: keccak256(\"canonical question document with the resolved window\"),\n            answerType: OracleAttestation.ANSWER_BOOL,\n            answer: abi.encode(true),\n            figure: 0,\n            fromBlock: 1_000,\n            toBlock: 1_300,\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        (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerKey, requester.attestationDigest(a));\n        bool delivered = intake.complete(askId, abi.encode(askId, a, abi.encodePacked(r, s, v)));\n\n        // The one-shot callback must settle the contract's own ask.\n        assertTrue(delivered, \"the Intake's single callback reverted (ReviewNotBound) and is never retried\");\n        assertEq(\n            uint8(requester.getJob(jobId).review),\n            uint8(HookJobRequester.ReviewStatus.Passed),\n            \"paid, honestly answered review left Pending until its deadline; the price is not refunded\"\n        );\n        assertTrue(requester.consumed(a.requestId), \"replay protection still keys on the oracle id\");\n    }\n}","reproduction":"State: owner has set the job and review templates (test/helpers/ImdTemplates.sol, window {\"hours\":1}); creator has approved IMD. Steps: (1) creator openJob -> Intake completes it (Delivered). (2) creator requestReview(jobId): 0.5 IMD is pulled and forwarded; getReviewAsk(askId).oracleRequestId == 0. (3) Five minutes later the writer calls intake.complete(askId, abi.encode(askId, a, sig)) once, where a is signed by the configured oracleSigner for this contract with a.requestId = the plane's UUID, a.chainId = 31337, a.fromBlock/toBlock = the resolved window, panel 7/5/6, issuedAt = now, expiresAt = now + 1 h, answer = true. Expected: the contract's own, honestly signed answer settles the review (Passed) and consumed[a.requestId] becomes true. Actual: the callback reverts ReviewNotBound(askId) inside the Intake's try (complete returns false), the ask stays Pending, nothing is retried, the 0.5 IMD is not refundable; the requester can only call expireReview after reviewDeadline. The attached test test/scratch/UnboundCallback.t.sol fails on this code with 'the Intake's single callback reverted (ReviewNotBound) and is never retried'. The existing test testUnboundAskFailsClosedEvenWithCorrectlySignedMatchingIntakeIdAndRawBodyHash in test/ReviewBinding.t.sol asserts this very revert as desired behaviour and no test exercises the resulting loss.","severity":"medium","snippet":"        if (ask.oracleRequestId == bytes32(0)) revert ReviewNotBound(requestId);","title":"Per-ask owner binding cannot be completed before the Intake's one-shot callback for the shipped relative-window template; every honest review answer is refused and the requester's 0.5 IMD is spent for"},{"citation":"resolved","description":"askedAt is the block timestamp of the requestReview transaction (a validator's or sequencer's clock); a.issuedAt is the attester's wall clock. OracleAttestationConsumer already tolerates the attester being up to ISSUED_AT_TOLERANCE (5 minutes) ahead of the chain, but the new check tolerates nothing in the other direction. If the chain's timestamp at the ask block leads the attester's clock by more than the time between the ask and the attester stamping issuedAt, or if the attester stamps issuedAt from a clock behind the chain by even one second in the same-block case, the only delivery attempt reverts AttestationPredatesAsk, the Intake does not retry, and the 0.5 IMD is spent with no verdict until expireReview. docs/IMD_INTEGRATION.md calls the strictness deliberate and defers it to the pilot, but the asymmetry with the existing forward tolerance has no stated reason and the failure mode is unrecoverable. An attestation that predates the ask is already excluded by the exact oracle UUID and questionHash binding plus _consume, so the check adds no security against replay; it only guards clock ordering. Fix: allow issuedAt >= askedAt - ISSUED_AT_TOLERANCE (or a dedicated backward tolerance), keeping expiresAt and the deadline checks as they are.","line":371,"path":"src/imd/HookJobRequester.sol","reproduction":"State: a bound pending review ask with ask.askedAt = T (block.timestamp at requestReview). Input: an otherwise fully valid attestation (correct oracle UUID, questionHash, chain, window, panel 7/5/6, signed by the configured signer) with a.issuedAt = T - 1 and a.expiresAt = T + 1 h, delivered by the Intake at T + 3 min. Expected: accepted, since the forward direction already tolerates 300 s of drift and the answer is unambiguously for this ask. Actual: reverts AttestationPredatesAsk(T-1, T); intake.complete returns false; review stays Pending; no retry; price not refunded. The same attestation with a.issuedAt = T + 300 at T + 3 min is accepted. Shown by test/scratch/PredatesAskBoundary.t.sol (testOneSecondOfBackwardClockSkewLosesTheAsk), which passes because it asserts the current refusal.","severity":"low","snippet":"        if (a.issuedAt < ask.askedAt) revert AttestationPredatesAsk(a.issuedAt, ask.askedAt);","title":"issuedAt must be >= askedAt with zero tolerance while forward drift gets five minutes; one second of backward attester clock skew relative to the ask block loses the one-shot answer"}],"hash":"19d8a1fb43a0a671de9678a6d97d50f2355932cb0b343fd325c8e063b23ad100","nodeId":"556d352d-93ac-4b71-950c-9046b815ad7d","outcome":"completed","summary":"Review complete. The findings file holds two substantiated findings; the proof test for the first fails on the current code as required.\n\n**Outcome**\n\nThe fixes for audit d9970905 are locally sound. All 154 existing tests pass, and I found no regression in escrow accounting, two-step ownership, replay or expiry handling, or the EIP-7702 and ERC-1271 signature path. The one real problem is that the new per-review owner binding does not work against the protocol as the pinned reference describes it.\n\n**Findings written to `.imd-findings.json`**\n\n- **Medium, with proof.** The callback at `src/imd/HookJobRequester.sol:366` refuses every attestation until the owner has pinned the oracle UUID, canonical question hash, question chain and resolved block window. For the shipped template with a relative window, the reference says those values exist only once the plane reads the ask, and the writer calls back once, minutes later, with no retry and no refund. With a multisig owner the sequence is deterministic: the requester pays 0.5 IMD, the single callback reverts, the ask sits Pending until its deadline, and nothing is recoverable. Binding is possible only with a hot-key bot racing the panel from an undocumented, unauthenticated source. The attached scratch test fails on this code with that exact revert. Fixing it needs a scope decision, because the binding was the remedy for the earlier medium finding. I described two directions: accept the contract's own pending Intake id with replay keyed on the consumed oracle id, pinning only what is knowable at ask time, or keep the binding mandatory but stop requesters paying until a pilot proves a pre-dispatch mapping source.\n- **Low.** At `src/imd/HookJobRequester.sol:371` an attestation issued even one second before the ask's block timestamp is refused, while the existing forward tolerance allows five minutes. The failure is unrecoverable for the same one-shot reason. The check adds nothing against replay given the exact binding and consumption, so a symmetric tolerance is safe.\n\n**Checked and clean**\n\n- Oracle binding, replay, deadline and expiry boundaries: consistent at the exact deadline and expiry, consumption keyed on the signed oracle id, cross-ask and cross-contract replay refused.\n- Signature path: low-s ECDSA first, then ERC-1271 by staticcall with length and magic checks. A 7702 designator or reverting code on the signer no longer disables the key. Wrong key, malleated s, short data and reverting registries all fail closed.\n- Two-step ownership in `StoreAdmin`: zero and self refused, only the nominee accepts, the owner keeps control until then.\n- Ad cancellation after listing transfer: refund always credits the original payer, earned share goes to the pinned recipient, totals conserve under the fuzz invariants and my reading of every path. Rounding dust favours the payer and cannot be farmed.\n- Arithmetic and loops: no overflow, truncation or pagination edge in either contract. UTF-8 validation rejects overlongs, surrogates and out-of-range code points correctly.\n\n**Not verified here**\n\nLive gas of the real signer registry under the 200,000 stipend, the `job.open` callback ABI, and whether live Intake ids or oracle UUIDs ever repeat. These remain pilot items as the repository already states.","treeHash":null,"usage":{"cachedInputTokens":964163,"inputTokens":226,"model":"claude-fable-5-1","outputTokens":32670,"runtime":"claude","turns":30,"wallClockMs":602833}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3beb94a990617d46","findings":[{"citation":"resolved","description":"Merged from four specialist reports (audit_math a6cba18e, audit_economics 15e6096e, audit_permissions fd1246b3, audit_flow 2d584062): same mechanism, same line, one fix. The d9970905 remedy for 'attestation can settle another ask' makes every verdict depend on the owner having already called bindReview(intakeId, bodyHash, oracleRequestId, questionHash, questionChainId, fromBlock, toBlock) with the exact values the oracle will sign. onReviewResult (onlyIntake) reverts ReviewNotBound for an unbound ask, and bindReview afterwards still succeeds but nothing can then apply the already-signed answer: the Intake's writer attempts the callback exactly once inside a try, records a revert as not delivered and does not retry (oracle-consumer reference, 'The callback'), and no other function of this contract accepts an attestation. The values the owner must pin do not exist when the ask is paid for: a.requestId is the oracle request UUID the plane assigns, which the reference says differs from the Intake id on purpose; and with the shipped review template's relative window ({\"hours\":1} in test/helpers/ImdTemplates.sol and docs/IMD_INTEGRATION.md) the reference states the resolved fromBlock/toBlock and therefore questionHash 'cannot be computed in advance'. The reference names no pre-dispatch mapping endpoint ('the pairing of an answer to the intake request is the writer's'), and docs/AUDIT_D9970905.md lines 43-47 and docs/IMD_INTEGRATION.md lines 77-83 concede this. So under the documented configuration the sequence is deterministic: requestReview spends 0.5 IMD (no refund), the single delivery reverts, the job's review stays Pending until reviewDeadline (1 day default, 1 hour minimum), the requester calls expireReview and gets Unanswered, and a retry costs another 0.5 IMD and faces the same ordering. There is also no rebind path, so a binding typo strands the ask the same way. Proven locally: the fail-closed property holds (ReviewBinding.t.sol: unbound, mismatched, swapped, replayed and late answers never settle, and no oracle id is consumed), and the attached scratch test shows the burn against a mock Intake with the reference's semantics (one attempt, 200k gas, no retry). Unverified live: whether an operator can obtain the UUID and canonical question hash from the plane in the minutes between the plane reading the ask and the writer's callback; the reference gives no such facility and the repository's own docs call review delivery not production-ready. The contract itself does not enforce that: requestReview is open to every requester and charges them. Note the fixture hides the race: HookJobRequesterFixture._bind derives a synthetic UUID and hash locally and binds in the same step as the ask, which a live owner cannot do. Fix that preserves the agreed design (owner may still pin, and a pinned ask must match exactly): either (a) accept, for a pending ask this contract created (reviewOf) when it is unbound, a signature in this contract's domain with the snapshotted panel minimums, issuedAt/deadline checks and _consume(a.requestId), as the reference's same-chain consumer does, keeping bindReview as an optional stricter pre-pin; or (b) on an unbound delivery verify and store the attestation's requestId/questionHash/chainId/fromBlock/toBlock/answer/panel under the Intake id as 'delivered, awaiting confirmation' without consuming, and have bindReview with identical fields consume the oracle id and set Passed/NotPassed. The attached proof passes under either shape. If neither is acceptable, do not let requesters pay until the binding can be satisfied (an owner switch that disables requestReview, or an absolute-window template plus an owner-registered expected questionHash per template).","line":366,"path":"src/imd/HookJobRequester.sol","reproduction":"State: HookJobRequester deployed with a mock Intake that pulls the price and calls the callback once under 200,000 gas inside a try (test/scratch/EarlyCallbackBurnsAsk.t.sol, ScratchIntake); owner set the job template and a review template whose suffix contains \"window\":{\"hours\":1}; creator approved IMD. Steps: (1) creator.openJob(keccak256(\"spec\"), \"a hook\"); intake.complete(jobId, abi.encode(jobId, \"ipfs://result\")) -> Delivered. (2) creator.requestReview(jobId) -> askId; creator's balance drops by 0.5 IMD; getReviewAsk(askId).oracleRequestId == 0. (3) vm.warp(+5 minutes); build Attestation{requestId = bytes16 UUID, chainId 31337, questionHash = keccak256(\"canonical question document with the resolved window\"), answerType 0, answer abi.encode(true), fromBlock 1000, toBlock 1300, panelSize 7, quorum 5, agreed 6, issuedAt now, expiresAt now+1h}, sign requester.attestationDigest(a) with the configured signer key, and call intake.complete(askId, abi.encode(askId, a, sig)). Expected: the Intake's single attempt does not revert, and the review becomes Passed (directly, or after the owner's bindReview with the identical fields) with consumed[a.requestId] == true. Actual: complete returns false (ReviewNotBound(askId) inside the try); getJob(jobId).review == Pending; consumed[a.requestId] == false; a subsequent owner bindReview(askId, bodyHash, a.requestId, a.questionHash, 31337, 1000, 1300) succeeds but intake.complete reverts 'not pending' and onReviewResult from any non-Intake caller reverts NotTheIntake (test/scratch/JudgeChecks.t.sol testBindAfterEarlyCallbackCannotRecoverTheAnswer); after reviewDeadline the creator can only expireReview -> Unanswered; the 0.5 IMD is gone. forge test --match-path test/scratch/EarlyCallbackBurnsAsk.t.sol fails on 684c3da with 'the Intake's single callback reverted (ReviewNotBound) and is never retried'; it passes with a minimal patch that skips the ReviewNotBound/mismatch checks for an unbound own ask (verified locally, patch reverted). The specialist's attached proof .imd/reads/proofs/Proof_a6cba18e4cad.t.sol fails for the same reason.","severity":"high","snippet":"        if (ask.oracleRequestId == bytes32(0)) revert ReviewNotBound(requestId);","title":"Per-ask owner binding must land before the Intake's one-shot review callback; an honest, correctly signed answer to the contract's own ask delivered first is refused (ReviewNotBound), never retried, a"},{"citation":"resolved","description":"Merged from audit_math 62854f0f and audit_flow f4327605 (same check, same fix). askedAt is the block timestamp of the requestReview transaction; a.issuedAt is the attester's wall clock. OracleAttestationConsumer._verifyAttestation already tolerates issuedAt up to ISSUED_AT_TOLERANCE (300 s) ahead of the delivery block, but the new per-ask check tolerates nothing in the other direction. Because the Intake attempts the callback once, a refusal here is unrecoverable: the ask stays Pending until expireReview and the 0.5 IMD is spent. The check adds no replay protection beyond what the binding already gives (a.requestId, questionHash and window are pinned per ask and _consume keys on the oracle id), so it only trades liveness for clock ordering. docs/IMD_INTEGRATION.md line 66 calls the strictness deliberate and defers it to the pilot; the asymmetry with the existing forward tolerance has no stated reason. Honest caveat on likelihood: the attester stamps issuedAt when the panel answers, minutes after the ask, so hitting this needs the attester's clock to lag the chain by more than the panel latency, or an issuedAt stamped at an earlier plane step; the reference does not say which. Fix: tolerate a.issuedAt + ISSUED_AT_TOLERANCE >= ask.askedAt (or a dedicated backward tolerance), keeping expiresAt and the ask deadline as they are.","line":371,"path":"src/imd/HookJobRequester.sol","reproduction":"State: a bound pending ask with askedAt = T (test/scratch/JudgeChecks.t.sol, _boundAsk). Input: at T + 3 min the Intake delivers an attestation matching the binding exactly, panel 7/5/6, signed by the configured signer, with issuedAt = T - 1 and expiresAt = T + 1 h. Expected: accepted, as the same attestation with issuedAt = T + 3 min + 300 s is. Actual: onReviewResult reverts AttestationPredatesAsk(T-1, T) from the Intake; the subsequent issuedAt = now + 300 s delivery is accepted (Passed). testOneSecondBackwardSkewRefusedWhileFiveMinutesForwardAccepted passes on 684c3da, i.e. it asserts the current refusal; test/ReviewBinding.t.sol line 171 asserts the same revert at askedAt - 1.","severity":"low","snippet":"        if (a.issuedAt < ask.askedAt) revert AttestationPredatesAsk(a.issuedAt, ask.askedAt);","title":"issuedAt must be >= askedAt with zero backward tolerance while the base contract allows five minutes of forward drift; attester clock behind the ask block's timestamp loses the one-shot answer"},{"citation":"resolved","description":"From audit_permissions 4d21f524, reproduced. onReviewResult refuses delivery once block.timestamp exceeds the ask's deadline even though the ask is still Pending (expireReview not yet called), the owner's binding matches and the attestation is inside its own signed [issuedAt, expiresAt] window. The reference asks consumers to keep a deadline so the requester can clear an unanswered request; it does not ask them to refuse a late but valid answer, and its same-chain consumer accepts any answer for a pending id. Since the Intake never retries, an answer that lands between reviewDeadline and expireReview is lost and the requester must pay 0.5 IMD again. The owner may set reviewTimeout as low as MIN_TIMEOUT (1 hour) while the review body carries validForSeconds 3600 and the attestation its own expiresAt, so the contract-side cut-off duplicates a freshness bound the signature already enforces. With the 1-day default and panels answering in minutes this rarely bites, hence low. Fix: drop the deadline comparison in onReviewResult (keep it as the expireReview gate), or bound answer age by a.issuedAt <= ask.askedAt + reviewTimeout rather than by the delivery block's timestamp; bindReview may keep its deadline check.","line":365,"path":"src/imd/HookJobRequester.sol","reproduction":"State: owner.setTimeouts(7 days, 1 hours); creator opened a job, it was delivered, creator.requestReview(jobId) -> askId at time T; owner bound the ask with the correct UUID/hash/chain/window (test/scratch/JudgeChecks.t.sol testBoundValidAnswerAfterReviewTimeoutIsLost). Step: at T + 1 h + 60 s the Intake calls onReviewResult(askId, a, sig) where a matches the binding exactly, issuedAt = now, expiresAt = now + 1 h, panel 7/5/6, valid signature. Expected: the verdict settles. Actual: revert ReviewDeadlinePassed(T + 1 h); intake.complete returns false and marks the request settled; getJob(jobId).review stays Pending; consumed[a.requestId] == false; creator.expireReview -> Unanswered and must pay again. The scratch test passes on 684c3da because it asserts the current refusal.","severity":"low","snippet":"        if (block.timestamp > ask.deadline) revert ReviewDeadlinePassed(ask.deadline);","title":"A bound, matching, unexpired verdict delivered after reviewTimeout is refused in the Intake's single attempt, so a slow panel costs the requester the price"},{"citation":"resolved","description":"From audit_flow 955fc8c6; kept separate from the high finding because it lives in the shipped template rather than the contract and has its own fix. The template documented in docs/IMD_INTEGRATION.md and used by every local test asks with \"window\":{\"hours\":1}. The oracle-consumer reference states that questionHash covers the resolved block window, that a contract cannot compute it in advance when the window is relative, and that a consumer which pins its question uses absolute windows. bindReview requires the exact questionHash, fromBlock and toBlock, so with this template the owner can only learn the values to pin from the plane after it has resolved them, which is also when the writer dispatches the answer. The local fixture sidesteps this by deriving a synthetic hash from the body and fixed blocks 1..2 (HookJobRequester.t.sol lines 69-77, marked 'NOT the protocol canonical hash algorithm'), so no test exercises the live ordering. Fix: with the current contract design, ship an absolute-window template ({\"fromBlock\",\"toBlock\"} chosen at ask time) whose questionHash the owner can compute with the protocol package's oracleQuestionHash, or apply the store-then-confirm/accept-own-ask fix from the high finding so the window may stay relative.","line":14,"path":"test/helpers/ImdTemplates.sol","reproduction":"State: owner calls setReviewTemplate with REVIEW_PREFIX/REVIEW_SUFFIX as documented; creator calls requestReview -> ReviewRequested(jobId, askId, price, body) where body contains \"window\":{\"hours\":1}. Input: the owner attempts bindReview immediately after the event. Expected: the owner can compute questionHash, fromBlock and toBlock to pin from the paid body. Actual: they depend on the plane's resolution of the relative window at answer time, so the owner has nothing to pin until the answer exists; any guess that differs from the signed values makes onReviewResult revert ReviewAttestationMismatch (ReviewBinding.t.sol testSignedWrongQuestionIdChainOrWindowCannotSettle shows fromBlock+1 / toBlock+1 refused) and, with no rebind path, the paid answer is lost the same way as in the high finding.","severity":"info","snippet":"        ' on this chain finish its adversarial review step with the review recorded as passed?\",\"chainId\":31337,\"window\":{\"hours\":1},\"answerType\":\"bool\",\"evidence\":\"panel\",\"panelSize\":7,\"quorum\":5,\"validForSeconds\":3600,\"definitions\":{\"passed\":\"The job page shows the adversarial review step completed and the reviewer accepted the delivered code with no unresolved finding. Missing, pending or failed review means false.\"}}';","title":"The documented review template uses a relative window, which the protocol reference says cannot be pinned in advance, while bindReview requires pinning the resolved window and questionHash before the "}],"hash":"348cdca17c9cfebc6a80fe2d14c9f7e358b40291e2ee17c0997caaef0a9aaf50","nodeId":"a52fed09-96c3-4e2a-a56f-f85482124c40","outcome":"completed","summary":"The review is complete. The findings file at the repository root holds four findings, each reproduced against commit 684c3da, and the tracked tree is unchanged.\n\n**What I kept, after merging the eight specialist reports**\n\n- **High, HookJobRequester.sol line 366.** The per-ask owner binding must land before the Intake's one-shot callback. The four specialist reports on this line (audit_math, audit_economics, audit_permissions, audit_flow) describe one mechanism and one fix, so they are one finding. An honest answer to the contract's own ask delivered before `bindReview` reverts with ReviewNotBound inside the Intake's try, is never retried, and the requester's 0.5 IMD is spent. A later binding with the identical fields succeeds but nothing can then apply the signed answer. Local safety is proven by the existing binding tests. Live completability is unverified and, for the shipped relative-window template, contradicted by the oracle-consumer reference. The attached proof fails on this code and passed under a minimal patch I applied and reverted. I rewrote the specialist's proof so it passes under either fix shape, accepting the contract's own unbound ask or storing then confirming.\n- **Low, line 371.** Zero backward tolerance on `issuedAt` versus the ask timestamp, while the base contract allows five minutes forward. Two specialist reports merged. Reproduced: one second behind is refused, 300 seconds ahead is accepted. I noted that hitting this live needs attester clock lag exceeding the panel latency.\n- **Low, line 365.** A bound, matching, unexpired verdict delivered after the review timeout is refused in the single attempt. Reproduced with a one-hour timeout.\n- **Info, ImdTemplates.sol line 14.** The documented template's relative window cannot be pinned in advance, which the binding requires. Kept separate because its fix lives in the template.\n\n**What I checked and found no regression in**\n\n- Escrow accounting after the ad-cancellation change: refunds still credit the original payer, earned proceeds go to the pinned recipient, and the conservation invariant and fuzz suites pass.\n- Two-step ownership, ERC-1271 with ECDSA tried first for EIP-7702 delegation, replay and expiry checks, and oracle id reservation all behave as documented.\n- The full suite passes: 154 tests, zero failures.\n\nScratch tests live under test/scratch and are not part of the submission.","treeHash":null,"usage":{"cachedInputTokens":1200528,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":26975,"runtime":"claude","turns":28,"wallClockMs":516981}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"f20ec62b0213db3f","findings":[{"citation":"resolved","description":"onReviewResult refuses any ask the owner has not yet bound (ReviewNotBound) and reverts inside the Intake's try. The Intake attempts the callback exactly once and never retries, so an answer that arrives before the owner's bindReview transaction is recorded as not delivered. The binding cannot be made in advance: the Intake id only exists after requestReview, the oracle UUID is assigned by the plane, and the questionHash, fromBlock and toBlock the binding must pin are resolved by the plane. The protocol reference (.imd/reads/skills/oracle-consumer.md) states that with a relative window the question hash cannot be computed in advance and that no pre-delivery mapping endpoint exists; the shipped review template uses \"window\":{\"hours\":1}. So the owner (a multisig per docs/IMD_INTEGRATION.md) has to observe ReviewRequested, obtain the UUID and resolved window from the plane, sign and get bindReview mined, all inside the minutes between the ask and the writer's complete, and there is no ordering guarantee even when the data is available: the writer's tx can simply land first. Local safety is proven (the fail-closed check works in every test); live completability is unverified and, from the reference's own text, structurally unlikely. The creator's 0.5 IMD is spent, the review stays Pending for reviewTimeout (1 day default) and can only be cleared by expireReview; each retry costs another 0.5 IMD and faces the same race. Fix that preserves the owner's authentication of the mapping: let onReviewResult, for a pending but unbound ask from the Intake, verify the signature, window and panel, and store the attestation's requestId/questionHash/chainId/fromBlock/toBlock/answer/panel under the Intake id as 'delivered, awaiting confirmation' (consuming nothing yet); then have bindReview, when its fields equal the stored ones, consume the oracle id and set Passed/NotPassed, and when they differ, leave the ask pending until expiry. The ordering dependency disappears and nothing settles without the owner's pin. docs/IMD_INTEGRATION.md already calls review delivery not production-ready for this reason; the shipped contract still charges for it.","line":366,"path":"src/imd/HookJobRequester.sol","reproduction":"State: review template set (any template; the documented one has a relative window), job opened and completed, creator calls requestReview(jobId) paying 0.5 IMD, owner has not yet called bindReview. Input: the Intake calls onReviewResult(askId, a, sig) where a.requestId, a.questionHash, a.chainId, a.fromBlock, a.toBlock are exactly what the owner will bind next block, panel 7/5/6, issuedAt now, and sig is the oracle signer's valid EIP-712 signature for this contract. Expected: the paid, correctly signed verdict is kept and becomes the review status once the owner confirms the binding. Actual: revert ReviewNotBound(askId) under the Intake's try; intake.complete returns false and marks the request settled; the owner's subsequent bindReview with the identical fields succeeds but the review stays Pending and consumed[a.requestId] stays false; the creator must wait reviewTimeout and call expireReview, and the 0.5 IMD is gone. test/scratch/ReviewBindingRace.t.sol reproduces this with a mock Intake under the 200,000-gas stipend.","severity":"high","snippet":"        if (ask.oracleRequestId == bytes32(0)) revert ReviewNotBound(requestId);","title":"Per-ask owner binding races the Intake's one-shot callback: an answer delivered before bindReview is lost and the 0.5 IMD is spent"},{"citation":"resolved","description":"askedAt is the block timestamp of the requestReview transaction (a validator's or sequencer's clock); issuedAt is the attester's wall clock, which the base contract already allows to run up to ISSUED_AT_TOLERANCE (5 minutes) ahead of the chain. The new check allows zero tolerance in the other direction: if the chain's timestamp at the ask is ahead of the attester's clock by more than the time it takes the panel to answer, the callback reverts AttestationPredatesAsk, the Intake records it as not delivered and never retries, and the 0.5 IMD is spent. Because the rest of the design already pins the ask to the oracle id and question hash, an old answer cannot be replayed into a new ask, so this check adds no replay protection; it only adds a liveness failure. Fix: allow the same tolerance as the base contract (a.issuedAt + ISSUED_AT_TOLERANCE < ask.askedAt) or drop the check and rely on the per-ask oracle-id binding plus a.expiresAt and ask.deadline, which already bound freshness.","line":371,"path":"src/imd/HookJobRequester.sol","reproduction":"State: a bound review ask created in a block whose timestamp is T. Input: the Intake delivers a correctly signed attestation with issuedAt = T - 1 (the attester's clock one second behind the chain at the ask; panel answered within that drift), all bound fields matching, panel 7/5/6, expiresAt = T + 3600. Expected: the fresh, signed verdict is accepted (it is well inside a.expiresAt and ask.deadline, and the oracle id is pinned to this ask so it cannot be a replay). Actual: revert AttestationPredatesAsk(T-1, T); the Intake marks the request settled without delivery; the review stays Pending until expireReview; 0.5 IMD spent. ReviewBinding.t.sol testAnswerAtDeadlineAcceptedAndEarlierThanAskRefused shows the revert with exactly askedAt - 1.","severity":"low","snippet":"        if (a.issuedAt < ask.askedAt) revert AttestationPredatesAsk(a.issuedAt, ask.askedAt);","title":"Strict issuedAt >= askedAt refuses the one-shot answer whenever the attester's clock is behind the chain's block timestamp at the ask"},{"citation":"resolved","description":"The template documented in docs/IMD_INTEGRATION.md and used by every local test asks with \"window\":{\"hours\":1}. The oracle-consumer reference states that questionHash covers the resolved block window, that a contract cannot compute it in advance when the window is relative, and that a consumer which pins its question uses absolute windows. bindReview requires the exact questionHash, fromBlock and toBlock before the one-shot callback, so with this template the owner can only learn the values to pin from the plane after the plane has resolved them, which is also when the writer dispatches the answer. The local fixture sidesteps this by deriving the 'canonical' hash from the body with a synthetic formula and fixed blocks 1..2, so no test exercises the live ordering. Fix: with the current contract design, document and ship an absolute-window template, or, better, apply the store-then-confirm fix from the ReviewNotBound finding so the window may stay relative.","line":14,"path":"test/helpers/ImdTemplates.sol","reproduction":"State: owner calls setReviewTemplate with REVIEW_PREFIX/REVIEW_SUFFIX as documented; creator calls requestReview. Input: owner attempts bindReview immediately after the ReviewRequested event. Expected: the owner can compute the fields to pin from the paid body. Actual: fromBlock, toBlock and the canonical questionHash depend on the plane's resolution of {\"hours\":1} at answer time, so the owner has no value to pin until the answer exists; any guess that differs from the signed values makes onReviewResult revert ReviewAttestationMismatch and loses the paid answer, with no rebind path.","severity":"info","snippet":"        ' on this chain finish its adversarial review step with the review recorded as passed?\",\"chainId\":31337,\"window\":{\"hours\":1},\"answerType\":\"bool\",\"evidence\":\"panel\",\"panelSize\":7,\"quorum\":5,\"validForSeconds\":3600,\"definitions\":{\"passed\":\"The job page shows the adversarial review step completed and the reviewer accepted the delivered code with no unresolved finding. Missing, pending or failed review means false.\"}}';","title":"Documented review template uses a relative window, which the protocol reference says cannot be pinned in advance; bindReview requires pinning it"}],"hash":"5b3255a64893b059d770cdb0c9a1dc87f334b079419b1680a7e8a937188cd1b2","nodeId":"1b0e8339-f2dc-4fbc-a219-597353464739","outcome":"completed","summary":"The review is complete. The findings file is written, nothing under src, test (outside scratch), docs or configuration was changed, and the whole suite still passes on the pinned commit.\n\n**Outcome**\n\nThe fixes for audit d9970905 are locally sound in every area the task named, but the central one, the owner-per-review binding, cannot be relied on to complete before the Intake's one-shot callback. I reported that as the single high finding with a Foundry proof. Two smaller issues accompany it.\n\n**Findings written to .imd-findings.json**\n\n- **High. Binding races the one-shot callback** at `src/imd/HookJobRequester.sol:366`. An answer the writer delivers before the owner's bindReview lands reverts with ReviewNotBound inside the Intake's try, is never retried, and the creator's 0.5 IMD is gone. The binding cannot be prepared in advance: the Intake id, the oracle UUID, and the resolved window and canonical question hash all come from the plane after the ask, and the reference says a relative window cannot be pinned at all. The proof in test/scratch/ReviewBindingRace.t.sol fails on this code and passes on a store-then-confirm patch I validated in a scratch copy, where the callback verifies and stores an unbound answer and bindReview finalizes it.\n- **Low. Strict issuedAt >= askedAt** at `src/imd/HookJobRequester.sol:371`. An attester clock one second behind the chain's block timestamp at the ask loses the one-shot answer. The per-ask oracle-id pin already prevents replay, so the check adds only a liveness failure.\n- **Info. Documented template uses a relative window** at `test/helpers/ImdTemplates.sol:14`. With it, the fields bindReview must pin do not exist until the answer does.\n\n**Verified clean**\n\n- Exact attestation binding, replay and expiry: signed id, question hash, chain and window are all compared, oracle ids are consumed once and stay reserved, deadlines and the expiry edge are consistent.\n- Two-step ownership through StoreAdmin: zero and self refused, only the nominee accepts, cancel and replace work.\n- EIP-7702 and ERC-1271: ECDSA first, then the ERC-1271 fallback, with high-s and malformed signatures rejected and both paths fitting the stipend. The 23-byte designator reproduction is valid under Cancun.\n- Ad cancellation after listing transfer: refund always credits the original payer, earned proceeds go to the pinned recipient, and the escrow conservation identity holds through cancel, settle and both invariant suites.\n\n**Limits**\n\nLocal safety is proven by the suite. Live compatibility, meaning whether the owner can ever obtain the UUID and resolved window before dispatch, is unverified here and the repository's own integration doc already calls review delivery not production-ready. No fork, network or static-analysis tool was used.","treeHash":null,"usage":{"cachedInputTokens":1853313,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":36115,"runtime":"claude","turns":39,"wallClockMs":629923}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"a11137177fc72d8b","findings":[{"citation":"resolved","description":"The d9970905 fix makes every oracle verdict depend on the owner having already called bindReview(intakeRequestId, bodyHash, oracleRequestId, questionHash, chainId, fromBlock, toBlock) for that ask. The oracle-consumer reference (the pinned protocol input) states three facts that make this ordering unachievable in the live flow: (1) the writer attempts the callback exactly once, minutes after payment, inside a try, and a reverting callback 'is recorded as not delivered and is not retried'; (2) with a relative window ({\"hours\":1}, which is what ImdTemplates.REVIEW_SUFFIX uses) the canonical questionHash 'cannot be computed in advance' because it is resolved when the plane reads the ask; (3) 'the pairing of an answer to the intake request is the writer's' and the reference establishes no pre-delivery endpoint from which the owner could learn the oracle UUID and resolved question for a given Intake id (docs/AUDIT_D9970905.md lines 43-47 and docs/IMD_INTEGRATION.md lines 77-83 concede this). So the normal live sequence is requestReview -> writer calls onReviewResult -> owner learns the UUID/hash. On this code that sequence hits ReviewNotBound inside the Intake's try: the Intake marks the request settled, the correctly signed verdict for this contract's own ask is discarded, job.review stays Pending until the requester calls expireReview after reviewTimeout, and the only way to get a verdict is to pay another 0.5 IMD and hope the owner wins the race next time. The requester, not the owner, bears the loss, and the owner has no transaction to send until the information arrives in the very callback that is being refused. Proven locally: the attached test reproduces the burn with a mock Intake that behaves as the reference describes (single attempt, 200k gas, no retry). Unverified live: whether an operator can obtain the UUID and canonical hash before dispatch; the reference says no such facility exists and the repository's own docs list it as an open pilot item, so the delivered review feature should be treated as non-functional until that is shown, not as 'fail closed'. Trust gap: the binding also adds no assurance against the Medium finding it was meant to fix. The only sources of oracleRequestId and questionHash are the plane and, in practice, the writer's pending complete() calldata, which is the same party whose pairing the fix distrusts; an owner who copies those values into bindReview has re-trusted the writer with extra latency and a 0.5 IMD penalty for being slow. Fix (minimal, preserves the agreed design): follow the reference's same-chain consumer. In onReviewResult, authenticate by msg.sender == intake plus reviewOf[requestId] (this contract's own unsettled ask), the signature in this contract's domain, the snapshotted panel minimums, freshness and _consume(a.requestId); accept the writer-paired answer when the ask is unbound, and keep bindReview as an optional stricter pre-pin that, when present, must match exactly. Alternatively, on an unbound delivery verify and store the attestation (its requestId, questionHash, chainId, window, verdict and consumed flag) in a Delivered-unconfirmed state and add an owner confirmReview that compares the stored fields to the independently verified values and promotes the verdict; either variant lets the Intake's single attempt run to completion so no paid answer is lost.","line":366,"path":"src/imd/HookJobRequester.sol","reproduction":"State: HookJobRequester configured with any job/review template; creator approved IMD. Steps: (1) creator.openJob(spec, brief); Intake completes the job. (2) creator.requestReview(jobId) -> intakeRequestId A; 0.5 IMD pulled and forwarded. (3) Before the owner has called bindReview(A, ...), the Intake (msg.sender == intake) calls onReviewResult(A, a, sig) with a correctly signed attestation for this contract's domain, a.requestId = the oracle UUID, a.questionHash = the canonical hash, panel 7/5/6, issuedAt = now, expiresAt = now + 1h. Expected: the contract's own pending ask is settled (or the verified verdict is stored for later owner confirmation). Actual: revert ReviewNotBound(A) inside the Intake's try; the Intake marks A settled and never retries; getJob(jobId).review == Pending; consumed(a.requestId) == false; the creator's 0.5 IMD is spent with no verdict; after reviewTimeout the creator must expireReview and pay 0.5 IMD again. Run: forge test --match-path test/scratch/EarlyCallbackBurnsAsk.t.sol (fails on 684c3da with 'a correctly signed answer to this contract's own ask was refused and is not retried').","severity":"high","snippet":"        if (ask.oracleRequestId == bytes32(0)) revert ReviewNotBound(requestId);","title":"Owner-per-review binding must precede the Intake's one-shot callback; under the protocol's documented timing the honest verdict is refused and the 0.5 IMD review price is lost"},{"citation":"resolved","description":"onReviewResult refuses delivery once block.timestamp exceeds the ask's deadline even though the ask is still Pending, the owner's binding matches, and the attestation itself is inside its signed [issuedAt, expiresAt] window. The protocol reference asks consumers to keep a deadline so the requester can clear a request that never gets an answer; it does not ask them to refuse a late but valid answer, and its own same-chain consumer accepts any answer for a pending id. Because the Intake attempts the callback once and never retries, a verdict that arrives after the deadline but before expireReview is simply lost, and the requester must pay 0.5 IMD again. The owner may set reviewTimeout as low as 1 hour (MIN_TIMEOUT), while the review body already carries validForSeconds 3600 and the attestation carries its own expiresAt, so the contract-side cut-off duplicates a freshness bound the signature already enforces and only adds a way to lose paid answers. This is the Asymmetry guide's 'bad symmetry' case: a second check of the same invariant that is now over-restrictive. Fix: in onReviewResult drop the deadline comparison (or compare against a.expiresAt only) and keep the deadline solely as the requester's expireReview gate; if the operator wants an upper bound on answer age, check a.issuedAt against askedAt + reviewTimeout rather than the delivery block's timestamp.","line":365,"path":"src/imd/HookJobRequester.sol","reproduction":"State: owner.setTimeouts(7 days, 1 hours); creator opened a job, it was delivered, creator.requestReview(jobId) -> A at time T; owner.bindReview(A, ...) succeeded with the correct UUID/hash/chain/window. Step: at T + 61 minutes the Intake calls onReviewResult(A, a, sig) where a matches the binding exactly, a.issuedAt = T + 61 min, a.expiresAt = T + 121 min, panel 7/5/6, signature valid for this contract. Expected: the verdict settles (the attestation is fresh and matches the pinned question). Actual: revert ReviewDeadlinePassed(T + 1h) inside the Intake's try; the Intake records the request as settled and does not retry; getJob(jobId).review stays Pending; consumed(a.requestId) == false; creator must expireReview and pay again. Scratch check: test/scratch/LateValidAnswerBurned.t.sol (testBoundValidAnswerAfterReviewTimeoutIsRefusedOnce passes on 684c3da, i.e. the refusal is reproduced).","severity":"low","snippet":"        if (block.timestamp > ask.deadline) revert ReviewDeadlinePassed(ask.deadline);","title":"A bound, correctly signed, unexpired verdict delivered after reviewTimeout is refused in the Intake's single attempt, so a slow panel costs the requester the price"}],"hash":"6dcb4ec39fb6df2f9d3567b6298b632797013a2c573b954bce1ca6a44633a012","nodeId":"f8ec3951-c64d-4c3b-aca8-a7b2b6ece411","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds two substantiated findings. Nothing in `src/` was changed. The only files I created are the findings file and scratch tests under `test/scratch/`, which are not kept.\n\n**What I checked, and what held up**\n\n- Exact attestation binding: the callback compares the signed `requestId`, `questionHash`, `chainId`, `fromBlock` and `toBlock` to the owner's pin, snapshots panel minimums per ask, consumes the oracle UUID once, and refuses expired or future-dated attestations. Swapped, tampered, cross-consumer and replayed answers all fail as documented, and Intake ids and oracle UUIDs stay reserved after failure, expiry and settlement.\n- Two-step ownership: `StoreAdmin` rejects zero and self, the current owner keeps authority until acceptance, and binding authority follows the accepted owner only. Verified with an extra scratch test.\n- EIP-7702 and ERC-1271: ECDSA-first recovery accepts the key under a 23-byte designator, rejects high-s, wrong-key, 64-byte compact and malformed signatures, and the ERC-1271 fallback fails closed on reverts, bad magic, short returns and a registry that eats the 200k stipend. The deviation from the canonical consumer (a delegated wallet cannot revoke its own key) is a documented trust assumption, not a defect.\n- Ad cancellation after transfer and escrow accounting: a current listing owner can cancel, the refund always credits the original payer, the seller cannot touch the buyer's new ad, and conservation holds through transfer, cancel, repurchase, settle and a complete wind-down to zero balances. No regression found.\n\n**Findings**\n\n1. **High.** The owner must call `bindReview` before the Intake's one-shot callback, but the pinned oracle reference says the writer delivers minutes later, attempts once, never retries, that a relative-window `questionHash` cannot be computed in advance, and that no pre-delivery mapping facility exists. In the normal live order the honest signed verdict hits `ReviewNotBound` inside the Intake's try, the request is marked settled, and the requester's 0.5 IMD is spent for nothing. The attached proof `test/scratch/EarlyCallbackBurnsAsk.t.sol` fails on this commit. This is proven locally; whether an operator can win that race live is unverified and, per the reference, unsupported. The binding also adds no independent assurance, since the only source of the UUID and hash is the same plane whose pairing it distrusts. Suggested fix: accept the writer-paired answer for the contract's own pending ask as the reference's consumer does, keeping `bindReview` as an optional stricter pre-pin, or verify-and-store on unbound delivery with an owner `confirmReview`.\n2. **Low.** A bound, correctly signed, still-valid verdict delivered after `reviewTimeout` (as low as 1 hour) is refused with `ReviewDeadlinePassed` in the single attempt and lost, although the attestation carries its own `expiresAt`. The deadline should gate only `expireReview`.\n\n**Open items for a live pilot, not defects**: the `job.open` callback ABI, canonical question-hash construction, timestamp ordering of `issuedAt` versus `askedAt`, and the live Intake and signer addresses remain unverified by this review, as the repository's own docs state.","treeHash":null,"usage":{"cachedInputTokens":2354380,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":37011,"runtime":"claude","turns":44,"wallClockMs":598475}}],"verification":[]}