# Audit report

> Review the exact corrected Claus paid-agreement candidate at commit be5069c2e7cded57f786657a36453afe2aa98f2d. This distinct review concerns the corrections to prior IMD job b9c4399e-2616-411c-86e3-415b1e889f29 on source935c323e5190ce9806e1b844a3644b5519200487. Compare the delta and read docs/deal-audit-response.md. Do not mistake the earlier report for approval of these changes. The escrow Solidity and existing native TS are unchanged; focus on actual corrected offchain financial paths rather than repeating unchanged full suites. Verify durable case admission independent of requester-session veto, original-party and operator revocation boundaries, explicit missing/wrong-kind buyer objections without made-up payout evidence, per-owner and minimum-value subsidy limits, admission vs processing time margins, atomic release ONLY of never-submitted reservations while preserving all ambiguous submissions, mandatory chain-verified amount pins, actual separate-worker cron configuration and fresh-health admission. Review the shared decision validator and independent IMD/native/signer bindings end to end; exact native snapshots are in reference/deal-review-native. Run focused reproductions on these changed boundaries. All financial activation remains disabled pending final assessment and the real owned lifecycle. No deployment, purchase, transaction, grant or public safety guarantee is requested. Report concrete bugs and remaining assumptions.
>
> Also review the prepared-input wire binding: a real unpaid IMD quote confirmed inputHash covers {request,pinned,questionHash}, not the bare request. The adapter validates exact case inputs and canonical confirmed block hash before reserving/payment; proof and native host consume the same stored prepared input. Reject changed amount/question/window/consumer/allowAmbiguous, preserving original order on uncertain outcomes. The unpaid format probe had no payment or panel.

| | |
|---|---|
| Repository | https://github.com/contractclaus/claus-network |
| Commit | `be5069c2e7cded57f786657a36453afe2aa98f2d` |
| Job | `fdf7534f-bfb0-4c8f-9763-c41bcec01d81` |
| Judged | 2026-10-14 07:06 UTC |
| Findings | 1 high · 2 medium · 5 low · 3 info |

Four agents audited the code as it is at `be5069c`, 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. High: Any transient failure inside permitted()/stillAllowed() is converted into a permanent authority_closed block of a possibly paid case

`src/deals/arbitration.mjs:21`

```
 async function permitted(v,reader=store){try{const p=await admission(reader);return p.generation===v.admissionGeneration;}catch{return false;}}
```

permitted() wraps the store read of network-capabilities/deal-arbitration in a catch-all that returns false. tick() treats false as authority_closed and saves the case as state blocked (line 41), and this commit also added authority_closed to the permanent-error list (line 46), so the same catch-all now governs every stillAllowed() call inside advance(): before quote, before prepare, before submit and three times around signing. One transient database error on that single read (Neon websocket drop, ECONNRESET, statement timeout) therefore permanently blocks the case, removes it from the active list and never retries it, although the operator capability is still enabled with the unchanged generation. One permanent case ID exists per deal, so the agreement loses its only subsidized review; when the failure happens after submit the IMD oracle fee already paid is lost too, and the per-owner lifetime allowance and global eight-case slot stay consumed. At 935c323 a throw from inside advance was retryable. Fix: let read failures propagate (or return a distinct unknown value) from permitted(), and reserve authority_closed for an actually observed generation change or enabled:false in the admission record; keep infrastructure errors non-permanent.

**Reproduction**

Reproduction file: test/scratch/judge-repro.test.mjs (node --test). Every test there passes on the current tree, which demonstrates the defect it names. Test R2: case admitted, capability {enabled:true,generation:1} unchanged; the store passed to createArbitration is a Proxy whose read of network-capabilities rejects exactly once (asserted failed===1) and succeeds afterwards. Expected: case stays queued and is retried next tick. Actual: deal-cases[id].state==="blocked", error==="authority_closed", the id is removed from deal-case-capacity.active, ports.advance is never called and a second tick does not resume it. The second half of R2 injects the same single failure during stillAllowed() inside advance and gets the same permanent block.

### 2. Medium: Deal worker heartbeat is written even when the worker cannot process cases, so fresh-health admission keeps admitting and every admitted case is burned

`api/deals.mjs:21`

