# Audit report

> Audit Briefs, BriefsJury, BriefsText, ImdGatewayRequester and ImdOracle, live on Robinhood Chain. Briefs holds every IMD (seed pots, escrowed entry fees, creator earnings, the platform share): 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), or to stall a case so it can never settle. Then 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). Then griefing by the current leader (mistrial timing, skipStalled and the stall clock, queue spam), admin powers and their bounds, gas (hearingGas and the 64/63 rule), and the text rules in BriefsText.check (the question must stay valid JSON and under 2,000 characters). docs/audit-internal-2026-10.md lists what our own reviews found and fixed; test/audit holds the PoCs

| | |
|---|---|
| Repository | https://github.com/vadiszzz/briefs-contracts.git |
| Commit | `54a47f75be3999778b96ab95b88dd6d3fbb1c1ce` |
| Job | `8e03ebfb-e610-4f68-8fe5-71ab8251a8d2` |
| Judged | 2026-10-08 21:44 UTC |
| Findings | 1 medium · 4 low · 3 info |

Four agents audited the code as it is at `54a47f7`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Medium: Push payments on the live IMD token (which has an owner block list): one blocked address locks a whole case and its pot

`src/Briefs.sol:676`

```
        imd.safeTransfer(winner, prize);
```

docs/audit-internal-2026-10.md accepts push payments on the premise that "Standard bridged IMD can't" blacklist. That premise does not hold for the token Briefs is deployed on. The live IMD token on Robinhood Chain (0x5F7Bb59365ce557C26dbcAa4EE9d39A4b95B7127) is a LayerZero OFT ("BridgedFP") with a public blocked(address) view, a transfersEnabled() switch and an owner (0x047F606fD5b2BaA5f5C6c4aB8958E45CB6B054B7, the same address that owns IMD's Intake). On a fork of the live chain, setting blocked[x] (mapping at storage slot 13) makes transfer(x, 1) from the live Briefs revert with "BridgedFP: blocked", and clearing it makes the same transfer succeed. Briefs pays by push inside the functions that advance the docket: the pot in _maybeSettle (line 676, reached from fulfill, mistrial, skipStalled and settle), the mistrial refund in mistrial (line 442) and the skip refund in _skip (line 596, reached from _hearNext and skipStalled). If the standing leader is blocked while the final verdict is SUSTAINED (or a mistrial), every path that could close the case reverts and the pot (seed plus every entrant's 80% share) is locked for everyone, not only for the blocked address. If a challenger is blocked during their hearing, mistrial reverts and every brief behind them is queued forever. If a queued author is blocked, the price-rise skip in _hearNext reverts, so even the verdict of the brief being heard cannot land. There is no admin rescue and no alternative path, and the blocking actor is a third party (a compliance action, not necessarily an attack). This merges the audit_permissions finding with the existing test_Known_L_Blacklisted* PoCs in test/audit/Liveness.t.sol, which show the same mechanism with a stand-in token. Fix that keeps the design: credit the pot, the mistrial refund and the skip refund to a per-address claimable balance (or try the push and fall back to crediting on failure) so that no payment can stop the docket or settlement, and let the payee pull. The solvency invariant then counts the claimable balances.

**Reproduction**

Reproduced on this tree with test/scratch (the attached proof): a stand-in ERC20 whose _update reverts "BridgedFP: blocked" for a blocked from/to, deployed params (fee 5 IMD, seed 100 IMD, maxOracleFee 0.9, hearingGas 3M). Scenario 1: creator opens case c; alice files a1 and is OVERRULED (leads); bob files b1; warp to endsAt; token owner blocks alice; call fulfill(b1, SUSTAINED attestation, sig). Expected: b1 Sustained, case Settled, winner alice. Actual: revert "BridgedFP: blocked" in _maybeSettle; mistrial(c) after the timeout also reverts; settle(c) reverts TooEarly; the pot of 100 + 3.6 IMD and bob's escrow stay locked. Scenario 2: bob files b1 (hearing), alice files a1 (queued), bob is blocked, warp heardAt + 4 min + 2 min + 1 s, call mistrial(c). Expected: b1 Mistrial and a1's hearing opens. Actual: revert "BridgedFP: blocked" on bob's refund; a1 is queued forever. Live-token evidence: on a fork of Robinhood Chain, vm.store(token, keccak256(abi.encode(victim, 13)), 1) makes blocked(victim) == true and transfer(victim, 1) from 0x85737D04BDe718f42F90e31564540408CBbe6E4B revert with 0x08c379a0... "BridgedFP: blocked"; clearing the slot makes it succeed. cast calls on the live token: symbol() "IMD", owner() 0x047F606fD5b2BaA5f5C6c4aB8958E45CB6B054B7, transfersEnabled() true, blocked(0x...01) false.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

