{"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":"0b0d0d84-c7e0-4dd4-b87e-d94e2e5150d9","kind":"audit","nodes":[{"acceptedSubmissionHash":"a3efd50380b353fc7e82d45510240e51e0ef40849503bb15c7955986b4c7ea12","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":"202706d4ed9bf4cd441f9e4fda0383cd6fed3466f2585b1cba597e19f5abea4f","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":"ff120e8b6955fb0525ccc3c73184d65399084953949c081978b93a8466bfb354","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":"0f1042f25e91733cce4584452a59a9bc3b1c6b01a67bed3ae1379a1a3cdf64f4","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":"e760ad6a681ba6f9256751a319c220d85f9d122dc96661bf0fa15556379e2f27","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Audit v2 of Briefs, BriefsJury, BriefsText, ImdGatewayRequester and ImdOracle at commit c29c658 (not deployed yet; v1 is live on Robinhood Chain, tag v1-mainnet). Briefs holds every IMD (seed pots, escrowed entry fees, creator earnings, the platform share, unpaid shares for time in the lead, held payments): first look for any way to take, lock or misroute funds, or to break the exact solvency invariant (balance == open pots + creator owed + platform owed + queued fees + the fee of the brief being heard less the jury's price + (reignPool - reignPaid) of settled cases + unpaidTotal), or to stall a case so it can never settle.\nThen time in the lead: reignBps of the pot is shared by how long each challenger led, counted from when its brief was filed until the first deadline (reignEnd); overtime (a new leader in the last minutes pushes endsAt, up to finalCap) decides the final leader but earns no time, and what briefs filed in overtime add (overtimePot) goes to the final leader alone. Check that every leader's seconds add up to reignTotal, that claimReign never pays past reignPool, and that no one can buy time, dilute others or take the final share unfairly (queue spam, self-dealing with several wallets, verdicts landing after the deadline).\nThen relayed briefs: fileBriefFor files a brief its author signed (personal_sign of BriefsJury.relayMessage: case, author, words, deadline, court and chain), and refundRelay pays a refund through the jury and makes the message unfileable; look for replay, malleability, filing and refunding the same message, or free cancellation.\nThen the oracle path: an answer must land only for its own hearing (questionHash rebuilt on chain, delivery by IMD's Intake through BriefsJury.onImdAnswer, answer window, panel, EIP-712 signature for the jury's domain).\nThen griefing by the current leader (mistrial timing, skipStalled and the stall clock, queue spam), admin powers and their bounds (setParams; openCase must name the settings the creator saw, SettingsChanged otherwise), gas (hearingGas and the 64/63 rule), and the text rules in BriefsText (check, checkCase, checkBrief with an English-only ASCII mode and a per-case length limit; the question must stay valid JSON and under 2,000 characters).\ndocs/audit-internal-2026-10.md lists what our nine internal reviews found and fixed; test/audit holds the PoCs.","parentJobId":null,"planHash":"e59cf6cdbf8e37f5e06d7ff0dc4688cf141d5d1a47a7e46e6736f01e57a9f64f","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"0b0d0d84-c7e0-4dd4-b87e-d94e2e5150d9","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":"51514","feedbackHash":"27ad32d446458c08009ce672fe2052bddfd01dba664f926f46a4505113c21a6a","nodeKey":"audit_economics","submissionHash":"a3efd50380b353fc7e82d45510240e51e0ef40849503bb15c7955986b4c7ea12","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51025","feedbackHash":"5d8ffd71e6863f40374d91c8a83fc450cd9ff15c11db19af1f454e769d37e932","nodeKey":"audit_flow","submissionHash":"202706d4ed9bf4cd441f9e4fda0383cd6fed3466f2585b1cba597e19f5abea4f","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52189","feedbackHash":"e1481718c86ad577641a4fb9fafa07575bec4314c1e85da7c39b14597364fdf2","nodeKey":"audit_judge","submissionHash":"ff120e8b6955fb0525ccc3c73184d65399084953949c081978b93a8466bfb354","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51032","feedbackHash":"a4b4ab73ba64b46b2788f8b92102f39420df5b11213f356dc0572f15523a8ff7","nodeKey":"audit_math","submissionHash":"0f1042f25e91733cce4584452a59a9bc3b1c6b01a67bed3ae1379a1a3cdf64f4","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51511","feedbackHash":"8c5c30987dbf8a72cb6bdd5be383911ff1ad57e10f1db7648f80a9cc1bc92eb0","nodeKey":"audit_permissions","submissionHash":"e760ad6a681ba6f9256751a319c220d85f9d122dc96661bf0fa15556379e2f27","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"1d9a77e37395d6f32cf887598506e83d78b448437e5a618e638f3cc0149776b5","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"2a9662a76cb5f51d","findings":[{"citation":"resolved","description":"_newLeader pushes the deadline only when the verdict's block.timestamp is still before endsAt. A brief filed in the last seconds (or queued behind one or two others filed in the last minutes) is always judged after endsAt, because a hearing takes the jury minutes to answer. It then takes the lead and the final (1 - reignBps) share plus the un-led fraction and overtimePot, the Overtime event never fires, and _maybeSettle pays out in the same transaction, so no other player can answer. Overtime therefore protects against a late leader only when the jury happens to answer before the bell; the natural snipe (file at endsAt - 1, or file two briefs at endsAt - 5 min so the second is heard after the bell) is untouched. The existing test test_Reign_ALeaderHeardAfterTheDeadlineTakesTheFinalShareAndTimeFromItsFiling asserts this behaviour as intended, so this is a design gap rather than a coding slip, but it defeats the stated purpose of overtime ('a new leader in the last minutes pushes endsAt'). Fixing it needs a scope decision: either push from the filing time (if filedAt >= endsAt - overtime, set endsAt = max(endsAt, t) + overtime even when t >= endsAt, which reopens entries after the bell and must keep reignEnd fixed so no time is earned), or disallow entries in the last answerTimeout + MISTRIAL_GRACE seconds so every pre-deadline filing is judged before the bell.","line":758,"path":"src/Briefs.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.30;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {Briefs} from \"src/Briefs.sol\";\nimport {BriefsJury} from \"src/BriefsJury.sol\";\nimport {BriefsText} from \"src/BriefsText.sol\";\nimport {ImdOracle} from \"src/ImdOracle.sol\";\nimport {IImdRequester} from \"src/interfaces/IImdRequester.sol\";\n\ncontract Token is ERC20 {\n    constructor() ERC20(\"IMD\", \"IMD\") {\n        _mint(msg.sender, 1e9 ether);\n    }\n}\n\ncontract Requester is IImdRequester {\n    IERC20 immutable imd;\n    uint256 n;\n\n    constructor(IERC20 t) {\n        imd = t;\n    }\n\n    function fee() external pure returns (uint256) {\n        return 0.5 ether;\n    }\n\n    function answerSource() external pure returns (address) {\n        return address(0);\n    }\n\n    function request(string calldata, address) external returns (bytes32) {\n        imd.transferFrom(msg.sender, address(this), 0.5 ether);\n        return bytes32(uint256(keccak256(abi.encode(address(this), ++n))) << 128);\n    }\n}\n\ncontract Digester {\n    function digest(bytes32 d, ImdOracle.AttestationV2 calldata a) external pure returns (bytes32) {\n        return ImdOracle.digestV2(d, a);\n    }\n}\n\n/// Overtime exists so that a new leader late in a case gives everyone `overtime` seconds to answer. But the push\n/// happens only when the VERDICT lands before endsAt. A brief filed one second before the deadline is always judged\n/// after it (the jury takes minutes), so it takes the lead and the final share with no overtime at all.\ncontract OvertimeBypassTest is Test {\n    Token imd;\n    Requester requester;\n    BriefsJury jury;\n    Briefs b;\n    Digester digester = new Digester();\n    uint256 key = 0xB21EF;\n    bytes32 domain;\n    address creator = makeAddr(\"creator\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address treasury = makeAddr(\"treasury\");\n\n    function setUp() public {\n        vm.warp(1_790_800_000);\n        imd = new Token();\n        requester = new Requester(IERC20(address(imd)));\n        domain = ImdOracle.domainSeparatorV(\"2\", 1, address(0));\n        jury = new BriefsJury(\n            BriefsJury.Oracle({signer: vm.addr(key), requester: requester, domain: domain, chainId: 1, hearingGas: 3_000_000}),\n            address(this),\n            address(0)\n        );\n        Briefs.Params memory p = Briefs.Params({\n            minSeed: 10 ether,\n            minFee: 1 ether,\n            maxOracleFee: 0.9 ether,\n            creatorBps: 1_500,\n            platformBps: 500,\n            panelSize: 11,\n            quorum: 6,\n            answerTimeout: 4 minutes,\n            caseFee: 0,\n            minDuration: 10 minutes,\n            maxDuration: 90 days,\n            maxBrief: 500,\n            reignBps: 6_000,\n            overtime: 10 minutes,\n            finalCap: 0,\n            creatorSetsReign: false,\n            creatorSetsCap: false\n        });\n        b = new Briefs(IERC20(address(imd)), new BriefsText(), jury, treasury, p);\n        address[3] memory users = [creator, alice, bob];\n        for (uint256 i; i < users.length; i++) {\n            imd.transfer(users[i], 1_000_000 ether);\n            vm.prank(users[i]);\n            imd.approve(address(b), type(uint256).max);\n        }\n    }\n\n    function _verdict(uint256 briefId, bool better) internal {\n        ImdOracle.AttestationV2 memory a = ImdOracle.AttestationV2({\n            requestId: b.getBrief(briefId).requestId,\n            chainId: 1,\n            questionHash: jury.questionHashOf(briefId, 1, 2),\n            answerType: 0,\n            answer: abi.encode(better),\n            figure: 0,\n            fromBlock: 1,\n            toBlock: 2,\n            blockHash: keccak256(\"b\"),\n            panelJobId: keccak256(\"p\"),\n            panelSize: 11,\n            quorum: 6,\n            agreed: 6,\n            issuedAt: b.getBrief(briefId).heardAt + 30,\n            expiresAt: uint64(block.timestamp + 1 days)\n        });\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, digester.digest(domain, a));\n        b.fulfill(briefId, a, abi.encodePacked(r, s, v));\n    }\n\n    function test_ALastSecondLeaderJudgedAfterTheDeadlineGetsNoOvertime() public {\n        uint64 end0 = uint64(block.timestamp + 1 days);\n        vm.prank(creator);\n        uint256 c = b.openCase(\n            Briefs.CaseInput({\n                title: \"Dragon Jokes\",\n                task: \"Write the funniest joke about dragons.\",\n                standard: \"The funnier brief wins.\",\n                opening: \"Dragons never use banks. Too many firewalls.\",\n                avatar: 0,\n                seed: 500 ether,\n                fee: 5 ether,\n                endsAt: end0,\n                minHold: 0,\n                oracleId: 0,\n                reignBps: 6_000,\n                finalCap: 0,\n                overtime: 10 minutes,\n                lang: 0,\n                maxBrief: 0,\n                caseFee: 0,\n                creatorBps: 1_500,\n                platformBps: 500\n            })\n        );\n        // alice leads from the first hour\n        vm.warp(block.timestamp + 1 hours);\n        vm.prank(alice);\n        uint256 a1 = b.fileBrief(c, \"A dragon walked into a bar. The bar is now a barbecue.\");\n        _verdict(a1, true);\n\n        // bob snipes: filed one second before the deadline, the hearing opens at once, the jury answers a minute later\n        vm.warp(end0 - 1);\n        vm.prank(bob);\n        uint256 b1 = b.fileBrief(c, \"My dragon asked for a raise. HR said to stop burning through the budget.\");\n        assertEq(b.getCase(c).hearing, b1);\n        vm.warp(end0 + 60);\n        _verdict(b1, true);\n\n        // bob is the new leader in the case's last minutes: by the overtime rule everyone should get 10 more minutes\n        Briefs.Case memory k = b.getCase(c);\n        assertEq(k.winner, bob, \"bob took the final share\");\n        assertGt(k.endsAt, end0, \"a late new leader must push the deadline (overtime)\");\n        assertEq(uint8(k.status), uint8(Briefs.CaseStatus.Open), \"the case must not settle before anyone can answer\");\n    }\n}","reproduction":"overtime = 10 min, reignBps = 6000, 1-day case. Alice takes the lead at t0 + 1 h. Bob files at endsAt - 1 s (hearing opens at once); the jury's COOKED answer is delivered at endsAt + 60 s. Expected (overtime rule): endsAt pushed to about endsAt + 11 min, case still Open, others may answer. Actual: endsAt unchanged, no Overtime event, the case settles in the same fulfill with bob as winner taking pot - pool. See test/scratch/OvertimeBypass.t.sol (fails on this code).","severity":"medium","snippet":"        if (t < end) {\n            uint256 later = t + c.overtime;","title":"Overtime is bypassed by any new leader whose verdict lands after endsAt: a last-second filing takes the final share with no reply window"},{"citation":"resolved","description":"The rule the code states ('a setting the creator can't pick must be the one they saw: the owner may have changed it since') is applied to reignBps, finalCap, overtime, caseFee, creatorBps, platformBps and oracleId, but the case also fixes for life c.panelSize, c.quorum, c.answerTimeout (lines 628-630), c.reserve = p.maxOracleFee (line 634) and the court's maxBrief passed to checkCase (line 334). None of these is named in CaseInput, so a setParams landing before a pending openCase silently opens the case with a different jury size, a shorter answer window, a reserve under which every hearing is priced out (0.1 IMD reserve against a 0.5 IMD jury price: every brief skipped and refunded, the opening brief wins by default) or a brief limit the creator never saw. The owner is trusted, so this is a consistency gap in the SettingsChanged guarantee rather than a bypass; fix by adding the five values to CaseInput and comparing them (Briefs is 54 bytes under the size limit, so room must be made first, e.g. by moving the comparison into BriefsText or BriefsJury).","line":636,"path":"src/Briefs.sol","reproduction":"Params at launch: panelSize 11, quorum 6, answerTimeout 4 min, maxOracleFee 0.9 IMD, maxBrief 500. Owner calls setParams with panelSize 3, quorum 2, answerTimeout 3 min, maxOracleFee 0.1 IMD, maxBrief 100. The creator's openCase (CaseInput built from the old values, maxBrief 0) succeeds: getCase shows panelSize 3, quorum 2, answerTimeout 180, reserve 0.1e18, maxBrief 100. Expected: revert SettingsChanged. Verified with test/scratch/Misc.t.sol test_UnpinnedSettingsChangeUnderAPendingOpenCase.","severity":"low","snippet":"        if (p.creatorSetsReign ? x.reignBps > REIGN_CAP_BPS : x.reignBps != p.reignBps) revert SettingsChanged();","title":"openCase pins only some settings: panelSize, quorum, answerTimeout, maxOracleFee (reserve) and the court's maxBrief can change under a pending openCase without SettingsChanged"},{"citation":"resolved","description":"The eighth review's fix 5 requires a refund to pay 'something', but the minimum is 1 wei of IMD paid to the author, which is free in practice. Anyone who holds the signature (the relayer operator, or anyone who read it from the calldata of a fileBriefFor that reverted for a transient reason such as OracleTooExpensive, NotHolder or a short-lived requester failure) can call refundRelay with amount = 1 and the message becomes unfileable forever (relayed[h] = 2, _relayHash reverts on state != 0 for filing). The author, who already paid the full fee on Ethereum, loses the entry; the relayer must then refund the rest off its own record. The author can also do it to themselves to cancel a paid brief before the relayer files it, leaving the relayer to pay the balance. Fix: require amount to be at least a floor tied to the case fee (e.g. the case's fee, or a minRefund parameter), or restrict refundRelay to the author or a relayer role.","line":315,"path":"src/BriefsJury.sol","reproduction":"Dan signs relayMessage(case 1, words, dan, deadline). Alice (any holder of the signature) calls jury.refundRelay(1, words, dan, deadline, sig, 1): succeeds, dan receives 1 wei. The relayer's fileBriefFor(1, words, dan, deadline, sig) then reverts BadSignature. Expected: a signed and paid brief cannot be cancelled by a third party for nothing. Verified with test/scratch/Misc.t.sol test_OneWeiCancelsASignedBrief.","severity":"low","snippet":"        if (amount == 0) revert NothingToRefund(); // a refund pays something: nobody cancels a signed brief for free","title":"refundRelay lets anyone holding a signature cancel a signed brief for 1 wei"}],"hash":"0f1042f25e91733cce4584452a59a9bc3b1c6b01a67bed3ae1379a1a3cdf64f4","nodeId":"e7b8c065-b951-4340-90de-120a2736d630","outcome":"completed","summary":"Review complete. The findings file holds three substantiated findings, each verified with a Foundry test under `test/scratch/`. No critical or high defect was found.\n\n**What I checked**\n\n- **Funds and solvency.** Every path that moves IMD (file, hearing open, verdict split, mistrial refund, skip refund, settlement, creator and reign claims, held payments) keeps the stated invariant. The requester self-call rolls back cleanly, every outer entry point is guarded, and the token has no hooks. Nothing I tried takes, locks or misroutes funds.\n- **Time in the lead.** Credits happen only at lead changes in docket order, which is filing order, so each leader's seconds sum to `reignTotal`, `claimReign` never exceeds the pool, and overtime earns nobody time. Queue stuffing and multi-wallet self-dealing gain nothing.\n- **Relay.** The message is uniquely parseable, keyed by message hash so signature malleability is irrelevant, bound to court and chain, and a message is filed or refunded but never both.\n- **Oracle.** The question hash is rebuilt from immutable case texts, delivery is checked against the Intake's own slot, the time window and panel are enforced, and the signature is checked under the jury's domain.\n- **Liveness, gas, text.** A hearing always ends, a stalled head can always be skipped, the 64/63 check is correct, and the longest English question stays under the limit.\n\n**Findings**\n\n1. **Medium.** Overtime only fires when the verdict lands before `endsAt`. A brief filed one second before the bell is always judged after it, so it takes the lead and the final share with no reply window. The authors' own test asserts this behaviour, so this is a design gap, and the fix needs a scope decision. Proof test attached and fails on current code.\n2. **Low.** `openCase` pins the fee, split, share, cap, overtime and jury, but not `panelSize`, `quorum`, `answerTimeout`, `maxOracleFee` or the court's `maxBrief`. An owner change under a pending `openCase` opens the case with values the creator never saw.\n3. **Low.** `refundRelay` with `amount = 1` wei cancels a signed brief forever. Anyone holding the signature can do it, including after a reverted relayer transaction exposed it in calldata.\n\nTrust assumptions to document rather than fix: the owner and jury owner set economics and jury setups within bounds, and the IMD Intake is trusted to deliver honestly.","treeHash":null,"usage":{"cachedInputTokens":1384135,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":59859,"runtime":"claude","turns":22,"wallClockMs":883081}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"b273d407784470b4","findings":[{"citation":"resolved","description":"refundRelay marks a message refunded (relayed[h] = 2, after which useRelay/fileBriefFor revert BadSignature forever) for any caller holding the signature as long as amount > 0. The eighth review's fix (NothingToRefund) only excludes amount == 0; amount == 1 wei of IMD is effectively free. Anyone who holds the signature (the author, the relayer, or anyone who saw it in the calldata of a reverted fileBriefFor) can therefore cancel a signed brief for 1 wei plus gas. In the relay flow this gives the author a free option: pay on Ethereum, watch the case for the ~12 confirmations the relayer waits, and self-cancel on Robinhood Chain if the case turns unfavourable; the relayer then has to refund paid - 1 wei through refundRelay. Since nothing ties the refund amount to the entry fee or to the author's payment, the guard does not achieve what its comment claims.","line":315,"path":"src/BriefsJury.sol","reproduction":"dan signs relayMessage(c, words, dan, deadline). carol (any address with 1 wei of IMD approved to the jury) calls jury.refundRelay(c, words, dan, deadline, sig, 1). Expected (per the comment and the eighth review): a cancellation must cost a real refund. Actual: relayed[h] == 2, dan receives 1 wei, and a later b.fileBriefFor(c, words, dan, deadline, sig) by the relayer reverts BadSignature. Scratch test test_OneWeiRefundCancelsASignedBrief passes against the current code. Suggested fix: require amount to be at least the case's entry fee (court.getCase(caseId).fee) or at least a minimum set by the owner, or restrict the first refund (the one that flips the state to 2) to the author or to an allow-listed relayer.","severity":"low","snippet":"        if (amount == 0) revert NothingToRefund(); // a refund pays something: nobody cancels a signed brief for free","title":"refundRelay: a 1-wei refund cancels any signed brief, so the 'no free cancellation' guard is ineffective"},{"citation":"resolved","description":"The stated rule is that openCase must name the settings the creator saw and revert SettingsChanged otherwise. CaseInput pins reignBps, finalCap, overtime, caseFee, creatorBps and platformBps, but _newCase also copies params.panelSize, params.quorum, params.answerTimeout and params.maxOracleFee (the jury reserve) into the case for life, and checkCase takes params.maxBrief as the court limit when the creator passes maxBrief == 0. None of these is named in CaseInput, so a setParams transaction sequenced between the creator reading the settings and openCase changes the jury the case is judged by (3 seats instead of 11), the mistrial window (2 hours instead of 4 minutes), the brief length and the reserve, with no revert. These are the same class of surprise the SettingsChanged check was added for in the eighth review, and the panel and answer window shape every hearing of the case.","line":638,"path":"src/Briefs.sol","reproduction":"Creator builds CaseInput against params {panelSize 11, quorum 6, answerTimeout 4 min, maxBrief 500, maxOracleFee 0.9}. Owner calls setParams with {panelSize 3, quorum 2, answerTimeout 2 hours, maxBrief 100, maxOracleFee 0.1}. Creator's openCase(x) is then mined. Expected: revert SettingsChanged. Actual: the case opens with panelSize 3, quorum 2, answerTimeout 7200, maxBrief 100, reserve 0.1 ether (scratch test test_UnpinnedSettingsApplySilently). Fix: add panelSize, quorum, answerTimeout and maxOracleFee (and the court maxBrief, or require maxBrief != 0) to CaseInput and compare them in _newCase like the other settings; Briefs is near the size limit, so the comparison may have to live in BriefsText.checkCase's packed rules word.","severity":"low","snippet":"        if (x.overtime != p.overtime || x.caseFee != p.caseFee || x.creatorBps != p.creatorBps || x.platformBps != p.platformBps) {","title":"openCase does not pin panelSize, quorum, answerTimeout, the court's maxBrief or maxOracleFee: an owner setParams landing first silently changes the case the creator saw"},{"citation":"resolved","description":"Overtime is meant to keep a hot case open when a new leader appears in its last minutes, so that others can answer. _newLeader only extends the deadline when the verdict's block.timestamp is before the current endsAt. A hearing takes at least the panel's latency (about 30 s to several minutes), so any challenger filed inside that latency of endsAt has its verdict land after endsAt and, if it wins, becomes the final leader with endsAt unchanged; if the docket is then empty, _maybeSettle pays the pot in the same fulfill. The protection is thus bypassed in exactly the situation it was designed for, and the bypass is cheaper (one fee) than winning in overtime. The suite documents the behaviour (test_Reign_ALeaderHeardAfterTheDeadline...) and the v1 doc accepts deadline sniping, but v2 advertises overtime as the counter to it; the two are inconsistent.","line":758,"path":"src/Briefs.sol","reproduction":"Params overtime = 10 min. Alice takes the lead at endsAt - 2 h. Bob files at endsAt - 1 s; the verdict (COOKED) lands at endsAt + 30 s. Expected (the feature's purpose): endsAt pushed so others can respond to the new leader. Actual: endsAt unchanged, status Settled, winner bob, all in the same fulfill (scratch test test_LastSecondFilingSkipsOvertime). Fix option: decide overtime from the filing time rather than the verdict time, e.g. if filedAt + overtime > endsAt (the brief was filed in the last `overtime` seconds) set endsAt = max(t, endsAt) + overtime capped at finalEnd, reopening entries for `overtime` seconds after the verdict; this changes the agreed rule and should be a product decision, so alternatively document that a filing inside the jury's latency defeats overtime.","severity":"low","snippet":"        if (t < end) {","title":"Overtime never triggers for a brief whose COOKED verdict lands after endsAt, so a last-second filing wins with no overtime and the case settles in the same call"}],"hash":"202706d4ed9bf4cd441f9e4fda0383cd6fed3466f2585b1cba597e19f5abea4f","nodeId":"7a91fa9c-7ae7-40e4-ac37-1b0f9a99369d","outcome":"completed","summary":"The review is complete. The findings file is valid, every snippet matches its line, and the tree is clean apart from `.imd-findings.json` and the scratch directory.\n\n**Result: no critical, high or medium defect found. Three low findings, each with a passing reproduction test.**\n\n**Scope covered in depth**\n\n- Funds and the solvency invariant: every IMD movement in Briefs (seed, escrowed fees, the jury's price at opening, the verdict split, mistrial and skip refunds, settlement, time-in-the-lead claims, held payments). The sum of credited seconds equals `reignTotal` on every path, `claimReign` cannot exceed `reignPool`, and no transfer failure can block a docket or a settlement.\n- Time in the lead and overtime: filing-time crediting under FIFO, `reignEnd`, `overtimePot`, `finalCap`, queue stuffing, self-dealing and late verdicts.\n- Relayed briefs: message framing is unambiguous, replay and malleability are closed, filed and refunded states are exclusive.\n- Oracle path: questionHash rebuilt on chain, the Intake's delivery slot, answer window, panel, EIP-712 domain, the Intake callback's gas.\n- Griefing, admin bounds, the 64/63 gas rule, and BriefsText's UTF-8, JSON and English-only rules.\n\n**Findings (all low)**\n\n1. **Near-free cancellation of a signed brief.** `refundRelay` only rejects `amount == 0`, so anyone holding the signature cancels a brief for 1 wei. The author gains a free withdrawal option after paying on Ethereum. Suggested fix: require at least the case's fee or restrict the state-flipping refund.\n2. **Unpinned case settings.** `openCase` pins the fee split, reign share, cap and overtime, but not `panelSize`, `quorum`, `answerTimeout`, the court `maxBrief` or `maxOracleFee`. An owner `setParams` sequenced first silently changes the jury and the mistrial window of the case.\n3. **Overtime bypass.** A COOKED verdict landing after `endsAt` never extends the deadline, so a filing inside the jury's latency wins and settles in the same call. The suite documents this as intended, but it defeats the stated purpose of overtime. Flagged as a product decision.\n\n**Known items re-checked and left as accepted:** the late-answer mistrial race, the leader skipping challengers on a transient requester failure after the stall grace, and a few wei of rounding dust in the reign pool.","treeHash":null,"usage":{"cachedInputTokens":2171727,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":59711,"runtime":"claude","turns":25,"wallClockMs":983165}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"4ebee02fc506f27c","findings":[{"citation":"resolved","description":"_newLeader pushes the deadline only when the verdict's block.timestamp t is still before c.endsAt. A brief filed inside the last `overtime` seconds (the exact situation overtime exists for) is heard for at least one hearing's duration (an IMD panel answer takes about a minute; answerTimeout is 3 min to 2 h), so a brief filed in the last minute, or any brief that sits behind other briefs on the docket, gets its verdict at t >= endsAt. Then no Overtime is emitted, entries stay closed, and because the docket is now empty the same fulfill() transaction settles the case and pays the pot to that brief's author. The anti-sniping property described in the contract header ('A new leader late in a case pushes its deadline (overtime)') therefore protects only against a sniper whose verdict happens to land before the bell. A sniper controls this: file at endsAt-1, or file the real brief behind two or three throwaway briefs (each costs one entry fee and about a minute of hearing) so the real one is reached only after endsAt. The previous leader, who may have led for the whole case, has no way to respond, and overtime briefs that would have fed the final leader are never possible. Expected (per the docs and the Overtime event's own comment): a leader that emerges from a brief filed in the last `overtime` seconds extends the case by `overtime` (capped by finalEnd) whether its verdict lands a second before or a second after endsAt. Fix: base the push on the brief's filing time rather than the verdict time, e.g. in _newLeader: `if (filedAt + c.overtime > end) { later = t + c.overtime; cap...; if (later > end) { c.endsAt = later; emit Overtime } }`, and keep canSettle as it is (the case cannot settle while that hearing is open, so reopening entries is consistent). If the design is to keep the current rule, the docs, site and the claim in the eighth review should say that overtime does not apply to briefs heard after the bell, since that is the common case for a last-minute filing.","line":758,"path":"src/Briefs.sol","reproduction":"Params: overtime=10 minutes, reignBps=6000, finalCap=0, answerTimeout=4 minutes (test/scratch/Probe.t.sol: test_Probe_SnipeAtTheBellSkipsOvertime and test_Probe_QueueStuffingMakesTheLateVerdictCertain, both pass on c29c658 and show the behaviour). (1) Alice files at end0-20h and is COOKED. (2) Bob files 'Bob snipes at the bell' at end0-1; the hearing opens in the same block. (3) The attestation for Bob's hearing (true, issuedAt = heardAt+30) is delivered with fulfill at end0+60. Actual: getCase(c).endsAt == end0 (unchanged, no Overtime event), status == Settled in that same transaction, winner == bob; Alice's fileBrief reverts NotOpen. Expected: endsAt pushed to about end0+60+10 minutes so Alice (or anyone) could answer. Variant (2'): Bob files two junk briefs then the real one at end0-5 minutes (inside the overtime window); the junk ones are heard and Sustained at end0-3 min and end0-1 min, the real one is heard only now and its true verdict lands at end0+30: again no overtime, Bob wins and the case settles.","severity":"medium","snippet":"        if (t < end) {","title":"Overtime never triggers when the winning verdict lands at or after endsAt: a late filer (or a queue-stuffer) takes the final share with no chance for anyone to answer"},{"citation":"resolved","description":"refundRelay marks the message refunded (relayed[h] = 2, unfileable forever) for any amount > 0, paid by the caller to the author. One wei of IMD is free in practice, so anyone who gets hold of a signed relay message can cancel it. Signatures become public whenever fileBriefFor lands and reverts (a reverted transaction's calldata is on chain on Robinhood Chain: OracleTooExpensive while IMD's price is above the reserve, NotHolder, EntriesClosed after an overtime push moved the deadline, a relayer retry after a replaced transaction), or whenever the relayer's infrastructure leaks them. A griefer then calls refundRelay with amount = 1 before the relayer's retry; the retry reverts BadSignature, the author's brief is lost for that case (they lose the time-in-the-lead they would have had from that filing) and the relayer has to refund the author's Ethereum payment through the normal path. Expected: a refund that cancels a brief pays at least something meaningful to the author, or only the author / the court may cancel. Minimal fix: require amount >= a floor the author can rely on (for example the case's entry fee, read from the court: `if (amount < court.feeOf(caseId)) revert NothingToRefund();`), or restrict cancellation (state 0 -> 2) to msg.sender == author and let anyone only add to a refund already opened.","line":315,"path":"src/BriefsJury.sol","reproduction":"test/scratch/Probe.t.sol: test_Probe_AnyoneCancelsASignedBriefForOneWei (passes on c29c658). Dan signs relayMessage(caseId, \"Dan's brief\", dan, deadline). Carol, who has the signature, calls jury.refundRelay(caseId, \"Dan's brief\", dan, deadline, sig, 1). Actual: relayed[h] == 2, Dan received 1 wei, and the relayer's fileBriefFor(caseId, \"Dan's brief\", dan, deadline, sig) reverts BadSignature. Expected: a 1-wei refund does not void a signed brief.","severity":"low","snippet":"        if (amount == 0) revert NothingToRefund(); // a refund pays something: nobody cancels a signed brief for free","title":"refundRelay lets anyone holding a signature cancel the signed brief for 1 wei: the 'nobody cancels for free' guard is only a non-zero check"},{"citation":"resolved","description":"The header says a case fixes 'its split, jury, panel, answer window and brief length when it opens' and SettingsChanged exists so that a creator never gets settings other than the ones they saw. But _newCase copies p.panelSize, p.quorum, p.answerTimeout, p.maxOracleFee (the reserve) and, when x.maxBrief == 0, p.maxBrief straight from storage without comparing them to CaseInput. A setParams that lands between the creator reading the settings and openCase executing (the owner is a multisig, so its transactions are visible in advance, but openCase cannot refuse them) gives the case a different jury (panel 3/2 instead of 11/6, a 2-hour window instead of 4 minutes, a reserve of 4.9 IMD instead of 0.9 of a 5 IMD fee) for its whole life, and the case cannot be moved to another setup. The reserve matters economically: it is the most each hearing may hand to IMD out of the fee, so a higher reserve means that if IMD's price rises, less of each fee reaches the pot, the creator and the platform. Expected: SettingsChanged for every setting the creator cannot choose, as the eighth review did for caseFee and the split. Fix: add panelSize, quorum, answerTimeout and maxOracleFee (and the court's maxBrief when x.maxBrief == 0) to CaseInput and compare them in _newCase (Briefs is 54 bytes under the size limit, so the comparison may need to move into BriefsText.checkCase's packed `rules` word or a new jury view).","line":638,"path":"src/Briefs.sol","reproduction":"test/scratch/Probe.t.sol: test_Probe_OwnerChangesPanelAndReserveUnderAPendingOpenCase (passes on c29c658). Creator builds CaseInput from Params{panelSize 11, quorum 6, answerTimeout 4 min, maxOracleFee 0.9e18, minFee 1e18} with fee 5e18. Owner calls setParams with panelSize 3, quorum 2, answerTimeout 2 hours, maxOracleFee 4.9e18, minFee 5e18. openCase(x) succeeds (no SettingsChanged). Actual: getCase(id).panelSize == 3, quorum == 2, answerTimeout == 7200, reserve == 4.9e18. Expected: revert SettingsChanged, as it does for overtime, caseFee and the split.","severity":"low","snippet":"        if (x.overtime != p.overtime || x.caseFee != p.caseFee || x.creatorBps != p.creatorBps || x.platformBps != p.platformBps) {","title":"openCase pins the split, caseFee, overtime, reignBps and finalCap but not the panel, quorum, answer window or jury reserve: an owner change landing before a pending openCase changes the case's jury fo"},{"citation":"resolved","description":"_check accepts hearingGas as low as 200k, while Briefs._hearNext's own comment says openHearing costs about 265k on IMD's Intake (two forceApproves, the Intake's request with a 1.9 KB body, and the bookkeeping). With a setup proposed at the floor, this.openHearing{gas: hearingGas} runs out of gas on every brief, each filing is reported as HearingStalled, and 6 hours later anyone (the sitting leader first) can skipStalled each challenger unheard. The sitting precedent then wins the whole pot by default. This is an owner mistake that the 7-day proposal delay makes visible, so it is a trust assumption rather than a bypass; the floor should still not admit a value that is known to fail. Fix: raise the floor to the measured worst case with a margin (say 400k), or document that the floor is a placeholder and that the deploy script measures the real requester.","line":400,"path":"src/BriefsJury.sol","reproduction":"proposeOracle(Oracle{..., hearingGas: 200_000}) with ImdGatewayRequester on an Intake whose request costs about 265k; after 7 days applyOracle; open a case on it. fileBrief: the hearing's openHearing reverts out of gas, HearingStalled is emitted and the brief stays at the head of the docket; 6 h later skipStalled hands its fee back unheard. Expected: _check refuses a hearingGas that cannot open a hearing.","severity":"info","snippet":"            o.signer == address(0) || address(o.requester) == address(0) || o.chainId == 0 || o.hearingGas < 200_000","title":"hearingGas floor (200,000) is below the measured cost of opening a hearing on IMD's Intake (about 265k): a setup at the floor stalls every hearing and lets the leader win by skipStalled"},{"citation":"resolved","description":"The design says the opening brief earns no time in the lead and that the time before the first COOKED belongs to nobody (the pool is scaled by total / (reignEnd - createdAt)). The creator writes both the opening brief and, from any other wallet, a marginally better challenger filed one second after openCase; the jury must call it better, so it takes the lead at createdAt+1, firstReign is set there, the pool is the full reignBps of the pot, and that wallet is paid for every second until the first honest leader's filing. With reignBps 60%, a 1-day case, a 500 IMD seed, 5 IMD fees and an honest leader arriving after 20 hours, the creator's second wallet takes 253.6 IMD of a 304.3 IMD pool (the creator's own seed is most of that, so the real transfer is other players' fees in the pool; it grows with the pot). The same trick also lets the creator earn creatorBps on its own fee back. Nothing here steals from the exact accounting (the books still balance), and it cannot be prevented on chain without changing the rule (any address could file first), so this is an economic note for the docs and the site: a case creator who plays is a normal player, and the 'opening brief earns none' rule only binds the single opening entry, not its author.","line":152,"path":"src/Briefs.sol","reproduction":"test/scratch/Probe.t.sol: test_Probe_CreatorSelfChallengerEarnsThePreCompetitionTime (passes on c29c658). Params reignBps 6000; case opened at T with endsAt T+1 day, seed 500e18, fee 5e18. Wallet `sock` files at T+1 and its verdict (true) lands at T+60; alice files at T+20h and wins. settle at T+1d. Actual: reignOf(c, sock) == 20h-1s, reignTotal == 1d-1s, claimReign(c, sock) pays 253_596_477_777_777_777_777 wei of a 304_316_477_777_777_777_777 wei pool. Expected per the stated rule: the creator's side earns nothing for the time before the first outside challenger.","severity":"info","snippet":"        uint64 reignStart; // when the standing leader took the lead (0: the opening brief leads, nobody's time)","title":"'The opening brief earns no time' is void for a creator with a second wallet: a self-challenger filed a second after opening is paid for the whole pre-competition period"}],"hash":"a3efd50380b353fc7e82d45510240e51e0ef40849503bb15c7955986b4c7ea12","nodeId":"61a065a6-fee0-40c9-922d-a9df0f16b0a5","outcome":"completed","summary":"The review is complete and the findings file is written. Here is the recap.\n\n**Result:** no critical or high issue. One medium, two low, two info, all with passing probe tests under `test/scratch/Probe.t.sol` and recorded in `.imd-findings.json` with snippets verified against the tree. The project's own suite (161 tests, including the solvency invariant) passes at c29c658.\n\n**Findings**\n\n- **Medium, overtime is skipped when the verdict lands at or after the deadline.** The push in `_newLeader` only fires when the verdict's timestamp is before endsAt. A brief filed at the bell, or a real brief queued behind two throwaway briefs, is heard after the deadline, gets no overtime, and the same fulfill transaction settles the case to the sniper. The previous leader cannot answer. Fix suggested: push based on the brief's filing time, not the verdict time.\n- **Low, a signed relay brief can be cancelled for 1 wei.** The guard in refundRelay is only a non-zero check. Anyone who sees a signature (a reverted fileBriefFor leaves it on chain) can void the brief.\n- **Low, openCase does not pin the panel, quorum, answer window or jury reserve.** A setParams landing before a pending openCase fixes a different jury for the case's whole life without SettingsChanged. The test shows panel 3/2, a 2 hour window and a 4.9 IMD reserve on a 5 IMD fee.\n- **Info, the hearingGas floor of 200k is below the measured 265k Intake cost.** A setup at the floor stalls every hearing and lets the leader win through skipStalled. Owner mistake, visible for 7 days.\n- **Info, the creator's second wallet earns the pre-competition time.** A self-challenger filed one second after opening collects reignBps for every second until the first honest leader files. Inherent to the rule, worth stating in the docs.\n\n**Checked and found sound:** the solvency invariant across every fund path, the seconds telescoping to reignTotal and claimReign never exceeding reignPool, relay replay and malleability (court, chain, author and deadline in the message, hash-keyed state), the oracle binding (questionHash with briefId and precedent, Intake-only delivery slot, window and panel checks, jury-domain EIP-712), the 64/63 gas guard, the stall clock, and the JSON and length rules (the English question peaks at 1,900 bytes).","treeHash":null,"usage":{"cachedInputTokens":1683954,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":59625,"runtime":"claude","turns":24,"wallClockMs":955477}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fbcdfc017217af1f","findings":[{"citation":"resolved","description":"The README and the _newLeader comment state the rule as 'a new leader in the case's last `overtime` seconds pushes the deadline', the mechanism v2 added so a hot case stays open and a late winner can be answered. _newLeader implements it on `t = block.timestamp` of the VERDICT, not on when the winning brief was filed. Every hearing takes time (the jury answers minutes later; a mistrial on a brief ahead in the FIFO docket takes answerTimeout + 2 minutes), so a brief filed inside the last `overtime` seconds is routinely judged after endsAt. For such a lead `t >= end`, no extension happens, `_maybeSettle` runs in the same fulfill, and the case settles with the sniper as final leader. The outcome is inverted from the intent: a challenger whose verdict lands at end - 4 min hands everyone an extension to end + 6 min and can be beaten; one filed at end - 1 s (or behind one junk brief it files first, so its own hearing opens after the deadline) takes the pot with no possibility of reply. In overtime itself the same applies: after an extension to end + 1 min, a brief filed at end + 0:30 and judged at end + 1:30 ends the case, so overtime degenerates to 'whoever is judged last'. Since every player can see the queue and the clock, the dominant strategy is to file as late as possible, which is exactly what overtime was meant to remove. Fix (validated against the proof on a patched copy): in _newLeader change the trigger to `if (t < end || filedAt + c.overtime > end)` and keep `later = t + c.overtime` (capped at finalEnd as now), so a lead won by a brief filed within the last `overtime` seconds extends the deadline even when its verdict lands after it. Reign accounting is unaffected: reignEnd and overtimePot already key on filedAt, and the proof then passes (endsAt = end + 11 min, case Open, carol can answer).","line":758,"path":"src/Briefs.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.30;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {Briefs} from \"src/Briefs.sol\";\nimport {BriefsText} from \"src/BriefsText.sol\";\nimport {BriefsJury} from \"src/BriefsJury.sol\";\nimport {ImdOracle} from \"src/ImdOracle.sol\";\nimport {IImdRequester} from \"src/interfaces/IImdRequester.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\n\ncontract MockERC20 is ERC20 {\n    constructor(string memory n, string memory s, address to) ERC20(n, s) {\n        _mint(to, 1_000_000_000 ether);\n    }\n}\n\n/// a stand-in for IMD's requester: takes the fee, returns a fresh id, no on-chain answer source\ncontract MockRequester is IImdRequester {\n    IERC20 public immutable imd;\n    uint256 internal fee_;\n    uint256 public count;\n\n    constructor(IERC20 imd_, uint256 fee__) {\n        imd = imd_;\n        fee_ = fee__;\n    }\n\n    function answerSource() external pure returns (address) {\n        return address(0);\n    }\n\n    function fee() external view returns (uint256) {\n        return fee_;\n    }\n\n    function request(string calldata, address) external returns (bytes32 requestId) {\n        imd.transferFrom(msg.sender, address(this), fee_);\n        requestId = bytes32(uint256(keccak256(abi.encode(address(this), ++count))) << 128);\n    }\n}\n\ncontract Digester {\n    function digest(bytes32 domain, ImdOracle.AttestationV2 calldata a) external pure returns (bytes32) {\n        return ImdOracle.digestV2(domain, a);\n    }\n}\n\n/// Overtime is meant to keep a hot case open: \"a new leader in the last `overtime` seconds pushes the deadline\".\n/// But _newLeader only extends when the VERDICT lands before endsAt. A brief filed in the last seconds (or behind a\n/// queue) is judged after the deadline, takes the lead, and the case settles at once: the latest sniper can't be\n/// answered, while an honest challenger judged a few minutes earlier hands everyone else an extension.\ncontract OvertimeSnipeTest is Test {\n    Digester digester = new Digester();\n    MockERC20 imd;\n    MockRequester requester;\n    Briefs b;\n    BriefsJury jury;\n    uint256 key = 0xB21EF;\n    bytes32 domain;\n\n    address creator = makeAddr(\"creator\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address carol = makeAddr(\"carol\");\n    address treasury = makeAddr(\"treasury\");\n\n    function setUp() public {\n        vm.warp(1_790_800_000);\n        imd = new MockERC20(\"IMD\", \"IMD\", address(this));\n        requester = new MockRequester(IERC20(address(imd)), 0.5 ether);\n        domain = ImdOracle.domainSeparatorV(\"2\", 1, address(0));\n        jury = new BriefsJury(\n            BriefsJury.Oracle({signer: vm.addr(key), requester: requester, domain: domain, chainId: 1, hearingGas: 3_000_000}),\n            address(this),\n            address(0)\n        );\n        b = new Briefs(IERC20(address(imd)), new BriefsText(), jury, treasury, _params());\n        address[4] memory users = [creator, alice, bob, carol];\n        for (uint256 i; i < users.length; i++) {\n            imd.transfer(users[i], 1_000_000 ether);\n            vm.prank(users[i]);\n            imd.approve(address(b), type(uint256).max);\n        }\n    }\n\n    function _params() internal pure returns (Briefs.Params memory) {\n        return Briefs.Params({\n            minSeed: 10 ether,\n            minFee: 1 ether,\n            maxOracleFee: 0.9 ether,\n            creatorBps: 1_500,\n            platformBps: 500,\n            panelSize: 11,\n            quorum: 6,\n            answerTimeout: 4 minutes,\n            caseFee: 2 ether,\n            minDuration: 10 minutes,\n            maxDuration: 90 days,\n            maxBrief: 500,\n            reignBps: 6_000,\n            overtime: 10 minutes,\n            finalCap: 0,\n            creatorSetsReign: false,\n            creatorSetsCap: false\n        });\n    }\n\n    function _case() internal returns (uint256) {\n        Briefs.CaseInput memory x = Briefs.CaseInput({\n            title: \"Dragon Jokes\",\n            task: \"Write the funniest joke about dragons.\",\n            standard: \"The funnier brief wins.\",\n            opening: \"Dragons never use banks. Too many firewalls.\",\n            avatar: 3,\n            seed: 500 ether,\n            fee: 5 ether,\n            endsAt: uint64(block.timestamp + 1 days),\n            minHold: 0,\n            oracleId: 0,\n            reignBps: 6_000,\n            finalCap: 0,\n            overtime: 10 minutes,\n            lang: 0,\n            maxBrief: 0,\n            caseFee: 2 ether,\n            creatorBps: 1_500,\n            platformBps: 500\n        });\n        vm.prank(creator);\n        return b.openCase(x);\n    }\n\n    function _fileAt(uint256 c, address who, string memory words, uint256 t) internal returns (uint256) {\n        vm.warp(t);\n        vm.prank(who);\n        return b.fileBrief(c, words);\n    }\n\n    function _verdictAt(uint256 briefId, bool better, uint256 t) internal {\n        vm.warp(t);\n        Briefs.Brief memory br = b.getBrief(briefId);\n        ImdOracle.AttestationV2 memory a = ImdOracle.AttestationV2({\n            requestId: br.requestId,\n            chainId: 1,\n            questionHash: jury.questionHashOf(briefId, 1, 2),\n            answerType: 0,\n            answer: abi.encode(better),\n            figure: 0,\n            fromBlock: 1,\n            toBlock: 2,\n            blockHash: keccak256(\"b\"),\n            panelJobId: keccak256(\"p\"),\n            panelSize: 11,\n            quorum: 6,\n            agreed: 6,\n            issuedAt: br.heardAt + 30,\n            expiresAt: uint64(block.timestamp + 1 days)\n        });\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, digester.digest(domain, a));\n        b.fulfill(briefId, a, abi.encodePacked(r, s, v));\n    }\n\n    /// bob files inside the last `overtime` seconds, so by the stated rule his lead must push the deadline; the\n    /// verdict lands a minute after the deadline (every hearing takes time), so no overtime: the case settles at once\n    /// and carol, who was ready to answer, is shut out.\n    function test_ALeadTakenByAVerdictAfterTheDeadlineStillOpensOvertime() public {\n        uint256 c = _case();\n        uint256 end = b.getCase(c).endsAt;\n        uint256 a1 = _fileAt(c, alice, \"Alice leads from eleven minutes out.\", end - 11 minutes);\n        _verdictAt(a1, true, end - 11 minutes); // alice leads; not in the last 10 minutes, no overtime\n        assertEq(b.getCase(c).endsAt, end);\n\n        // bob snipes: filed 1 second before the deadline, inside the last `overtime` seconds\n        uint256 b1 = _fileAt(c, bob, \"Bob snipes one second before the bell.\", end - 1);\n        _verdictAt(b1, true, end + 1 minutes); // the jury answers after the deadline\n\n        // expected: a new leader in the last `overtime` seconds pushes the deadline, so others can answer\n        Briefs.Case memory k = b.getCase(c);\n        assertGt(k.endsAt, end, \"overtime must open for a lead taken in the last overtime seconds\");\n        assertEq(uint8(k.status), uint8(Briefs.CaseStatus.Open), \"the case must stay open for an answer\");\n        vm.warp(k.endsAt - 1);\n        vm.prank(carol);\n        b.fileBrief(c, \"Carol answers the sniper in overtime.\");\n    }\n}","reproduction":"Params: reignBps 6000, overtime 10 minutes, finalCap 0, answerTimeout 4 min. Case opened with endsAt = now + 1 day (= end). alice files at end - 11 min and wins at end - 11 min (leader, no overtime as expected). bob files at end - 1 s (inside the last 10 minutes). The verdict for bob lands at end + 1 min (fulfill). Expected (stated rule): endsAt pushed to about end + 11 min, case still Open, carol can file an answer. Actual: getCase(c).endsAt == end, status == Settled, winner == bob, carol's fileBrief reverts EntriesClosed. Compare: the same bob brief filed at end - 4 min and judged at end - 4 min pushes endsAt to end + 6 min and can be answered. test/scratch/OvertimeSnipe.t.sol fails on the current code with 'overtime must open for a lead taken in the last overtime seconds: 1790886400 <= 1790886400'.","severity":"medium","snippet":"        if (t < end) {\n            uint256 later = t + c.overtime;\n            uint256 cap = c.finalEnd;\n            if (cap != 0 && later > cap) later = cap;\n            if (later > end) {\n                c.endsAt = uint64(later);\n                emit Overtime(caseId, later);\n            }\n        }","title":"Overtime keys on the verdict time, so a lead taken by a verdict landing after the deadline never opens overtime: the latest sniper is unanswerable"},{"citation":"resolved","description":"The eighth review's fix for 'amount 0 marked a message refunded for free' only rejects amount == 0. Any amount >= 1 wei still sets relayed[h] = 2, after which useRelay (and so Briefs.fileBriefFor) reverts BadSignature for that message forever. The author signs the message and holds the signature, so after paying the relayer on Ethereum they can call refundRelay(..., 1) paying 1 wei to themselves and the relayer's filing fails. The off-chain relayer then reads `refunded` (1 wei) and pays the rest back, as documented ('the relayer only pays what that record says is still owed'). Net effect: a free, unilateral cancel option on a brief that was sold as final, exercisable up to the moment the relayer's fileBriefFor lands (e.g. after seeing a stronger brief land first, or the case's leader change). The comment's guarantee ('nobody cancels a signed brief for free') does not hold. Fix: require the refund to be at least a meaningful floor (e.g. the case's entry fee read from the court, or an owner-set minimum), or restrict refundRelay to a relayer role, or let only the court's owner/relayer mark a message refunded while anyone may top up.","line":315,"path":"src/BriefsJury.sol","reproduction":"Case c open (fee 5 IMD). dan signs relayMessage(c, words, dan, deadline) (personal_sign). dan (any holder of the signature) calls jury.refundRelay(c, words, dan, deadline, sig, 1) after approving 1 wei of IMD to the jury. Expected: a refund that cancels a signed brief costs a real amount. Actual: relayed(h) == 2, dan's balance is unchanged (1 wei to himself), and the relayer's b.fileBriefFor(c, words, dan, deadline, sig) reverts BadSignature. Verified in test/scratch/Misc.t.sol::test_RefundRelayOneWeiCancelsForFree.","severity":"low","snippet":"        if (amount == 0) revert NothingToRefund(); // a refund pays something: nobody cancels a signed brief for free","title":"refundRelay: a 1-wei refund marks a signed brief refunded, so the author (or anyone holding the signature) cancels a paid filing for free"},{"citation":"resolved","description":"The stated requirement is that openCase names the settings the creator saw and reverts SettingsChanged otherwise. CaseInput carries oracleId, reignBps, finalCap, overtime, caseFee, creatorBps and platformBps, but the case also fixes for life panelSize, quorum, answerTimeout (c.panelSize/c.quorum/c.answerTimeout), the jury reserve c.reserve = params.maxOracleFee (line 634), and, when x.maxBrief is 0, the court's params.maxBrief (line 334). None of these is compared with what the creator saw. An owner setParams landing before a pending openCase (or simply between page load and the click) gives the creator a case judged by a 2-of-3 panel instead of the 6-of-11 shown, a 2-hour answer window instead of 4 minutes (each unanswered hearing then holds the docket 2 h), a reserve that lets the jury take up to 4.9 of a 5 IMD fee (the creator's 15% cut per brief falls from 0.675 to 0.015 IMD when IMD's price rises), or a brief limit of 100 bytes instead of 500, all silently. This is the same class as the fixed F-5 (caseFee) and the fixed overtime pin. Fix: add panelSize, quorum, answerTimeout and maxOracleFee (and the court maxBrief when x.maxBrief == 0) to CaseInput and revert SettingsChanged on any mismatch, as done for the others.","line":628,"path":"src/Briefs.sol","reproduction":"Owner calls setParams with panelSize 3, quorum 2, answerTimeout 2 hours, minFee 5 ether, maxOracleFee 4.9 ether, maxBrief 100 (all within _setParams bounds). Creator then calls openCase with the CaseInput built from the previous settings (fee 5 ether, maxBrief 0, oracleId/reign/overtime/caseFee/split unchanged). Expected: revert SettingsChanged. Actual: the case opens with panelSize 3, quorum 2, answerTimeout 7200, reserve 4.9e18, maxBrief 100. Verified in test/scratch/Misc.t.sol::test_UnpinnedSettings (console output: panel 3 2, timeout 7200 reserve 4900000000000000000, maxBrief 100).","severity":"low","snippet":"        c.panelSize = p.panelSize;\n        c.quorum = p.quorum;\n        c.answerTimeout = p.answerTimeout;","title":"openCase pins only part of the settings: panelSize, quorum, answerTimeout, the jury reserve (maxOracleFee) and the court's maxBrief can change under a pending openCase without SettingsChanged"}],"hash":"e760ad6a681ba6f9256751a319c220d85f9d122dc96661bf0fa15556379e2f27","nodeId":"73e251c2-2e73-46ef-b71b-d165c02c48fb","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds three substantiated findings. No source or configuration file was changed; only `test/scratch/` (mine, not kept) was written.\n\n**Findings**\n\n- **Medium, `src/Briefs.sol:758`.** Overtime keys on the verdict's timestamp, not the winning brief's filing time. A brief filed in the last `overtime` seconds is routinely judged after the deadline (hearings take minutes, and a junk brief ahead in the FIFO docket guarantees it), takes the lead, and the case settles in the same call. The latest sniper can never be answered, while an honest challenger judged a few minutes earlier hands everyone an extension. This inverts the stated rule and the purpose of v2 overtime. Proof `test/scratch/OvertimeSnipe.t.sol` (self-contained, inline token and requester) fails on the current code and passes under the fix I validated on a patched copy: trigger on `t < end || filedAt + overtime > end`, keep `later = t + overtime`.\n- **Low, `src/BriefsJury.sol:315`.** The eighth review's \"no free cancel\" fix only rejects amount 0. A 1-wei `refundRelay` by the author to themselves marks the message refunded, so the relayer's `fileBriefFor` reverts and the relayer pays the rest back. That is a free, unilateral cancel option on a paid brief, exercisable until the filing lands. Verified in `test/scratch/Misc.t.sol`.\n- **Low, `src/Briefs.sol:628`.** `openCase` pins oracleId, reign share, cap, overtime, caseFee and the split, but not panelSize, quorum, answerTimeout, the jury reserve (`maxOracleFee`) or the court's `maxBrief` when the creator passes 0. An owner `setParams` landing first gives the creator a 2-of-3 panel, a 2-hour window, a 4.9 IMD reserve on a 5 IMD fee or a 100-byte limit silently. Verified in the same scratch file.\n\n**Checked and found sound**, with evidence where it was cheap to get: the solvency invariant across every flow (fees in escrow, split at verdict, mistrial and skip refunds, settlement, held payments); the time-in-the-lead telescoping sum (every leader's seconds add up to `reignTotal`, `claimReign` cannot exceed `reignPool`, overtime earns no time, overtime filings feed only the final leader, queue stuffing moves nobody's time); relay signatures (chain, court, author, deadline and words in the message, high-s rejected, filed-or-refunded exclusivity); the oracle path (questionHash rebuilt per hearing with briefId, delivery bound to the Intake's slot and the full attestation, window, panel, jury-domain EIP-712); liveness (no state where `mistrial`, `skipStalled` or `hear` can all be blocked); text rules (JSON cannot be broken by checked text, the longest English question is 1,900 bytes); and the hearingGas 64/63 rule (the requester receives the same gas at the minimal passing call as with 7M sent).\n\n**Not done**: no fork or live-attestation check of IMD's canonical hash, and the creator playing in their own case to earn reign time was judged design, not a defect.","treeHash":null,"usage":{"cachedInputTokens":3050284,"inputTokens":674,"model":"claude-fable-5-1","outputTokens":67094,"runtime":"claude","turns":32,"wallClockMs":967597}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"47f3603854a893a3","findings":[],"hash":"f94928cfd9c6bff117fbdc80099521e9de5ed6e639cb0cb21f57f94670523e08","nodeId":"73e251c2-2e73-46ef-b71b-d165c02c48fb","outcome":"failed","summary":"Credit balance is too low","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":null,"outputTokens":0,"runtime":"claude","turns":1,"wallClockMs":2385}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fe5e46fb044c6af1","findings":[{"citation":"resolved","description":"Overtime exists so that a new leader late in a case gives everyone `overtime` seconds to answer (README: 'overtime keeps a hot case open'; the Overtime event: 'a new leader late in the case pushed the deadline'). _newLeader only pushes the deadline when the VERDICT's block.timestamp t is before c.endsAt. Every hearing takes time (IMD's panel answers in about a minute; answerTimeout is 3 min to 2 h; a mistrial ahead in the FIFO docket takes answerTimeout + 2 min), so a brief filed inside the last seconds, or one queued behind a junk brief its author filed first, is routinely judged after endsAt. For such a lead no Overtime fires, entries stay closed, and because the docket is then empty _maybeSettle pays the pot to that author in the same fulfill. The outcome is inverted from the intent: a challenger judged at end - 4 min hands everyone an extension and can be beaten; one filed at end - 1 s (or behind one junk brief) takes the final share with no possible reply. The same holds inside overtime, which degenerates to 'whoever is judged last'. The existing test test_Reign_ALeaderHeardAfterTheDeadlineTakesTheFinalShareAndTimeFromItsFiling asserts this behaviour and README line 34 states the rule as 'before the deadline', so this is a design gap rather than a coding slip, but it defeats the anti-sniping purpose v2 added overtime for. Merged from four specialists (permissions, economics, flow, math). Fix (verified: the attached proof passes with it and reign accounting is untouched, since reignEnd and overtimePot key on filedAt): decide overtime from the filing time: `if (t < end || filedAt + c.overtime > end)`, keeping `later = t + c.overtime` capped at finalEnd. Entries then reopen for `overtime` seconds after a late verdict; a brief filed long before the deadline but heard after it still does not extend. If the current rule is kept on purpose, the README, site and the eighth review should say that a filing inside the jury's latency defeats overtime.","line":758,"path":"src/Briefs.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.30;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {Briefs} from \"src/Briefs.sol\";\nimport {BriefsText} from \"src/BriefsText.sol\";\nimport {BriefsJury} from \"src/BriefsJury.sol\";\nimport {ImdOracle} from \"src/ImdOracle.sol\";\nimport {IImdRequester} from \"src/interfaces/IImdRequester.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\n\ncontract MockERC20 is ERC20 {\n    constructor(string memory n, string memory s, address to) ERC20(n, s) {\n        _mint(to, 1_000_000_000 ether);\n    }\n}\n\n/// a stand-in for IMD's requester: takes the fee, returns a fresh id, no on-chain answer source\ncontract MockRequester is IImdRequester {\n    IERC20 public immutable imd;\n    uint256 internal fee_;\n    uint256 public count;\n\n    constructor(IERC20 imd_, uint256 fee__) {\n        imd = imd_;\n        fee_ = fee__;\n    }\n\n    function answerSource() external pure returns (address) {\n        return address(0);\n    }\n\n    function fee() external view returns (uint256) {\n        return fee_;\n    }\n\n    function request(string calldata, address) external returns (bytes32 requestId) {\n        imd.transferFrom(msg.sender, address(this), fee_);\n        requestId = bytes32(uint256(keccak256(abi.encode(address(this), ++count))) << 128);\n    }\n}\n\ncontract Digester {\n    function digest(bytes32 domain, ImdOracle.AttestationV2 calldata a) external pure returns (bytes32) {\n        return ImdOracle.digestV2(domain, a);\n    }\n}\n\n/// Overtime is meant to keep a hot case open: \"a new leader in the last `overtime` seconds pushes the deadline\".\n/// But _newLeader only extends when the VERDICT lands before endsAt. A brief filed in the last seconds (or behind a\n/// queue) is judged after the deadline, takes the lead, and the case settles at once: the latest sniper can't be\n/// answered, while an honest challenger judged a few minutes earlier hands everyone else an extension.\ncontract OvertimeSnipeTest is Test {\n    Digester digester = new Digester();\n    MockERC20 imd;\n    MockRequester requester;\n    Briefs b;\n    BriefsJury jury;\n    uint256 key = 0xB21EF;\n    bytes32 domain;\n\n    address creator = makeAddr(\"creator\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address carol = makeAddr(\"carol\");\n    address treasury = makeAddr(\"treasury\");\n\n    function setUp() public {\n        vm.warp(1_790_800_000);\n        imd = new MockERC20(\"IMD\", \"IMD\", address(this));\n        requester = new MockRequester(IERC20(address(imd)), 0.5 ether);\n        domain = ImdOracle.domainSeparatorV(\"2\", 1, address(0));\n        jury = new BriefsJury(\n            BriefsJury.Oracle({signer: vm.addr(key), requester: requester, domain: domain, chainId: 1, hearingGas: 3_000_000}),\n            address(this),\n            address(0)\n        );\n        b = new Briefs(IERC20(address(imd)), new BriefsText(), jury, treasury, _params());\n        address[4] memory users = [creator, alice, bob, carol];\n        for (uint256 i; i < users.length; i++) {\n            imd.transfer(users[i], 1_000_000 ether);\n            vm.prank(users[i]);\n            imd.approve(address(b), type(uint256).max);\n        }\n    }\n\n    function _params() internal pure returns (Briefs.Params memory) {\n        return Briefs.Params({\n            minSeed: 10 ether,\n            minFee: 1 ether,\n            maxOracleFee: 0.9 ether,\n            creatorBps: 1_500,\n            platformBps: 500,\n            panelSize: 11,\n            quorum: 6,\n            answerTimeout: 4 minutes,\n            caseFee: 2 ether,\n            minDuration: 10 minutes,\n            maxDuration: 90 days,\n            maxBrief: 500,\n            reignBps: 6_000,\n            overtime: 10 minutes,\n            finalCap: 0,\n            creatorSetsReign: false,\n            creatorSetsCap: false\n        });\n    }\n\n    function _case() internal returns (uint256) {\n        Briefs.CaseInput memory x = Briefs.CaseInput({\n            title: \"Dragon Jokes\",\n            task: \"Write the funniest joke about dragons.\",\n            standard: \"The funnier brief wins.\",\n            opening: \"Dragons never use banks. Too many firewalls.\",\n            avatar: 3,\n            seed: 500 ether,\n            fee: 5 ether,\n            endsAt: uint64(block.timestamp + 1 days),\n            minHold: 0,\n            oracleId: 0,\n            reignBps: 6_000,\n            finalCap: 0,\n            overtime: 10 minutes,\n            lang: 0,\n            maxBrief: 0,\n            caseFee: 2 ether,\n            creatorBps: 1_500,\n            platformBps: 500\n        });\n        vm.prank(creator);\n        return b.openCase(x);\n    }\n\n    function _fileAt(uint256 c, address who, string memory words, uint256 t) internal returns (uint256) {\n        vm.warp(t);\n        vm.prank(who);\n        return b.fileBrief(c, words);\n    }\n\n    function _verdictAt(uint256 briefId, bool better, uint256 t) internal {\n        vm.warp(t);\n        Briefs.Brief memory br = b.getBrief(briefId);\n        ImdOracle.AttestationV2 memory a = ImdOracle.AttestationV2({\n            requestId: br.requestId,\n            chainId: 1,\n            questionHash: jury.questionHashOf(briefId, 1, 2),\n            answerType: 0,\n            answer: abi.encode(better),\n            figure: 0,\n            fromBlock: 1,\n            toBlock: 2,\n            blockHash: keccak256(\"b\"),\n            panelJobId: keccak256(\"p\"),\n            panelSize: 11,\n            quorum: 6,\n            agreed: 6,\n            issuedAt: br.heardAt + 30,\n            expiresAt: uint64(block.timestamp + 1 days)\n        });\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, digester.digest(domain, a));\n        b.fulfill(briefId, a, abi.encodePacked(r, s, v));\n    }\n\n    /// bob files inside the last `overtime` seconds, so by the stated rule his lead must push the deadline; the\n    /// verdict lands a minute after the deadline (every hearing takes time), so no overtime: the case settles at once\n    /// and carol, who was ready to answer, is shut out.\n    function test_ALeadTakenByAVerdictAfterTheDeadlineStillOpensOvertime() public {\n        uint256 c = _case();\n        uint256 end = b.getCase(c).endsAt;\n        uint256 a1 = _fileAt(c, alice, \"Alice leads from eleven minutes out.\", end - 11 minutes);\n        _verdictAt(a1, true, end - 11 minutes); // alice leads; not in the last 10 minutes, no overtime\n        assertEq(b.getCase(c).endsAt, end);\n\n        // bob snipes: filed 1 second before the deadline, inside the last `overtime` seconds\n        uint256 b1 = _fileAt(c, bob, \"Bob snipes one second before the bell.\", end - 1);\n        _verdictAt(b1, true, end + 1 minutes); // the jury answers after the deadline\n\n        // expected: a new leader in the last `overtime` seconds pushes the deadline, so others can answer\n        Briefs.Case memory k = b.getCase(c);\n        assertGt(k.endsAt, end, \"overtime must open for a lead taken in the last overtime seconds\");\n        assertEq(uint8(k.status), uint8(Briefs.CaseStatus.Open), \"the case must stay open for an answer\");\n        vm.warp(k.endsAt - 1);\n        vm.prank(carol);\n        b.fileBrief(c, \"Carol answers the sniper in overtime.\");\n    }\n}","reproduction":"Params: reignBps 6000, overtime 10 min, finalCap 0, answerTimeout 4 min; case with endsAt = now + 1 day (= end), seed 500, fee 5. alice files at end - 11 min and is COOKED at end - 11 min (no overtime, as expected). bob files at end - 1 s (inside the last `overtime` seconds); the hearing opens at once; the COOKED attestation (issuedAt = heardAt + 30) is delivered with fulfill at end + 1 min. Expected (stated purpose): endsAt pushed to about end + 11 min, status Open, carol can file an answer. Actual: getCase(c).endsAt == end, no Overtime event, status == Settled in that same fulfill, winner == bob; carol's fileBrief reverts EntriesClosed/NotOpen. Variant: bob files two junk briefs at end - 5 min and the real one behind them; the junk ones are Sustained at end - 3 min and end - 1 min, the real verdict lands at end + 30 s: same result. test/scratch/OvertimeSnipe.t.sol (the proof) fails on c29c658 with 'overtime must open for a lead taken in the last overtime seconds: 1790886400 <= 1790886400' and passes once _newLeader's trigger includes `filedAt + c.overtime > end`.","severity":"medium","snippet":"        if (t < end) {","title":"Overtime keys on the verdict's time, so a lead taken by a verdict landing at or after endsAt never opens overtime: a last-second filing wins and the case settles in the same call"},{"citation":"resolved","description":"The eighth review's fix 5 only rejects amount == 0. Any amount >= 1 wei of IMD, paid by the caller to the author, sets relayed[h] = 2, after which _relayHash reverts BadSignature for useRelay and so Briefs.fileBriefFor can never file that message. One wei is free in practice, and the author holds their own signature, so after paying the relayer on Ethereum they can call refundRelay(..., 1) to themselves and the relayer's filing fails; the relayer then reads `refunded` (1 wei) and pays the rest back, as the relay design documents ('the relayer only pays what that record says is still owed'). Net effect: a free, unilateral cancel option on a brief sold as final, exercisable until the relayer's fileBriefFor lands (after seeing a stronger brief land, say). Third parties get the same power whenever a signature becomes visible (the calldata of a fileBriefFor that reverted with OracleTooExpensive, NotHolder or EntriesClosed, or a relayer leak): a 1-wei refund voids the author's brief and leaves the relayer to refund the balance. The comment's guarantee does not hold. Merged from four specialists. Fix: require the first refund (the one flipping state 0 -> 2) to pay a floor the author can rely on (the case's entry fee read from the court, or an owner-set minimum), or restrict that first refund to the author or a relayer role while anyone may add to an open refund.","line":315,"path":"src/BriefsJury.sol","reproduction":"Case c open (fee 5 IMD). dan signs relayMessage(c, words, dan, deadline) with personal_sign. carol (any holder of the signature, 1 wei of IMD approved to the jury) calls jury.refundRelay(c, words, dan, deadline, sig, 1). Expected: cancelling a signed brief costs a real refund. Actual: the call succeeds, relayed(h) == 2, dan received 1 wei, and the relayer's b.fileBriefFor(c, words, dan, deadline, sig) reverts BadSignature. dan can also call refundRelay on his own second signed brief with amount 1 (1 wei to himself, net cost zero) and that filing reverts BadSignature too. test/scratch/Misc.t.sol::test_OneWeiRefundCancelsASignedBrief passes on c29c658.","severity":"low","snippet":"        if (amount == 0) revert NothingToRefund(); // a refund pays something: nobody cancels a signed brief for free","title":"refundRelay: a 1-wei refund marks a signed brief refunded, so the author or anyone holding the signature cancels a paid filing for free"},{"citation":"resolved","description":"The stated rule is that openCase names the settings the creator saw and reverts SettingsChanged otherwise ('a setting the creator can't pick must be the one they saw: the owner may have changed it since', line 635). CaseInput carries oracleId, reignBps, finalCap, overtime, caseFee, creatorBps and platformBps, but the case also fixes for life c.panelSize, c.quorum, c.answerTimeout (lines 628-630), c.reserve = p.maxOracleFee (line 634) and, when x.maxBrief is 0, the court's params.maxBrief passed to checkCase (line 334). None of these is compared with what the creator saw, so a setParams landing between the creator reading the settings and openCase executing (the owner is a multisig, whose transactions are visible in advance, but openCase cannot refuse them) opens the case with a different jury (3-of-2 instead of 11-of-6), a 2-hour mistrial window instead of 4 minutes (each unanswered hearing then holds the docket 2 h), a reserve that lets the jury take up to 4.9 of a 5 IMD fee (the creator's and the pot's cut per brief shrink when IMD's price rises), or a brief limit the creator never saw, with no revert and no way to move the case. Same class as the fixed F-5 (caseFee) and the overtime pin. The owner is trusted, so this is a consistency gap in the SettingsChanged guarantee, not a bypass. Merged from four specialists. Fix: add panelSize, quorum, answerTimeout and maxOracleFee (and the court's maxBrief when x.maxBrief == 0, or require maxBrief != 0) to CaseInput and compare them in _newCase like the others; Briefs is 54 bytes under the size limit, so the comparison may need to live in BriefsText.checkCase's packed rules word or a jury view.","line":628,"path":"src/Briefs.sol","reproduction":"Settings at page load: panelSize 11, quorum 6, answerTimeout 4 min, minFee 1, maxOracleFee 0.9, maxBrief 500. Owner calls setParams with panelSize 3, quorum 2, answerTimeout 2 hours, minFee 5 ether, maxOracleFee 4.9 ether, maxBrief 100 (all within _setParams bounds). The creator's openCase with the CaseInput built from the old settings (fee 5 ether, maxBrief 0, oracleId/reignBps/finalCap/overtime/caseFee/split unchanged) is then mined. Expected: revert SettingsChanged. Actual: the case opens; getCase(id) shows panelSize 3, quorum 2, answerTimeout 7200, reserve 4.9e18, maxBrief 100. test/scratch/Misc.t.sol::test_UnpinnedSettingsApplySilently passes on c29c658.","severity":"low","snippet":"        c.panelSize = p.panelSize;\n        c.quorum = p.quorum;\n        c.answerTimeout = p.answerTimeout;","title":"openCase pins only part of the settings: panelSize, quorum, answerTimeout, the jury reserve (maxOracleFee) and the court's maxBrief can change under a pending openCase without SettingsChanged"},{"citation":"resolved","description":"_check admits hearingGas down to 200,000, while Briefs._hearNext's own comment (line 688-689) says openHearing as a whole costs 'about 265k on IMD's Intake' (two forceApproves, the Intake's request with a 1.9 KB body and the bookkeeping). With a setup proposed at the floor, this.openHearing{gas: hearingGas} runs out of gas on every brief: each filing is reported HearingStalled, the brief stays at the head of the docket, and STALL_WAIT (6 h) later anyone, the sitting leader first, calls skipStalled to hand each challenger back unheard. The precedent then wins the whole pot by default. This is an owner mistake that the 7-day proposal delay makes visible, so it is a trust assumption rather than a bypass, but the floor should not admit a value the code itself documents as insufficient. Merged from the economics specialist. Fix: raise the floor to the measured worst case with a margin (say 400k), or document that the floor is a placeholder and the deploy script measures the real requester.","line":400,"path":"src/BriefsJury.sol","reproduction":"jury.proposeOracle(Oracle{signer, requester, domain, chainId 1, hearingGas: 200_000}) succeeds; applyOracle after 7 days; a case opened on oracleId 1 with a requester whose request() costs about 230k gas (standing in for the Intake's 265k). alice's fileBrief: the fee is taken, openHearing reverts out of gas, HearingStalled is emitted, getCase(c).hearing == 0 and stalledSince > 0. 6 hours later skipStalled(c) marks the brief Unheard and refunds it. Expected: _check refuses a hearingGas that cannot open a hearing. test/scratch/Misc.t.sol::test_HearingGasFloorBelowTheIntakesCost passes on c29c658.","severity":"info","snippet":"            o.signer == address(0) || address(o.requester) == address(0) || o.chainId == 0 || o.hearingGas < 200_000","title":"BriefsJury._check accepts a hearingGas (200k) below what the code says opening a hearing on IMD's Intake costs (about 265k): a setup at the floor stalls every hearing and lets the leader win by skipSt"},{"citation":"resolved","description":"The design says the opening brief earns no time in the lead and that the time before the first COOKED belongs to nobody (the pool is scaled by total / (reignEnd - createdAt)). The creator writes a weak opening brief and, from any other wallet, a marginally better challenger filed one second after openCase; the jury must call it better, so it takes the lead at createdAt + 1, firstReign is set there, the pool is the full reignBps of the pot less overtimePot, and that wallet is paid for every second until the first honest leader's filing. The books still balance (the exact invariant holds) and nothing can be enforced on chain (any address could file first), so this is an economic note for the docs and the site: a case creator who plays is an ordinary player, and the 'opening brief earns none' rule binds only the single opening entry, not its author. The same trick also lets the creator earn creatorBps back on its own fee. Merged from the economics specialist.","line":755,"path":"src/Briefs.sol","reproduction":"Params reignBps 6000; case opened at T with endsAt T + 1 day, seed 500e18, fee 5e18. Wallet `sock` files at T + 1 and its COOKED verdict lands at T + 60; alice files at T + 20 h and is COOKED at T + 20 h; settle at T + 1 d. Actual: reignOf(c, sock) == 20h - 1 s, reignTotal == 1 d - 1 s, claimReign(c, sock) pays 253_596_477_777_777_777_777 wei of a 304_316_477_777_777_777_777 wei pool (console output of test/scratch/Misc.t.sol::test_CreatorSockPuppetEarnsThePreCompetitionTime, which passes on c29c658). Expected per the stated rule: the creator's side earns nothing for the time before the first outside challenger.","severity":"info","snippet":"            if (c.firstReign == 0) c.firstReign = uint64(from);","title":"'The opening brief earns no time' is void for a creator with a second wallet: a self-challenger filed one second after opening is paid for the whole pre-competition period"}],"hash":"ff120e8b6955fb0525ccc3c73184d65399084953949c081978b93a8466bfb354","nodeId":"671be4a4-02ca-4900-9a19-8437227dc2de","outcome":"completed","summary":"The review is complete. `.imd-findings.json` holds five findings, all reproduced against commit c29c658; the source tree is unchanged (only the ignored `test/scratch/` and the findings file were written).\n\n**What I kept, merged across the four specialists**\n\n- **Medium, overtime bypass** (`src/Briefs.sol:758`). The deadline push keys on the verdict's timestamp, not the filing time, so a brief filed in the last seconds, or queued behind a junk brief, is judged after the bell, takes the lead, and the case settles in the same `fulfill` with nobody able to answer. This is the one scenario overtime was added for. The README states the rule as \"before the deadline\" and the suite asserts it, so it is a design gap rather than a slip. The permissions specialist's proof fails on the current code and passes once the trigger also accepts a brief filed within the last `overtime` seconds; I verified that on a temporary patch and restored the file. The math specialist's proof asserts the winner before settlement, so it fails even on the fix and was dropped.\n- **Low, 1-wei refundRelay** (`src/BriefsJury.sol:315`). Any amount above zero marks a signed message refunded forever. The author, or anyone who saw the signature in reverted calldata, cancels a paid brief for 1 wei, leaving the relayer to refund the balance. Reproduced in a scratch test.\n- **Low, unpinned settings** (`src/Briefs.sol:628`). Panel, quorum, answer window, jury reserve and the court's brief limit are copied from storage without a `SettingsChanged` comparison. A `setParams` landing first silently opens the case with a 3-of-2 panel, a 2-hour window and a 4.9 IMD reserve. Reproduced.\n- **Info, hearingGas floor** (`src/BriefsJury.sol:400`). The jury accepts 200k while the code itself documents about 265k for the Intake. Reproduced with a requester costing 230k: every hearing stalls and `skipStalled` refunds challengers unheard.\n- **Info, creator sock puppet** (`src/Briefs.sol:755`). A second wallet filed one second after opening earns the whole pre-competition time. The books balance, so this is a documentation note.\n\n**My own pass** over solvency, reign accounting, the oracle path, relay replay and malleability, stall and mistrial timing, and the text rules found nothing beyond what the specialists reported. Seconds telescope to `reignTotal` on every path, `claimReign` cannot exceed `reignPool`, the relay message parses uniquely, and the longest English question stays under the 2,000 limit per the existing test.","treeHash":null,"usage":{"cachedInputTokens":1082740,"inputTokens":290,"model":"claude-fable-5-1","outputTokens":29213,"runtime":"claude","turns":20,"wallClockMs":394549}}],"verification":[]}