```
  await ports.reconcile();const result=await deals.tick();await store.transaction(async tx=>{const old=await tx.read('worker-health','deals');await tx.write('worker-health','deals',{observedAt:Date.now(),durationMs:Date.now()-start,processed:result.processed,release:process.env.CLAUS_NETWORK_RELEASE},old?.revision??0);});res.status(200).json({ok:true,...result});
```

The public API and the signer worker are separate deployments with separate copies of CLAUS_DEAL_ARBITRATION_ENABLED and CLAUS_DEAL_PINS. Admission only requires worker-health/deals.observedAt within five minutes (src/deals/arbitration.mjs:16,30). api/deals.mjs writes that heartbeat unconditionally after reconcile()+tick(), including when its own createArbitration was built with enabled:false (flag unset or not "1") or when dealPorts() fell back to its stub because the pins are invalid. With the flag off, tick() -> permitted() -> admission() throws deal_arbitration_disabled, permitted returns false and every active case is saved as blocked/authority_closed on its first tick without ever calling ports.advance. Meanwhile the public API keeps reporting capabilities.deals.arbitration.enabled=true and request() keeps admitting; each admission consumes one of the eight lifetime subsidy slots, the owner daily/total allowance and the deal's single permanent case ID, none of which is refunded. docs/paid-deals.md says new admission rejects a worker that cannot process, but nothing in the heartbeat carries the worker's enabled flag, pin configuration or admission generation. Fix: record the worker's effective enabled flag (and ideally the generation it is serving) in the heartbeat and require enabled===true in workerHealth(); or skip the heartbeat when the flag is off or the pins are invalid.

**Reproduction**

Reproduction file: test/scratch/judge-repro.test.mjs (node --test). Every test there passes on the current tree, which demonstrates the defect it names. Test R1: two createArbitration instances on one store; the API side has enabled:true, the worker side enabled:false, capability {enabled:true,generation:1}. Worker tick then heartbeat write (the api/deals.mjs order), API discovery().enabled===true, API request() returns state queued, worker tick+heartbeat again. Expected: request rejected with deal_worker_unavailable, or the case left queued until a configured worker exists. Actual: case state blocked, error authority_closed, advance count 0, deal-case-capacity.total===1, deal-case-daily principal:<ownerId>:total===1, a repeat request returns the dead blocked case, and discovery().enabled is still true.

### 3. Medium: releaseUnsent cancels prepared payments that are still submittable or merely paused, and advance() then mislabels the case authority_closed

`runtime/arbitration-ports.mjs:18`

```
 async function releaseUnsent(offerId){return store.transaction(async tx=>{const row=await tx.read('deal-oracle-allocations',offerId);if(row?.value.billing!=='reserved')return false;const a=row.value;if(await allowed(a.caseId,tx)&&a.offer.expiresAt>now()+60000)return false;if(!await payment.abandonUnsent(tx,offerId))return false;await settleReservation(tx,ACCOUNT,ASSET,a.maximum,'0');await tx.write('deal-oracle-allocations',offerId,{...a,billing:'released-unsent',releasedAt:now(),reason:'No submit intent existed; original offer retained.'},row.revision);return true;});}
```

reconcile() runs releaseUnsent for every reserved allocation, before tick() and without taking the case lease. It cancels any payment in signing/signing_unknown/prepared state as soon as (a) offer.expiresAt<=now()+60000, although submit() still accepts until deadline>now+15 and a concurrent invocation (minute cron, 300 s maxDuration) may already have passed the line-29 check and be between prepare and submit; or (b) allowed(caseId) is false, which includes a temporary operator enabled:false with the same generation. When advance() later reads payment.status() it finds cancelled and throws authority_closed (line 29), which this commit made permanent. Three consequences: a payment that was about to be submitted is killed and the deal's single case is lost; an expired never-submitted quote is recorded on the public case as authority_closed instead of oracle_quote_expired, because reconcile runs before tick (the oracle_quote_expired branch is unreachable in the api/deals.mjs order); and a brief operator pause that does not bump the generation permanently kills every in-flight prepared case on re-enable, which docs/paid-deals.md does not state. Money is not lost (the submit fence holds), but the label is wrong and the case outcome is wrong. Fix: release on expiry only once nothing can be submitted any more (expiresAt<=now(), i.e. after the submit deadline) and skip allocations whose case holds an unexpired lease; check offer expiry before the cancelled check so the public error is oracle_quote_expired; and either document that an operator pause is terminal for admitted cases or release only when the case is terminal or the offer has really expired.