import {Test} from "forge-std/Test.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Briefs} from "src/Briefs.sol";
import {BriefsText} from "src/BriefsText.sol";
import {BriefsJury} from "src/BriefsJury.sol";
import {ImdOracle} from "src/ImdOracle.sol";
import {IImdRequester} from "src/interfaces/IImdRequester.sol";

/// The IMD token live on Robinhood Chain (0x5F7Bb59365ce557C26dbcAa4EE9d39A4b95B7127) is a LayerZero OFT with a
/// block list: blocked(address) is public, and a transfer to or from a blocked address reverts "BridgedFP: blocked".
/// This stand-in does the same.
contract BlockableIMD is ERC20 {
    mapping(address => bool) public blocked;

    constructor(address to) ERC20("IMD", "IMD") {
        _mint(to, 1_000_000 ether);
    }

    function setBlocked(address a, bool v) external {
        blocked[a] = v;
    }

    function _update(address from, address to, uint256 value) internal override {
        require(!blocked[from] && !blocked[to], "BridgedFP: blocked");
        super._update(from, to, value);
    }
}

contract SimpleRequester is IImdRequester {
    IERC20 immutable imd;
    uint256 n;

    constructor(IERC20 imd_) {
        imd = imd_;
    }

    function fee() external pure returns (uint256) {
        return 0.5 ether;
    }

    function answerSource() external pure returns (address) {
        return address(0);
    }

    function request(string calldata, address) external returns (bytes32) {
        imd.transferFrom(msg.sender, address(this), 0.5 ether);
        return bytes32(uint256(keccak256(abi.encode(address(this), ++n))) << 128);
    }
}

contract Digest {
    function digest(bytes32 domain, ImdOracle.AttestationV2 calldata a) external pure returns (bytes32) {
        return ImdOracle.digestV2(domain, a);
    }
}

