You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Severity: MEDIUM (durability/correctness; a testnet-confined security side-benefit). Source: ADR 0003 (#105), decoupled by ADR 0004 (#119). Reviewed + re-scoped via /autoplan (2026-06-20).
Problem
The signer daily spend cap is not durable: spent_today_wei is in-memory and force-zeroed on every daemon load (policy_store.rs:63), incremented only post-broadcast (daemon.rs:1166, after the broadcast at :1148). So any restart — crash, OOM, app update, sleep — silently zeroes the day's accounting. That is a plain correctness bug that hits honest users on every restart.
It also removes a (now testnet-confined) drain: a same-uid attacker could crash-loop the auto-respawning daemon (supervise.rs) to reset the cap and spend in within-cap chunks. This is bounded — the daemon boots Locked and needs a fresh passphrase per restart (daemon.rs:262), and #76's guardrail already downgrades every real-chain auto-allow to a human card (daemon.rs:635, guardrail_active:1392), so the hands-free within-cap path only exists on testnets/forks or under an explicit autonomy override. The same-uid attacker can also just delete the counter file (accepted residual, ADR 0004), so the durable counter's value is primarily honest-restart durability, not a security guarantee. The security story completes when #72 authenticates the cap values in policy.json.
Re-scoped by review: dropped the nonce-keyed RPC reconciliation from the earlier draft (it restructures the frozen broadcast path and defends a sub-second window the file-delete attacker bypasses). Conservative count-on-reboot gives the same safety, errs tighter, and needs no chain query.
Acceptance criteria (Option A)
Durable counter file (spend.json in config_dir) holding {chain_id, account, day, committed_wei, reserved_wei}, written atomically via a new deckard_core::atomic_write(path, &[u8]) extracted from Vault::write_atomic (temp@0600 + fsync + rename + dir-sync). (F1 — write_atomic is a Vault method, not a byte-writer; the extraction is a prerequisite.)
Reserve-before-sign: in execute, after the cap re-check (daemon.rs:1116) and before the broadcast (:1140), durably record reserved_wei += value (skip when value == 0 — shields/approves/contract-calls). A reserve-write failure denies fail-closed with a new reserve_failed deny tag. (F3, F5)
Effective spend = committed_wei + reserved_wei. On reboot any leftover reserved_wei (a crash between reserve and commit) is counted as spent — never released by a chain query. (conservative count-on-reboot)
Broadcast outcome: success → commit (reserved→committed); clean RPC rejection → release the reserve; timeout → KEEP it counted (a timed-out tx may have landed — must not re-open the double-spend). (F4)
Forward-only rollover: one effective_day = max(current_utc_day(), stored_day); reset spend only when the day advances. A backward wall-clock does not reset (fail closed). Replaces the bidirectional rollover() (daemon.rs:1397). (F8)
Binding: validate chain_id at boot (mismatch → discard — a different chain is a different cap); validate account at unlock (mismatch → reset window). Missing file → fresh window; corrupt file → fully-spent + loud log until rollover (mirror the policy loader's missing/invalid split). Clamp recovered spend ≤ daily cap. (F9, F10, F6)
Supervisor crash-budget (Option D): a non-resetting N-crashes-in-M-minutes budget in monitor_loop stops respawning and surfaces a loud error (the per-spawn backoff resets every 200ms today — supervise.rs:590 — so a crash-loop respawns at the floor forever). Counter-load failures are non-fatal so a single bad file can't feed the budget into a lockout. (F11)
Anti-rollback anchor ([Deferred] Spike: rollback-protect the vault with a monotonic epoch (→ ADR 0004) #71, ADR 0004); spawn_blocking off-lock fsync (fast-follow if the two reserve/commit fsyncs on the STOP-blocking critical section bite — accepted for v1, anvil-instant, matching the existing "lock held across broadcast" precedent); forward-clock-jump reset (needs root, above the same-uid boundary).
Severity: MEDIUM (durability/correctness; a testnet-confined security side-benefit). Source: ADR 0003 (#105), decoupled by ADR 0004 (#119). Reviewed + re-scoped via
/autoplan(2026-06-20).Problem
The signer daily spend cap is not durable:
spent_today_weiis in-memory and force-zeroed on every daemon load (policy_store.rs:63), incremented only post-broadcast (daemon.rs:1166, after the broadcast at:1148). So any restart — crash, OOM, app update, sleep — silently zeroes the day's accounting. That is a plain correctness bug that hits honest users on every restart.It also removes a (now testnet-confined) drain: a same-uid attacker could crash-loop the auto-respawning daemon (
supervise.rs) to reset the cap and spend in within-cap chunks. This is bounded — the daemon bootsLockedand needs a fresh passphrase per restart (daemon.rs:262), and #76's guardrail already downgrades every real-chain auto-allow to a human card (daemon.rs:635,guardrail_active:1392), so the hands-free within-cap path only exists on testnets/forks or under an explicit autonomy override. The same-uid attacker can also just delete the counter file (accepted residual, ADR 0004), so the durable counter's value is primarily honest-restart durability, not a security guarantee. The security story completes when #72 authenticates the cap values inpolicy.json.Acceptance criteria (Option A)
spend.jsoninconfig_dir) holding{chain_id, account, day, committed_wei, reserved_wei}, written atomically via a newdeckard_core::atomic_write(path, &[u8])extracted fromVault::write_atomic(temp@0600 + fsync + rename + dir-sync). (F1 —write_atomicis aVaultmethod, not a byte-writer; the extraction is a prerequisite.)execute, after the cap re-check (daemon.rs:1116) and before the broadcast (:1140), durably recordreserved_wei += value(skip whenvalue == 0— shields/approves/contract-calls). A reserve-write failure denies fail-closed with a newreserve_faileddeny tag. (F3, F5)committed_wei + reserved_wei. On reboot any leftoverreserved_wei(a crash between reserve and commit) is counted as spent — never released by a chain query. (conservative count-on-reboot)effective_day = max(current_utc_day(), stored_day); reset spend only when the day advances. A backward wall-clock does not reset (fail closed). Replaces the bidirectionalrollover()(daemon.rs:1397). (F8)chain_idat boot (mismatch → discard — a different chain is a different cap); validateaccountat unlock (mismatch → reset window). Missing file → fresh window; corrupt file → fully-spent + loud log until rollover (mirror the policy loader's missing/invalid split). Clamp recovered spend ≤ daily cap. (F9, F10, F6)monitor_loopstops respawning and surfaces a loud error (the per-spawnbackoffresets every 200ms today —supervise.rs:590— so a crash-loop respawns at the floor forever). Counter-load failures are non-fatal so a single bad file can't feed the budget into a lockout. (F11)value==0skips reserve; chain/account binding; reserve-error fail-closed; clamp) + anvil e2e (happy; fault-injected crash-restart; clean-Err release; timeout keep; honest-restart durability) + supervisor budget + isolatedDECKARD_CONFIG_DIRper test (cross-test counter bleed). (F12)Deferred
policy.jsonauthentication (Spike: make policy.json tamper-evident + define a secure edit/maintenance path (not in the vault) #72) — sequences with this; the cap value is unauthenticated until it lands.spawn_blockingoff-lock fsync (fast-follow if the two reserve/commit fsyncs on the STOP-blocking critical section bite — accepted for v1, anvil-instant, matching the existing "lock held across broadcast" precedent); forward-clock-jump reset (needs root, above the same-uid boundary).