**Reproduction**

Reproduction file: test/scratch/judge-repro.test.mjs (node --test). Every test there passes on the current tree, which demonstrates the defect it names. Test R4: admitted case oracle-pending, allocation q1 reserved, offer.expiresAt=now+50000, imd-payments q1 state prepared with deadline now+40s (submit would accept). ports.reconcile() -> imd-payments q1 state cancelled, allocation released-unsent while deal-cases state is still oracle-pending; ports.advance(v) then rejects with authority_closed although the capability record is still enabled. Test R5: same with offer.expiresAt=now-1; reconcile then tick (api/deals.mjs order) leaves the case blocked with error authority_closed, not oracle_quote_expired. Test R6: offer expiring in 500 s, operator sets enabled:false with the same generation, reconcile cancels q3; operator sets enabled:true; advance rejects with authority_closed and the case is permanently blocked.

### 4. Low: Network-wide access-policy pause and admin project suspension do not stop an admitted case although the published description says operator suspension does

`src/deals/arbitration.mjs:21`

```
 async function permitted(v,reader=store){try{const p=await admission(reader);return p.generation===v.admissionGeneration;}catch{return false;}}
```

At 935c323 permitted() also called identity.allowed(v.actor,"deal.review",...), which returns false when the active access policy has paused:true (writesEnabled in api/deals.mjs) or project-access is suspended. The correction removed that call to make admission durable against requester-session revocation, but with it removed the only link between the operator's global pause and the financial worker. tick() now keeps quoting, signing, paying, posting to the native host and signing resolutions while the whole network is paused, and project_access_set {suspended:true} by the operator has no effect either (test/deal-arbitration.test.mjs:15 asserts the continuation). The only operator action that stops a case is editing network-capabilities/deal-arbitration. docs/openapi.json:727, interfaces/contract.mjs:22 and docs/paid-deals.md say "network operator suspension still stops it" without saying that neither the global pause nor project suspension counts. Either consult the access policy paused flag in permitted() (it is operator-controlled, not requester-controlled, so it does not reintroduce the party veto) or document that only the deal-arbitration capability record stops processing.

**Reproduction**

Reproduction file: test/scratch/judge-repro.test.mjs (node --test). Every test there passes on the current tree, which demonstrates the defect it names. Test R3: admitted case queued, worker health fresh, capability enabled. The owner publishes and activates an access policy with paused:true; access_policy_get returns paused===true and identity.allowed(actor,"deal.review",{projectId}) returns false. tick(): expected (behaviour at 935c323 and the documented operator suspension) no advance and authority_closed; actual: advance is called once and the case moves to oracle-pending.

### 5. Low: reconcile() reads at most eight allocation rows with no cursor, so later reservations are never released or settled

`runtime/arbitration-ports.mjs:58`

```
  async reconcile(){for(const row of await store.list('deal-oracle-allocations',{limit:8})){const a=row.value;if(a.billing!=='reserved')continue;try{if(await releaseUnsent(a.offer.id))continue;const old=await payment.status(a.offer.id);if(!old||old.state==='cancelled')continue;const state=await payment.reconcile(a.offer.id);await settleCost(a.offer,state,a.inputHash);}catch{}}}
```

store.list returns rows ordered by id with limit 8 and the loop never pages with the after cursor. Rows already settled or released still occupy those eight slots, so any allocation whose id sorts after the first eight is never visited: an expired never-submitted reservation is never released and a submitted payment is never reconciled or cost-settled from this path. Today the lifetime cap of eight cases keeps the bound latent, but it silently breaks once the cap is raised, a second chain pin shares the store, or a case ever produces a second offer row. Fix: page with the after cursor until fewer than limit rows return, or iterate only billing=reserved rows.

**Reproduction**

Reproduction file: test/scratch/judge-repro.test.mjs (node --test). Every test there passes on the current tree, which demonstrates the defect it names. Test R7: nine deal-oracle-allocations rows a1..a9, all billing reserved with offer.expiresAt in the past and a caseId that does not exist (so releaseUnsent should release each), 9000 units funded and 9x1000 reserved. ports.reconcile() five times. Expected: all nine released-unsent, reserved balance 0. Actual: a1..a8 become released-unsent, a9 stays reserved and the balance stays reserved:"1000" after any number of runs.