/// Fails on the current code: a single blocked address (the leader, or a challenger owed a refund) freezes the whole
/// case because the pot, the mistrial refund and the skip refund are pushed with safeTransfer inside fulfill,
/// mistrial, skipStalled and settle. Passes once those payments are credited and pulled (or otherwise never block
/// the docket).
contract BlockedPushPaymentsTest is Test {
    BlockableIMD imd;
    SimpleRequester requester;
    BriefsJury jury;
    Briefs b;
    Digest dg = new Digest();
    uint256 key = 0xB21EF;
    bytes32 domain = ImdOracle.domainSeparatorV("2", 1, address(0));
    address creator = makeAddr("creator");
    address alice = makeAddr("alice");
    address bob = makeAddr("bob");

    function setUp() public {
        vm.warp(1_790_800_000);
        imd = new BlockableIMD(address(this));
        requester = new SimpleRequester(IERC20(address(imd)));
        jury = new BriefsJury(
            BriefsJury.Oracle({signer: vm.addr(key), requester: requester, domain: domain, chainId: 1, hearingGas: 3_000_000}),
            address(this),
            address(0)
        );
        b = new Briefs(
            IERC20(address(imd)),
            new BriefsText(),
            jury,
            makeAddr("treasury"),
            Briefs.Params({
                minSeed: 10 ether, minFee: 1 ether, maxOracleFee: 0.9 ether, creatorBps: 1_500, platformBps: 500, panelSize: 11, quorum: 6,
                answerTimeout: 4 minutes, caseFee: 2 ether, minDuration: 10 minutes, maxDuration: 90 days, maxBrief: 500
            })
        );
        address[3] memory us = [creator, alice, bob];
        for (uint256 i; i < 3; i++) {
            imd.transfer(us[i], 10_000 ether);
            vm.prank(us[i]);
            imd.approve(address(b), type(uint256).max);
        }
    }

    function _case() internal returns (uint256) {
        vm.prank(creator);
        return b.openCase(
            Briefs.CaseInput({
                title: "Dragon Jokes", task: "Write the funniest joke about dragons.", standard: "The funnier brief wins.",
                opening: "Dragons never use banks.", avatar: 1, seed: 100 ether, fee: 5 ether, endsAt: uint64(block.timestamp + 1 days),
                minHold: 0, oracleId: 0
            })
        );
    }

    function _answer(uint256 briefId, bool better) internal view returns (ImdOracle.AttestationV2 memory a, bytes memory sig) {
        a = ImdOracle.AttestationV2({
            requestId: b.getBrief(briefId).requestId, chainId: 1, questionHash: jury.questionHashOf(briefId, 1, 2), answerType: 0,
            answer: abi.encode(better), figure: 0, fromBlock: 1, toBlock: 2, blockHash: keccak256("b"), panelJobId: keccak256("p"),
            panelSize: 11, quorum: 6, agreed: 6, issuedAt: b.getBrief(briefId).heardAt + 30, expiresAt: uint64(block.timestamp + 1 days)
        });
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, dg.digest(domain, a));
        sig = abi.encodePacked(r, s, v);
    }

    /// the leader is blocked by the token after taking the lead: the last verdict (SUSTAINED) can never land, a
    /// mistrial can't end the hearing either, and the pot (100 IMD seed + bob's share) is locked for everyone
    function test_ABlockedLeaderMustNotFreezeTheCase() public {
        uint256 c = _case();
        vm.prank(alice);
        uint256 a1 = b.fileBrief(c, "A dragon walked into a bar. The bar is now a barbecue.");
        vm.warp(block.timestamp + 1 minutes);
        (ImdOracle.AttestationV2 memory a, bytes memory sig) = _answer(a1, true);
        b.fulfill(a1, a, sig); // alice leads
        vm.prank(bob);
        uint256 b1 = b.fileBrief(c, "My dragon asked for a raise.");
        vm.warp(b.getCase(c).endsAt); // entries closed: b1 is the final hearing
        imd.setBlocked(alice, true); // the token's owner blocks the leader (compliance, a mistake, anything)
        (a, sig) = _answer(b1, false);
        b.fulfill(b1, a, sig); // must land: the verdict is bob's, not alice's payment
        assertEq(uint8(b.getBrief(b1).status), uint8(Briefs.BriefStatus.Sustained));
        assertEq(uint8(b.getCase(c).status), uint8(Briefs.CaseStatus.Settled), "the case must still close");
        assertEq(b.getCase(c).winner, alice);
    }

    /// a challenger owed a mistrial refund is blocked: the hearing can't be ended, so every brief behind it waits forever
    function test_ABlockedChallengerMustNotFreezeTheDocket() public {
        uint256 c = _case();
        vm.prank(bob);
        uint256 b1 = b.fileBrief(c, "My dragon asked for a raise.");
        vm.prank(alice);
        uint256 a1 = b.fileBrief(c, "A dragon walked into a bar."); // queued behind bob
        imd.setBlocked(bob, true);
        vm.warp(block.timestamp + 4 minutes + 2 minutes + 1); // no answer: a mistrial is due
        b.mistrial(c); // must go through: bob's refund must not hold alice's hearing hostage
        assertEq(uint8(b.getBrief(b1).status), uint8(Briefs.BriefStatus.Mistrial));
        assertEq(b.getCase(c).hearing, a1, "the docket moves on");
    }
}
```

### 2. Low: IMD pricing the action above a case's fixed reserve refuses every new entry for the rest of the case, so the pot settles to whoever leads at that moment

`src/Briefs.sol:366`

```
            if (quoted && price > reserve) revert OracleTooExpensive();
```

Each case fixes its jury reserve at creation (c.reserve = params.maxOracleFee, 0.9 IMD on the live deployment) and nothing can raise it or extend endsAt afterwards. The reserve protects escrowed fees (second review, fix 4), but it reintroduces the outcome the first internal review rated Medium (fix 1: "the owner could pause entries while a case's deadline kept running, locking in whoever led") with IMD's own price as the trigger instead of the owner. The live Intake price is 0.5 IMD (priceOf read on chain). If IMD reprices the action above 0.9 IMD, then for every open case with nothing being heard fileBrief reverts OracleTooExpensive at this line, every brief already queued is handed back Unheard by the next hear()/skip, and when endsAt passes settle() pays 100% of the pot to the standing precedent. The leader needs to do nothing and cannot be challenged; the creator, the owner (setParams reaches new cases only) and the players have no lever. On a 90-day case this freezes the contest for up to three months and then pays the leader. Minimal fix that keeps the escrow guarantee: let anyone top up a case's jury budget from their own funds (never the pot) so hearings can pay the overshoot, or, while entries are refused for price, extend endsAt by the refused time so the contest resumes when the price falls. At minimum, document that a price rise above maxOracleFee ends the contest in the leader's favour.

**Reproduction**

Reproduced in test/scratch/IntakeProbe.t.sol test_IntakePriceLockIn (MockIntake + ImdGatewayRequester, deployed params) and test/scratch/Probe.t.sol test_PriceLockIn (MockRequester). Steps: creator opens a 30-day case (seed 100 IMD, fee 2 IMD); alice files, IMD answers "better" through the Intake and fulfill lands it (alice leads); the Intake price becomes 1 IMD (> 0.9 reserve); bob calls fileBrief(c, ...). Expected: bob's brief is queued or heard. Actual: revert OracleTooExpensive (0x...), and the same for every address until endsAt; owner setParams(maxOracleFee 0.99) changes nothing for the running case. At endsAt settle(c) pays alice the 100 IMD seed plus 1.2 IMD (80% of her own 2 - 0.5 fee), assertEq(winner, alice) passes.

### 3. Low: A verdict IMD delivered on chain is voided by a mistrial if nobody relays it within DELIVERED_GRACE, because the jury keeps only its hash

`src/Briefs.sol:437`

```
        uint256 grace = jury.wasDelivered(briefId) ? DELIVERED_GRACE : MISTRIAL_GRACE;