### 6. Low: Deterministic verification failures and provider-format mismatches are retried every minute with provider calls and never reach a terminal state

`src/deals/arbitration.mjs:46`

```
    }catch(e){const permanent=['deal_not_reviewable','deal_state_changed','deal_pin_mismatch','deal_evidence_mismatch','oracle_quote_expired','authority_closed'].includes(e.code);await save(id,token,{...(permanent?{state:'blocked',error:e.code}:{}),lease:null}).catch(()=>{});}
```

Only six error codes are permanent. Errors that depend solely on already-checkpointed or deterministic state are retried for the life of the dispute window: oracle_prepared_input_mismatch, oracle_question_mismatch, oracle_attestation_mismatch, oracle_signature_invalid, oracle_amount_invalid (all raised by verifyDealOracle on the stored v.oracle), case_binding_mismatch, oracle_admission_mismatch, native_binding_mismatch and a native host 400. Each retry costs a provider status GET and, for native rejections, a fresh POST of the full input; the case also keeps its active slot and part of the 300 s tick budget until resolutionDue-3600 passes and deal_not_reviewable blocks it. The same shape exists one step earlier in adapters/imd-oracle.mjs:94: quote() only persists {quote,preparedInput} after the unpaid 402 probe passes validateOraclePrepared, whose shape() requires exactly the keys {pinned,questionHash,request} and {fromBlock,toBlock,toBlockHash}; if the provider wrapper ever carries one extra key the validator throws before the store write, the order row stays at quote:null and every later tick POSTs /requests/quote and the /submit probe again with no terminal outcome. Rejecting a changed wrapper is correct; the missing piece is a recorded terminal failure (or a bounded attempt count) so the parties can see why the case died and the provider is not polled indefinitely.

**Reproduction**

Reproduction file: test/scratch/judge-repro.test.mjs (node --test). Every test there passes on the current tree, which demonstrates the defect it names. Test R12: ports.advance throws oracle_attestation_mismatch; three ticks 130 s apart call advance three times, the case stays queued with no error and remains in deal-case-capacity.active. Expected: state failed with the code and removal from active. Test R9: provider /submit 402 returns input.pinned with one extra key fromBlockHash and an otherwise valid quote; provider.quote() called three times rejects with invalid_shape each time, /requests/quote and /submit are each called 3 times, imd-orders[key].quote===null, no imd-offer-index row.

### 7. Low: validateOraclePrepared normalizes a provider-supplied allowAmbiguous:null to false and accepts it

`src/deals/decision.mjs:11`

```
 requireThat(canonical({...value.request,allowAmbiguous:value.request?.allowAmbiguous??false})===canonical({...expected,allowAmbiguous:false})&&value.pinned.fromBlock===expected.window.fromBlock&&value.pinned.toBlock===expected.window.toBlock&&/^0x[0-9a-f]{64}$/i.test(value.pinned.toBlockHash??'')&&/^0x[0-9a-f]{64}$/i.test(value.questionHash??''),'oracle_prepared_input_mismatch');return value;
```

The comment says only the documented false default is normalized, but the nullish-coalescing operator also maps an explicit null to false. The provider inputHash is computed over the wrapper as returned (with null), so quote.inputHash===hash(preparedInput) still passes and the worker proceeds to reserve, sign and pay for a request whose allowAmbiguous holds a value this code never chose and whose provider-side meaning the unpaid probe did not verify. The stated design is to reject any changed allowAmbiguous. Fix: const a=value.request?.allowAmbiguous; requireThat(a===undefined||a===false,"oracle_prepared_input_mismatch") before the canonical comparison.

**Reproduction**

Reproduction file: test/scratch/judge-repro.test.mjs (node --test). Every test there passes on the current tree, which demonstrates the defect it names. Test R8: expected=oracleInput(case); value={request:{...expected,allowAmbiguous:null},pinned:{fromBlock,toBlock,toBlockHash:<32-byte hex>},questionHash:<32-byte hex>}. validateOraclePrepared(value,expected) returns value with request.allowAmbiguous===null. Expected: throws oracle_prepared_input_mismatch, as it does for true and for the string "false".

### 8. Low: Separate-worker deployment config does not exclude the public API functions, so gateway/compile/workflows handlers are deployed into the process that holds the payment and arbiter keys

`deploy/deals.vercel.json:2`

```
  "functions": {
```

docs/paid-deals.md says the private host receives the funded IMD payer key, the unfunded resolution signer and the sealing key, and that the public API never receives either signing key. Vercel deploys every file under api/ as a serverless function regardless of the functions map, and .vercelignore excludes only node_modules, .git, test, contracts, public, sqlite and env files. A deployment of this repository with deploy/deals.vercel.json as its vercel.json therefore also serves /api/gateway, /api/compile, /api/workflows, /api/domains and /api/workers from the same project whose environment contains CLAUS_DEAL_PAYMENT_KEY and CLAUS_DEAL_ARBITER_KEY, and /api/compile and /api/workflows run there against the shared database when the second project also sets CRON_SECRET. Those handlers do not read the keys today, so there is no direct leak, but the stated isolation boundary (keys only in a process that runs the worker) is not what the configuration produces. Nothing in the repository selects the deploy file either: the root vercel.json schedules /api/compile and /api/workflows only, so a worker project created without a --local-config step never ticks arbitration (safe but silent: deal_worker_unavailable forever). This could not be executed here; it follows from Vercel api/ auto-detection and the absence of any exclusion. Fix: add a worker-specific ignore or move api/deals.mjs into its own minimal project root so only the deals function is built in the key-holding deployment, and record in the operator docs how the deals config is selected.

**Reproduction**

Configuration inspection, not executed (no deployment). State: a Vercel project created from this repository with deploy/deals.vercel.json copied to vercel.json and the deal keys in env. Input: GET https://<deals-deployment>/api/gateway?__claus_route=/v1/health. Expected: 404, only /api/deals exists in the key-holding deployment. Actual per Vercel function detection: the public gateway handler is built and executes inside the deployment whose environment contains CLAUS_DEAL_PAYMENT_KEY and CLAUS_DEAL_ARBITER_KEY; `ls api` shows six handlers and .vercelignore excludes none of them.

### 9. Info: Attested request window is bound by whole-object canonical equality, an assumption about an unprobed provider response that fails only after payment

`adapters/deal-oracle-proof.mjs:12`

```
 validateOraclePrepared(preparedInput,expected);requireThat(q?.questionHash===preparedInput.questionHash&&canonical(q?.window)===canonical(preparedInput.pinned),'oracle_prepared_input_mismatch');
```

The new binding compares canonical(q.window) with canonical(preparedInput.pinned), i.e. exactly {fromBlock,toBlock,toBlockHash}. The 402 challenge input format was confirmed by the unpaid probe, but the attested request object returned by GET /oracle/requests/{id} was not (no panel was opened), and the previous code only read the three fields from it. If the attested window carries one more key (fromBlockHash, pinnedAt, chainId), verifyDealOracle throws oracle_prepared_input_mismatch after the IMD payment has been made and settled, which the finding at src/deals/arbitration.mjs:46 shows is then retried every minute until the dispute window closes: panel fee spent, no decision. Retained assumption rather than a demonstrated mismatch. Fix: bind the three pinned fields individually (q.window.fromBlock, toBlock, toBlockHash each === preparedInput.pinned.*), which is what the attestation message already pins.

**Reproduction**

Reproduction file: test/scratch/judge-repro.test.mjs (node --test). Every test there passes on the current tree, which demonstrates the defect it names. Test R10: a valid attested request/attestation pair (passes verifyDealOracle with workerAmount "600") with request.window changed to {fromBlock:96,toBlock:97,toBlockHash:<same>,fromBlockHash:<32-byte hex>}; attestation unchanged and still matching all pinned fields and preparedInput. Expected: accepted, since the three pinned fields equal the stored prepared input and the attestation blockHash. Actual: rejects with oracle_prepared_input_mismatch.

### 10. Info: Signer re-validates with the 3600 s admission margin, so the 60 s resolution_expired check is unreachable and the effective processing window ends an hour before expiry

`src/deals/decision.mjs:33`