```

BriefsJury.onImdAnswer (src/BriefsJury.sol line 194) stores only keccak256(abi.encode(att)) and discards the attestation and signature the Intake delivered. Landing the verdict therefore needs an off-chain party to resubmit both through Briefs.fulfill within DELIVERED_GRACE (1 hour after the 4-minute answer window). After that anyone can call mistrial and the delivered verdict is void for good: the precedent stands, the author gets fee minus the jury's price back, the pot gets nothing, and fulfill reverts WrongStatus forever. One side always prefers the mistrial: the leader when the answer was "better", the challenger when it was not (a mistrial refund beats losing the whole fee to the split). The audit doc says a case finishes without the keeper, which is true, but the correct outcome does not: it depends on an interested party relaying within the hour. Storing the full attestation in the callback does not fit the Intake's 200,000 callbackGas (read on chain; about 15 extra slots would need about 300k). Minimal fix: make the delivered grace a parameter sized for real keeper outages (attestations are valid for 86,400 s), and have the site offer the one-click relay to the winning side; or store the few signed fields the Intake delivers and let anyone fulfil with only the signature.

**Reproduction**

Reproduced in test/scratch/IntakeProbe.t.sol test_DeliveredVerdictLostToMistrial (MockIntake modelled on the live Intake's bytecode, ImdGatewayRequester). State: creator opens a case; alice files brief id (hearing at heardAt); IMD's writer calls intake.complete(rid, 0, ..., abi.encode(rid, att{answer true, issuedAt heardAt + 100}, sig)) so jury.wasDelivered(id) == true. Input: at heardAt + 4 min + 1 h + 1 s the creator (the leader) calls mistrial(c). Expected: the answer IMD delivered on chain decides the hearing (alice becomes the precedent). Actual: mistrial succeeds, brief status == Mistrial, precedent stays 1 (the opening brief), and fulfill(id, att, sig) reverts WrongStatus from then on.

### 4. Low: BriefsText.check costs ~3.7k gas per ASCII character, so a max-length ASCII filing that opens a hearing needs ~5.6M gas, above the 4M the docs say the site sends

`src/BriefsText.sol:165`

```
    function _forbidden(uint256 cp) private pure returns (bool) {
```

check() evaluates _forbidden(cp), _isSpace (twice) and _isFiller for every code point. _forbidden is a chain of about 95 comparisons with no early exit, and none of them can match a code point below 0x80, so every plain ASCII character pays for the whole chain. Measured on this tree: check() on 600 ASCII bytes costs 2,197,716 gas (about 3.66k per character) against 716,771 for 150 four-byte characters (600 bytes), and 1,833,016 for 500 ASCII bytes. Briefs._hearNext then requires gasleft() >= hearingGas*64/63 + 60,000 (3,107,619 at the deployed hearingGas of 3,000,000) after the check, the brief storage and the request build, so a fileBrief that opens a hearing needs about 5,569,000 gas for a 500-byte ASCII brief (the deployed maxBrief) and about 4,335,000 for a 125-emoji brief. docs/audit-internal-2026-10.md (second review, fix 11) says the site and keeper send calls that may open a hearing with at least 4M gas; at that budget both filings revert OutOfGas() and the user pays for a failed transaction. openCase with all-max ASCII texts (48/240/160/500) costs 4,623,725 gas on its own. No funds are at risk; this is a liveness/UX cost that is the dominant term of the gas a filer must bring and that a wallet estimate following the cheaper (stalled) path will miss. Minimal fix: in _forbidden return false at once for cp < 0x80, and skip _isFiller/_isSpace for ASCII other than 0x20, which removes most of the per-character cost; then re-measure and raise the documented gas floor to what a max-length ASCII filing actually needs.

**Reproduction**

Reproduced in test/scratch/Probe.t.sol (test_CheckGas, test_FilingGasNeeded). Deployed params (maxBrief 500, hearingGas 3,000,000), MockRequester at 0.5 IMD, a case open, nothing being heard. Input: fileBrief(caseId, 500 x "a") from an approved account with exactly 4,000,000 gas (vm.cool on Briefs so storage is cold as in a real tx). Expected (per the docs): the brief is filed and its hearing opens. Actual: revert with selector 0x77ebef4d (Briefs.OutOfGas()) from _hearNext line 621. Binary search over the gas limit: the call first opens the hearing at about 5,569,457 gas for the ASCII brief and about 4,335,448 for the 125 x U+1F409 brief. Standalone: text.check(600 x "a", 1, 600, 600) consumes 2,197,716 gas; text.check(150 x U+1F409, 1, 600, 600) consumes 716,771.

### 5. Low: The answer window has no tolerance for clock skew: an attestation whose issuedAt is one second before its hearing's block timestamp can never land

`src/BriefsJury.sol:223`

```
        if (att.issuedAt < b.heardAt) revert AnsweredBeforeAsked();
```

verdict() compares IMD's off-chain signing clock (att.issuedAt) with the chain's block timestamp at openHearing (b.heardAt) with a strict less-than and zero slack. On an L2 the sequencer sets block.timestamp from its own clock (Arbitrum-style chains allow it to run ahead of wall time); the attester sets issuedAt from its own. If the sequencer's clock is ahead of IMD's by more than IMD's answer latency (43-63 s in the team's live probe), every answer for that hearing has issuedAt < heardAt and is refused for good, even though it was delivered by the Intake for this very requestId and carries this hearing's questionHash. The hearing then ends only by mistrial: because wasDelivered() is true that waits heardAt + answerTimeout + DELIVERED_GRACE (64 minutes at the deployed 4-minute timeout), the challenger loses the jury's price and the standing leader keeps the lead on a brief IMD may have judged better. The upper bound (issuedAt <= heardAt + answerTimeout) is a real liveness rule; the lower bound adds nothing on the production path, since the attestation must also be the one the Intake delivered for b.requestId, which cannot exist before the hearing opened. Minimal fix: allow a small skew, e.g. require att.issuedAt + SKEW >= b.heardAt with SKEW around MISTRIAL_GRACE (2 minutes), or drop the lower bound for setups with an on-chain answer source.

**Reproduction**

Reproduced in test/scratch/Probe.t.sol test_ClockSkew. Open a case, file a brief so its hearing opens at block timestamp T (b.heardAt == T). Build a valid attestation for that hearing (its requestId, questionHashOf(briefId, 1, 2), panel 11/6/6, signed by the setup's attester for the jury's domain) with issuedAt = T - 1 and expiresAt = now + 1 day. Call fulfill(briefId, att, sig) at T + 60. Expected: the verdict lands (the answer is for this hearing's question and request). Actual: revert BriefsJury.AnsweredBeforeAsked(). With issuedAt = T the same call succeeds and the brief is Overruled.

### 6. Info: BriefsText._forbidden misses invisible format characters, more look-alikes of the «» quote marks, Unicode noncharacters and private-use code points

`src/BriefsText.sol:166`

```
        return (cp >= 0x80 && cp <= 0x9f) || cp == 0xad || cp == 0x61c || cp == 0x180e || cp == 0xab || cp == 0xbb
```

Merged from audit_permissions (invisibles and look-alikes) and audit_flow (noncharacters and private use): the same function, the same kind of gap and one fix. check() documents that bidi and zero-width characters are refused and that text must reach IMD unchanged inside a JSON string; the internal reviews extended _forbidden three times on that basis (fixes 6, 7, 10 and swarm 5). The list still admits: (a) Unicode Default_Ignorable format characters that renderers draw as nothing: U+206A..U+206F (deprecated format controls), U+1BCA0..U+1BCA3 (shorthand format controls), U+FFF0..U+FFF8, and the unassigned parts of the tag and variation-selector planes U+E0080..U+E00FF and U+E01F0..U+E0FFF (only U+E0000..E007F and U+E0100..E01EF are blocked); (b) look-alikes of the guillemets the question quotes with: U+2AF7/U+2AF8, U+2991/U+2992, U+2995/U+2996, U+FE3F/U+FE40, and doubled Canadian syllabics U+1438/U+1433, which the "<<" rule does not catch since it pairs only U+003C and U+003E; (c) noncharacters (U+FFFE, U+FFFF, U+FDD0..U+FDEF and U+nFFFE/U+nFFFF on every plane; "not intended for interchange", often replaced by U+FFFD or rejected by sanitizers) and the private-use areas (U+E000..U+F8FF, planes 15 and 16). (a) and (b) are gaps against the stated rule with limited impact (the definitions tell the jury to ignore formatting tricks and the unassigned code points carry no readable text). (c) matters if IMD's canonicaliser replaces or strips any of them: the questionHash rebuilt on chain uses the raw bytes, so the signed questionHash could never match, every hearing whose question contains that text would end in a mistrial and the standing precedent would keep the lead; the author of the opening brief controls text that appears in every question of the case. Whether IMD alters these code points could not be verified offline, so (c) is reported as a text-rule gap, not a confirmed mistrial path. Fix: add those ranges to _forbidden ((cp >= 0x206a && cp <= 0x206f), (cp >= 0x1bca0 && cp <= 0x1bca3), (cp >= 0xfff0 && cp <= 0xfff8), (cp >= 0xe0000 && cp <= 0xe0fff) as one range, (cp >= 0xfdd0 && cp <= 0xfdef), (cp & 0xfffe) == 0xfffe, optionally the PUA ranges) and treat U+1438/U+1433 like < and > in the doubling rule.

**Reproduction**

Reproduced in test/scratch/Probe.t.sol test_TextGaps. Input: text.check(bytes.concat("a", utf8(cp), "b"), 1, 500, 500) for cp in U+206A, U+206F, U+1BCA0, U+1BCA3, U+FFF0, U+FFF8, U+E0080, U+E0FFF, U+2AF7, U+2AF8, U+2991, U+2992, U+2995, U+2996, U+1438, U+1433, U+FE3F, U+FE40, U+FFFF, U+FFFE, U+FDD0, U+FDEF, U+1FFFF, U+E000, U+F0000, U+10FFFF, and for "a" + U+1438 + U+1438 + "b". Expected (by the rule the function documents and the treatment of U+200B, U+2060 and U+FEFF, which all revert BadText in the same run): revert BadText. Actual: every one of them returns without reverting, so a brief or opening brief carrying them is filed and quoted verbatim in every hearing's question.

### 7. Info: A case is pinned for up to 90 days to an Intake that may keep charging after IMD stops answering: each hearing then burns the challenger's jury price in a mistrial and the leader is locked in

`src/Briefs.sol:441`

```
        uint256 back = uint256(c.fee) - b.oracleReserve;
```

Trust assumption on IMD's Intake, not stated in docs/audit-internal-2026-10.md. A case keeps its oracle setup for life (swarm fix 4) and the setup's requester pays IMD's Intake on every hearing. If IMD retires or migrates the action (new Intake, writer stopped, action delisted off chain) while the old Intake still sells it on chain (priceOf > 0 and request() succeeding), hearings keep opening: each pays the price to the Intake, no callback arrives, and after answerTimeout + MISTRIAL_GRACE the brief is a mistrial refunded fee - price at this line. The skip path (whole fee back) is never reached because the requester never fails, so the stall clock never starts. Every challenger loses 0.5 IMD per attempt, nobody can overrule the precedent, and at endsAt the pot goes to the leader. The jury owner can only add a new setup for new cases; running cases cannot be moved or paused. Suggested bound that keeps the pot and escrow rules: let the jury owner mark a setup "retired" (a flag read in _hearNext that makes the hearing stall instead of paying, so briefs are skipped Unheard with their whole fee back and the case still settles), or stall automatically after N consecutive mistrials on one case.

**Reproduction**

Reproduced in test/scratch/IntakeProbe.t.sol test_SilentIntake (MockIntake whose writer never calls complete(), ImdGatewayRequester, deployed params). creator opens a 90-day case (fee 1 IMD, seed 100); alice files 20 briefs one after another; each opens a hearing that pays 0.5 IMD to the Intake, and after 6 minutes and 1 second anyone calls mistrial(c) (refund 0.5 IMD). After 20 rounds the Intake's payee holds 10 IMD, alice is down 10 IMD, the precedent is still brief 1, and no address can change the case's setup. Expected: a way to stop paying a jury that never answers, or a skip with the whole fee back. Actual: at endsAt settle(c) pays the creator; assertEq(winner, creator) passes.

### 8. Info: Live ownership of Briefs, BriefsJury and ImdGatewayRequester is a single EOA, not the multisig the audit doc relies on

`script/Deploy.s.sol:97`

```
        address newOwner = vm.envOr("NEW_OWNER", address(0));
```

Merged from audit_permissions and audit_economics (same fact, same fix). Admin powers and their bounds, as reviewed: Briefs owner: setParams (hard-bounded, new cases only), setPaused (new cases only), setHolderToken, openCase with minHold, setTreasury (instant; refuses Briefs, the jury and the current requester), proposeSink (at most 50% of the platform share, 2-day delay, applied by anyone, never reaches pots or escrow). BriefsJury owner: proposeOracle (7-day delay, 7-day window; a setup decides verdicts only for cases whose creator names it; signer, requester and hearingGas are the owner's choice). ImdGatewayRequester owner: setClient once, sweep of stray IMD to the Briefs treasury. None of these can take pots, escrowed fees or creator earnings, which matches the README. The swarm audit (finding 10) records "ownership of all three contracts goes to a multisig" as the mitigation for the jury owner choosing verdict setups. The deploy script only starts that handover when NEW_OWNER is set, and on Robinhood Chain it was not: owner() of Briefs, BriefsJury and ImdGatewayRequester is the EOA 0x65751B8A6443BDDd8790D6f42547c0e7FA210620 (no code) and pendingOwner() is zero on all three (read 8 Oct 2026). A leaked deployer key therefore gets: setTreasury to the attacker (every future caseFee and the whole platform share), proposeSink of 50% of the share to the attacker's contract after 2 days, and after 7 days in public a jury setup with the attacker's signer for any case whose creator then opens on it. Bounded (no access to pots or escrow), and a trust assumption rather than a code defect, but the documented mitigation is not in place. Fix: run transferOwnership to the multisig on all three and acceptOwnership from it; until then the docs should state the actual owner.

**Reproduction**

On chain (chain id 4663, rpc.mainnet.chain.robinhood.com, 8 Oct 2026): cast call 0x85737d04bde718f42f90e31564540408cbbe6e4b "owner()(address)", cast call 0x265c541aa5c5f202e1e3024570cb7d8b278ca691 "owner()(address)" and cast call 0xbaee00b30d6f585218e257d84c70cf4181229592 "owner()(address)" all return 0x65751B8A6443BDDd8790D6f42547c0e7FA210620; "pendingOwner()(address)" returns the zero address on all three; cast code 0x65751B8A6443BDDd8790D6f42547c0e7FA210620 returns 0x. Expected after the documented handover: a contract (Safe) address as owner of all three. Actual: an EOA.

---

Judge's submission `341c6c8a50345db517aacbb708d0a0a64d57f500a2c2c6b7ff0e9413395b69ee`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