```
 requireThat(/^[1-9][0-9]*$/.test(pin.maximumDealWei??'')&&pin.maximumDealWei===o.maximumDealWei&&d.state===4&&/^0x[0-9a-fA-F]{40}$/.test(d.buyer)&&/^0x[0-9a-fA-F]{40}$/.test(d.worker)&&d.buyer.toLowerCase()!==d.worker.toLowerCase()&&![d.buyer.toLowerCase(),d.worker.toLowerCase()].includes(pin.arbiter.toLowerCase())&&/^[1-9][0-9]*$/.test(d.amount)&&BigInt(d.amount)<=BigInt(pin.maximumDealWei)&&/^(0|[1-9][0-9]*)$/.test(p.workerAmount)&&BigInt(p.workerAmount)<=BigInt(d.amount)&&Number.isSafeInteger(p.expiresAt)&&p.expiresAt>Math.floor(now/1000)+3600&&p.expiresAt<=Number(d.resolutionDue),'network_review_invalid_input');
```

validateDealDecisionShape requires proposal.expiresAt > now+3600 and runtime/arbitration-ports.mjs:38 re-runs it on every tick, including the tick whose only remaining work is to read an already completed native approval and sign. The native host itself only requires authorizationActive (expiresAt > now) and the escrow accepts resolve until expiresAt. So for any case whose approval lands in the window (expiresAt-3600, expiresAt-60] the worker throws network_review_invalid_input, retries each minute and is finally blocked as deal_not_reviewable; the paid panel result and the Claus approval are discarded. The dedicated signing-time check resolution_expired at arbitration-ports.mjs:51 can never fire. This is consistent with docs/deal-audit-response.md ("existing processing retains its one-hour margin") and with observed() using processingRemainingSeconds=3600 in tick, so it is recorded as a documented limit with dead code rather than a defect; note that proposal.expiresAt=min(resolutionDue,attestation.expiresAt), so the window can also close an hour before the attestation expiry. If signing with less than an hour left is intended, separate the admission margin from the signing margin.

**Reproduction**

Reproduction file: test/scratch/judge-repro.test.mjs (node --test). Every test there passes on the current tree, which demonstrates the defect it names. Test R11: native input built exactly as test/deal-arbitration.test.mjs does (valid attestation, pins, proposal.expiresAt=min(resolutionDue,attestation.expiresAt)); validateDealDecisionShape(native,pins,now) passes; the same call with now=(proposal.expiresAt-3540)*1000 (59 minutes before expiry, when the contract would still accept the resolution) throws network_review_invalid_input.

### 11. Info: Per-owner subsidy limits are keyed by requesting principal, not by onchain party address, so one provider address can occupy every subsidized case

`src/deals/arbitration.mjs:31`

```
    const day=Math.floor(now()/86400000);for(const [k,limit]of [[projectId+':'+day,ARBITRATION_LIMITS.projectDaily],['principal:'+project.ownerId+':'+day,ARBITRATION_LIMITS.principalDaily],['principal:'+project.ownerId+':total',ARBITRATION_LIMITS.principalTotal],['global:'+day,ARBITRATION_LIMITS.globalDaily]]){const r=await tx.read('deal-case-daily',k);requireThat((r?.value.count??0)<limit,'deal_arbitration_capacity');await tx.write('deal-case-daily',k,{count:(r?.value.count??0)+1},r?.revision??0);}
```

Retained assumption acknowledged in docs/deal-audit-response.md, recorded with the concrete bound. Limits are keyed on projectId and project.ownerId (the principal of the signing address); the party check only requires the owner address to be buyer or worker. A single provider address W can be the worker in deals with buyers B1..B4 (four sign-ins, four principals, four projects); each buyer requests one case per UTC day, so four cases on day 1 and four on day 2 exhaust cases:8 with W in every case, at a cost of four escrows of 0.0001 ETH plus gas, while the network pays up to 0.5 IMD plus inference per case. An additional counter keyed on each onchain party address (e.g. two lifetime cases per address) would raise the attacker cost to eight distinct addresses without changing the economic rules.

**Reproduction**

Reproduction file: test/scratch/judge-repro.test.mjs (node --test). Every test there passes on the current tree, which demonstrates the defect it names. Test R13: two principals (owner and bob) each owning a project, each the buyer of a distinct deal that shares worker address W. Both request() calls return queued and deal-case-capacity.total===2; no per-address counter rejects the second. Extending to four principals per day over two days reaches total 8 with W in every case.

---

Judge's submission `458afe22dcefba1c5c13a563ac12f2cd8e57e1e310c8cf1134a5f2fa2f988178`, 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.
