# Per-account, per-window spend caps

Slug: `spend-cap-limiter`

## Goal

An account may carry a hard cap per usage window: "stop this account when its 7d window has 10% left", "when its 5h window has 25% left". When a cap is breached the account is **refused for every new dispatch and every new session**, and its **running agent work is stopped** — the child scope is killed, so no further tokens are spent. The task worktree and its WIP stay exactly as they were, and the task retries automatically when the window resets or the user clears/raises the cap.

Caps are set from the account card's `⋮` menu → **Set limits…**, a modal with a window dropdown (only windows the account actually has) and a percent field.

## Non-goals

- No change to the web dashboard (`apps/web/src/components/limits/*`) — it stays a read-only view.
- No new cost/pricing model. Caps are expressed in the same **percent-of-window-used** unit the health snapshot already reports.
- No change to `budget.maxUsdPerRun` / `maxUsdPerTask` — a per-run dollar ceiling, orthogonal to a per-account window cap.
- **No touching `modules/harness/src/`.** That tree is pre-v2, dead by user order, untracked, and absent from the live engine bundle. The only engine is `modules/harness/v2`.

## Vocabulary — the inversion footgun

Two existing predicates use opposite conventions. Pinning them, because conflating them silently inverts the feature:

| Concept | Module | Compares | "10" means |
|---|---|---|---|
| Warning threshold | `modules/systray/limit_warning.py` `breached_windows` | `100 - used_pct < threshold` (**remaining**-based) | warn when only 10% is left (90% used) |
| Quota exhaustion | `modules/systray/routing_resolver.py` `_quota_exhausted` | `used_pct >= threshold` (**used**-based) | exhausted at 90% unused |

A **spend cap is remaining-based**, matching `limit_warning`'s convention and the user's stated intent ("stop work at 10% left"):

```
capped  iff  (100 - used_pct) <= cap_pct
```

A cap of 10 stops the account when only 10% of the window remains — i.e. at 90% used. The comparison is `<=`, not `limit_warning`'s `<`: a hard stop fires *at* the stated boundary, one step earlier than a warning, which is the safe direction for a limiter.

**This is the single most invertible decision in the spec.** Getting it backwards turns "stop at 90% used" into "stop at 10% used", which halts a healthy account instantly. Live example, from the current health snapshot: `zync2` 7d is `secondary_used_pct = 88` — 12% remaining. A cap of 10 must **not** fire on it today, and must fire once it reaches 90% used. That case is a required named test (see Testing).

`limit_warning` itself is unchanged by this work; the cap does not route through `breached_windows`.

## Window identity

Window keys are the existing strings `"5h"` and `"7d"` (`limit_warning.WINDOW_5H` / `WINDOW_7D`), mapping onto `AccountSnapshot.primary_used_pct` and `secondary_used_pct`. An account "has" a window iff its snapshot field for that window is not `None` — grok reports only `secondary_used_pct`, so grok offers `7d` alone with no provider-specific code.

Each window also carries a reset instant in the same snapshot: `primary_reset_at` / `secondary_reset_at`, **epoch seconds as a float, or `None`** (verified in the live `~/.systray-ai/health_cache.json`: `zync2.secondary_reset_at = 1786365653.0`, `zync2.secondary_used_pct = 88`, both `primary_*` `None`).

## Enforcement surfaces — where a capped account can still spend

Three, and only three, paths spend an account's tokens. Each gets a gate; all three converge on the same "hold, don't fail" semantics.

| # | Path | Gate | Covers |
|---|---|---|---|
| A | `cdx` / `cld` CLI — interactive sessions and account resolution | `RoutingResolver._is_available` via `command_router._resolve_account` (`command_router.py:662`) | Claude Code sessions, codex sessions, any account resolution |
| B | harness v2 dispatch → wrapper | pre-flight cap gate in `modules/harness/wrappers/lib/` | every seat dispatch of every provider |
| C | a dispatch already running when the cap trips | cap poller in `v2/dispatch.js` + abort seam in `v2/child.js` | in-flight token burn |

Surface A is what makes a **Claude** cap meaningful: v2 has no wrapper that dispatches to a real Claude Code subscription account (`na.sh` runs `claude -p` through ccr/OpenRouter; `ca.sh`, `na.sh`, `grok.sh` all print `--profile ignored`; only `codex.sh` honours `--profile`). Claude accounts are consumed by **Claude Code sessions launched through `cld`**, whose `CLAUDE_CONFIG_DIR` is set by `ClaudeAdapter.build_env` (`command_router.py:561-575`). Gating `_resolve_account` is therefore the whole Claude story, and it is the same code path codex already uses.

**Out of scope by design: killing a Claude Code session the user is sitting in.** Surface C stops *dispatched* agent work, where WIP preservation and auto-restart are meaningful. An interactive REPL has no WIP to preserve and no resume; killing it is data loss. A capped account refuses to start *new* sessions (A) and stops *dispatched* work (C); a session already open stays open.

## Components

### 1. Cap schema — `routing_rules` (both provider files)

Caps live in the routing-rules file the resolver already loads, per provider:
`~/.systray-ai/routing_rules.json` (codex) and `~/.systray-ai/claude_routing_rules.json` (claude), per `account_registry.ROUTING_RULES_FILENAMES`. No new store, no second loader in the enforcement path.

Schema addition (version stays `routing/v2`):

```json
{
  "account_caps": {
    "zync2": {"7d": 10},
    "multideal": {"5h": 25, "7d": 40}
  }
}
```

`modules/systray/routing_resolver.py`:

```
RoutingRules gains:
  account_caps: Mapping[str, Mapping[str, int]] = {}   # slug -> window key -> cap pct
```

- Absent / empty → account uncapped; the existing global `quota_exhausted_threshold_pct` continues to apply unchanged. Caps are additive, never a replacement.
Values are **percent remaining at which the account stops** (§ Vocabulary): `{"7d": 10}` means "stop zync2 when its 7d window has 10% left", i.e. at 90% used.

- Keys restricted to `"5h"` / `"7d"`; values `int` in `1..100`. Anything else → `ValueError` from `load_rules`, consistent with every other field there.
- A cap for a slug not in the registry is a validation error (same posture as `default` / `fallback_chain` slug validation).
- **`load_rules`'s v2 migration writer reconstructs the payload from named fields — `account_caps` must be added there or it is dropped on the next legacy rewrite.**
- The two default rule blobs in `modules/systray/systray_codex_switcher.py:40-52` seed `"account_caps": {}`.

### 2. Cap predicate — one per language, one contract

**`modules/systray/spend_cap.py`** — owns the remaining-based comparison for the Python side (resolver, card, dialog).

```
capped_windows(snapshot: AccountSnapshot, caps: Mapping[str, int]) -> frozenset[str]
  - window capped iff its used_pct is not None and (100 - used_pct) <= caps[window]
  - windows with no cap, or with a None used_pct, are never capped

describe_caps(snapshot: AccountSnapshot, caps: Mapping[str, int]) -> str
  - "capped(7d=8% left <= 10%)"; multiple windows comma-joined, 5h first; "" when clear

cap_reset_at(snapshot: AccountSnapshot, windows: Iterable[str]) -> str | None
  - earliest non-None reset among `windows`, as strict ISO-8601 UTC: "2026-08-06T14:23:05Z"
  - None when no breached window carries a reset instant
```

**`modules/harness/wrappers/lib/spend-cap.sh`** — the shell twin used by surfaces B and C, over the same on-disk contract. Deliberate, bounded duplication: a Python UI, a shell wrapper layer, and a JS engine share one JSON file; a per-dispatch subprocess into Python to answer a two-field comparison would be worse.

```
spend_cap_status <account>            # prints: capped|clear|stale|no-caps|unreadable
                                      # on `capped`, also exports:
                                      #   SPEND_CAP_WINDOWS  e.g. "7d"
                                      #   SPEND_CAP_RESET_AT e.g. "2026-08-06T14:23:05Z" (may be empty)
```

Reads `~/.systray-ai/health_cache.json` and both routing-rules files; honours the same staleness bound as `HealthSnapshotStore.read_fresh`. Boundary-inclusive `>=` is fixed by a paired test on both sides so they cannot drift.

**The ISO-8601 format is load-bearing and non-negotiable.** `v2/dispatch.js:498 parseResetAt` accepts only `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|±HH:MM)$` plus calendar validation. Anything else yields `null`, and a `null` resetAt makes `dispatch.js:68` return the failure instead of parking — the cap would silently degrade into a task failure. Epoch-seconds floats from the health snapshot MUST be rendered to that exact shape, seconds truncated (not rounded up past the reset).

### 3. Refusal — `RoutingResolver` (surface A)

- `_is_available(slug)` → also `False` when `capped_windows(snapshot, rules.account_caps.get(slug, {}))` is non-empty.
- `_describe_status(slug)` → emits `describe_caps(...)` when capped, **instead of** `quota-exhausted(...)`. A 10% cap must never render as "quota-exhausted" at 10% usage; the user would read that as a provider fault.
- `NoHealthyAccountError` carries the per-slug descriptions unchanged in shape, so the refusal reason is legible in the CLI error and in the tray.
- `fallback_chain` behavior is deliberately unchanged: a capped account is simply unavailable and the chain falls through. The ask is "refuse all dispatches **for this account**", not "halt the fleet".
- **`missing_health_is_available` must not apply to an account that carries a cap.** For a capped account, missing or stale health means *unavailable* — otherwise the router hands work straight back to the account whose in-flight dispatch was just killed.

**All candidates capped.** When cap exclusion empties the resolvable set, `cdx route` / `cld route` prints `{"ok":false,"detail":"rate-limited","reason":"all-accounts-capped","resume_at":"<ISO-8601>"}` and exits `75`. `resume_at` is the earliest cap reset across the capped candidates, omitted when none is known. This matches the wrapper status contract asserted in `wrappers/_contract-probe.sh:671-675` (`rc -eq 75` and a `"resume_at":"` field), so every existing consumer already reads it as "capacity returns, hold".

### 4. UI — `Set limits…`

**Menu.** `modules/systray/ui/account_card.py` `_MENU_ITEMS` gains `("Set limits…", "set_limits")`. Both the popover and the menu-shell fallback builders consume that tuple and key `_menu_action_widgets` off the action string, so both paths get the item with no further change.

**Callback.** `modules/systray/ui/view_model.py:122` `DashboardCallbacks` gains, matching the `(kind, slug)` shape of its siblings:

```
on_set_limits: Callable[[str, str], None]
```

**Dialog.** New `modules/systray/ui/limits_dialog.py`, modelled on `ui/settings_dialog.py` — same `_RESPONSE_SAVE = -5` / `_RESPONSE_CANCEL = -6` codes, same `_load_gtk_modules()` with the `_FallbackGtk` shim so the dialog is unit-testable headless.

```
LimitsDialog(slug: str, snapshot: AccountSnapshot, caps: Mapping[str, int], gtk_module=None)
  - window dropdown derived from the snapshot: primary_used_pct not None -> "5h",
    secondary_used_pct not None -> "7d". Never hardcoded; grok therefore shows "7d" only.
  - percent entry, labelled "Stop when remaining falls below: __ %": integer 1..100,
    clamped on read (settings_store coercer pattern). The label is load-bearing — it is
    what stops a remaining-based cap being read as a used-based one.
  - selecting a window pre-fills its existing cap, or blank when uncapped
  - shows the window's current REMAINING percent beside the field ("now: 12% remaining"),
    so a cap that fires immediately is visible before Save
  - Save -> {window: pct}; blank/zero clears that window's cap
  - Cancel -> no write
```

**Persistence.** Saving rewrites the provider's routing-rules file through the same atomic tempfile → `fsync` → `Path.replace` discipline as `ui/settings_store.py`, preserving every other field; only the edited account's `account_caps` entry changes. The dialog signals nothing further — parked tasks re-read the file themselves within one poll (§5).

**Legibility on the card.** The quota row for a capped window appends its cap to the value label (`"88% · stop at 10% left"`), so a capped account is visibly capped without opening the modal.

### 5. Stopping and resuming work — v2's park loop

Stopping and resuming is **not** a new daemon mechanism. `modules/harness/v2/dispatch.js` already implements exactly the needed behavior, for rate limits:

```
runDispatch (dispatch.js:14) — the park loop (dispatch.js:38-73)
  result = await runChild(childOptions)
  result.code !== 75                      -> return the result
  parseResetAt(rateLimitMetadata(result)) -> null, or parkCount >= maxParks -> return the result
  otherwise: parkCount += 1; emitParkEvent('parked'); await waitForWake(...)  -> retry same binding
```

`waitForWake` (`dispatch.js:510`) sleeps toward `resetAt` in ≤60s slices, emitting `heartbeat` park events, then returns and the loop re-attempts the same binding.

A parked task spends nothing, keeps its worktree and WIP untouched, and retries itself on wake. That *is* "stop the agent, keep the WIP, restart when the limit resets or the cap is removed". Every child dispatch runs inside its own `systemd-run --user --scope` unit (`v2-child-dispatch-<taskId>-<n>.scope`, `child.js`), while the coordinator and the task worktree live outside it — so killing the scope is precisely a "stop the worker, keep everything else" operation, with no process-group reasoning.

The work is to route cap breaches into this path, and to fix the three places where its wiring or bounds are wrong for caps.

**(a) Surface B — pre-dispatch, in the wrapper.** Every seat dispatch reaches a wrapper (`ca.sh` / `codex.sh` / `na.sh` / `grok.sh` / `pi.sh`). Before spawning its provider, each wrapper calls `spend_cap_status` (§2) for the account it will actually bill. `capped` → emit the standard rate-limited status through the existing `wrappers/lib/finalize.sh` choke point (`detail="rate-limited"`, `RESUME_AT` → `status.resume_at`, `finalize.sh:16-17,82-87`) and exit `75`, spawning nothing.

This is the universal gate: it needs no per-wrapper account plumbing beyond what each wrapper already knows, it covers non-harness wrapper invocations too, and `dispatch.js`'s `rateLimitMetadata` (`dispatch.js:477`) already reads `resume_at` — from `result.metadata`, or by scanning the child log backwards for the `status.detail === 'rate-limited'` line. No engine change is required for the pre-dispatch case.

**Account identity per wrapper.** `codex.sh` takes `--profile` (`codex.sh:117,128,293`). The three wrappers that today print `--profile ignored` bill a fixed identity, so their gate keys off that identity rather than a flag. Each wrapper names its billed account explicitly; no wrapper guesses.

**(b) Surface C — in-flight.** A dispatch already running keeps burning until it returns, because the loop only checks between attempts. Two additions close it:

`modules/harness/v2/child.js` — `runChild(settings)` gains an optional abort seam. `child.js` exports only `runChild`, so the seam belongs on its settings object rather than on `runScoped` internals, and it reuses the existing `killScope(unitName, signal)` (`child.js:277`) rather than adding a second kill implementation:

```
runChild({ ..., abort })        // abort: AbortSignal | undefined
  - on abort: killScope(unitName, 'SIGTERM'), then the existing escalation path
  - settles { code: 75, signal: 'SIGTERM', abortedByCap: true, metadata: { provider, resetAt }, ... }
    where provider/resetAt come from the abort reason
  - abortedByCap and killedByBudget are mutually exclusive and MUST be distinguishable.
    The budget path settles killedByBudget:true with signal SIGTERM and code null
    (verified on this machine; the 124 in the wrapper exit contract is applied further up,
    not at this seam), so `code` alone cannot discriminate — `abortedByCap` is the marker.
```

`modules/harness/v2/dispatch.js` — `runDispatch` starts a cap poller alongside each `runChild`, and stops it in the same place `liveness.stop()` is called:

```
runDispatch gains, alongside its existing options:
  capPoll        // () -> {capped, windows, resetAt} | null   (injectable; null/absent disables)
  capPollMs      // default 60_000
  maxCapParks    // default 0 = unlimited
```

On a capped poll it aborts the child with `{provider, resetAt}`. The resulting `code === 75` re-enters the existing park branch unchanged — so a cap stop parks, and a task never fails for being capped.

**(c) Park bounds — the two things a cap needs from the loop.**

1. **The cap park deadline is bounded, so resume needs no signalling channel.** A cap park's `resetAt` is `min(windowResetInstant, now + capPollMs)` — never the raw window reset. Every parked task therefore re-attempts at least once a minute, re-reads the rules file through the wrapper gate, and proceeds the moment the cap is cleared or raised. A re-attempt while still capped costs one wrapper start that spawns no provider.

   This deliberately does **not** use `wakeFile` / `consumeWakeNonce` (`dispatch.js:525`), which `run.js` never passes today (`run.js:584-585`, `742-743` pass `onParkEvent` but no `wakeFile`). `consumeWakeNonce` claims the nonce with an atomic `renameSync`, so one nonce releases exactly **one** waiter — with several agents held on the same capped account, a single "cap removed" signal would restart one of them and strand the rest. A bounded deadline releases all of them, needs no new file, no path convention, and no cross-process handshake between a Python GTK dialog and N node coordinators.

2. **Cap parks get their own counter.** `maxParks` defaults to 8 and is journal-persisted via `retryDisposition` (`journal.js:145`, `allowPark: state.parks < maxParks`), fed back as `initialParkCount: admission.parks`. With a ≤60s park deadline, a cap lasting more than eight minutes would exhaust that shared budget and convert the cap into a task failure. **Cap-originated parks therefore increment their own counter against `maxCapParks` (default unlimited) and never `parkCount`**, so a genuine rate-limit retry budget stays intact. Journal them as a distinct kind so they stay legible.

**Resume triggers** — both are the same code path, a park expiry followed by re-evaluation at the wrapper gate:
- *Cap cleared or raised* — released within `capPollMs` (≤60s).
- *Window reset* — released at the reset instant, or within `capPollMs` of it.

**Observability.** Cap parks journal through the existing `onParkEvent` → `rate-limit.<kind>` mapping with a cap-distinguishing field, so a cap-held run projects to `waiting` exactly as a rate-limit hold does — never `stalled`, never `failed`. The abort itself journals once with the breached windows and the kill outcome.

### 6. Health input and fail-safe direction

Both the systray and the wrapper gate read the health snapshot the systray already writes at `~/.systray-ai/health_cache.json` (`HealthSnapshotStore`), read-only, with the store's own `stale_after_s` bound.

Fail-safe is directional and asymmetric:

| Input state | Kill in-flight? | Refuse new dispatch/session? | Release a hold? |
|---|---|---|---|
| Fresh snapshot, cap breached | yes | yes | — |
| Fresh snapshot, cap clear | — | — | yes |
| Stale / missing / unparseable snapshot | **no** | **yes** (§3: capped accounts are unavailable without fresh health) | **no** |
| Routing-rules file unreadable or invalid | **no** | no (no caps are known) | **no** |

Killing live work is expensive and lossy, so it demands fresh evidence. Refusing a *new* dispatch is cheap and fully reversible, so it errs toward the cap. Releasing a hold always demands fresh evidence — never resume into a cap that may still be breached. Every degradation is journaled once per evaluation with its reason, so a silently disarmed limiter is visible rather than invisible.

## Data flow

```dot
digraph spend_caps {
  rankdir=LR;
  "Set limits… modal" -> "routing_rules.json\n(account_caps)";
  "health poller" -> "health_cache.json";

  "routing_rules.json\n(account_caps)" -> "RoutingResolver._is_available";
  "health_cache.json" -> "RoutingResolver._is_available";
  "RoutingResolver._is_available" -> "cdx / cld _resolve_account" [label="capped -> excluded\nall capped -> exit 75 + resume_at"];
  "cdx / cld _resolve_account" -> "Claude Code / codex session" [label="refused when capped"];

  "routing_rules.json\n(account_caps)" -> "wrappers/lib/spend-cap.sh";
  "health_cache.json" -> "wrappers/lib/spend-cap.sh";
  "wrappers/lib/spend-cap.sh" -> "finalize.sh\nrate-limited + resume_at, exit 75";
  "finalize.sh\nrate-limited + resume_at, exit 75" -> "v2/dispatch.js park loop";

  "wrappers/lib/spend-cap.sh" -> "v2 cap poller" [label="same status contract"];
  "v2 cap poller" -> "child.js abort -> killScope" [label="code 75 + resetAt"];
  "child.js abort -> killScope" -> "v2/dispatch.js park loop";

  "v2/dispatch.js park loop" -> "retry same binding\nWIP intact" [label="park expiry\n<= capPollMs"];
  "retry same binding\nWIP intact" -> "wrappers/lib/spend-cap.sh" [label="re-evaluate"];
}
```

## Error handling

- **Invalid cap in the file** — `load_rules` raises `ValueError` as for every other malformed field. The wrapper gate treats an unreadable/invalid rules file as `unreadable` → no cap → dispatch proceeds, journaled (§6).
- **Cap set below current usage** — takes effect immediately: refused at once, in-flight killed within one poll. Intended; the modal shows current usage next to the field.
- **Account with no windows yet** (both used_pct `None`) — dropdown empty, Save disabled; caps cannot be set on an account with unknown usage.
- **Abort fails** (scope already gone, or refuses to die) — the escalation path in `child.js` already covers this and reports `teardownEvidence`; the settled result is still coerced to 75, so the task parks rather than failing, and the wrapper gate blocks the next attempt regardless.
- **`resetAt` unparseable or absent** — never emit one that `parseResetAt` rejects; a cap always emits `min(windowReset, now + capPollMs)`, which is finite by construction. A `null` resetAt would drop the task straight to failure at `dispatch.js:68`.
- **All accounts capped** — surface A exits 75 with `resume_at`, which is the hold path, not a run failure.
- **A parked task cancelled by the user** — the park is an `await` inside the dispatch loop; existing run cancellation tears it down unchanged.

## Testing

- `spend_cap.py`: table-driven on **remaining** — `remaining == cap` (capped, boundary inclusive), `remaining == cap + 1` (clear), `remaining == 0`, `None` used pct, no cap for the window, both windows capped, `describe_caps` ordering and empty string.
- `cap_reset_at`: epoch float → exact `YYYY-MM-DDTHH:MM:SSZ`, truncating not rounding; earliest of multiple breached windows; `None` when no reset is known. **Assert the output against `parseResetAt`'s accepted shape**, not merely against a regex written twice.
- **Inversion regression, both languages**: an explicit named test in `spend_cap.py`'s suite *and* in `spend-cap.sh`'s, using the live `zync2` shape — `secondary_used_pct = 88` with `{"7d": 10}` is **not** capped (12% left), `= 90` **is** capped (10% left), and `= 10` is **not** capped. Named so it survives refactors.
- `spend-cap.sh`: same boundary table as the Python predicate, plus status classification for `stale` / missing / `unreadable` inputs and the union of the two rules files.
- `routing_resolver`: capped account skipped and the chain falls through; status string is `capped(...)` not `quota-exhausted(...)`; `missing_health_is_available` does not admit a capped account; uncapped accounts unchanged (`quota_exhausted_threshold_pct` regression).
- `load_rules`: accepts valid `account_caps`, rejects bad window keys / out-of-range / unknown slugs, and **round-trips `account_caps` through the v2 migration rewrite**.
- `command_router`: `cld`/`cdx` refuse a capped account; all-capped prints `detail:"rate-limited"` with `resume_at` and exits `75`; an open session is not disturbed.
- Wrapper gate: extend `wrappers/_contract-probe.sh` — a capped account makes each wrapper exit `75` with a `"resume_at":"` field and **spawn no provider process**; an uncapped account is byte-identical to today's behavior.
- `limits_dialog`: headless via `_FallbackGtk` — dropdown derived from snapshot fields (grok → `7d` only), clamping, existing-cap pre-fill, clear-on-blank, cancel writes nothing.
- `account_card`: the `set_limits` action appears in both the popover and the menu-shell fallback and dispatches with `(kind, slug)`.
- `child.js`: `abort` kills the scope and settles `code 75 / abortedByCap`, distinct from the budget path's `killedByBudget`; abort after natural exit is a no-op.
- `dispatch.js`: injected `capPoll` — a capped poll aborts the child and the result enters the park branch (not the failure return); cap parks do not increment `parkCount` and are not bounded by `maxParks: 8`; a cap park's `resetAt` never exceeds `now + capPollMs` even when the window reset is days away; `capPoll` absent leaves the loop byte-identical.
- End-to-end park/resume: a capped account parks a dispatch, the task worktree remains present and dirty (**the WIP-preservation assertion**), clearing the cap releases it within `capPollMs`, and the retry runs on the same binding. **Two tasks parked on the same capped account both resume from a single cap clear** — the regression that rules out a one-shot wake nonce.
- Journal projection: a cap-held run renders as `waiting`, not `stalled` or `failed`.

## Architecture Decisions

- **Caps live in the existing routing-rules files, not a new store.** The resolver already loads them and `quota_exhausted_threshold_pct` is the precedent; a new store would mean a second loader in the enforcement path. Accepted.
- **`spend_cap.py` is a separate module from `limit_warning.py`, despite both being "percent of window" predicates.** Merging them would put two opposite comparison conventions behind one import and invite exactly the inversion this spec exists to prevent. Accepted as a deliberate non-collapse.
- **Stopping and resuming reuses v2's park loop; no new daemon, no salvage push, no relaunch.** The loop already holds without spending and keeps the worktree intact. A cap is a capacity signal with a different source, so it converts to exit 75 and rides the same path. Accepted.
- **Resume is a bounded park deadline, not a wake signal.** `consumeWakeNonce` releases exactly one waiter per nonce, so a "cap removed" signal would restart one agent and strand the rest; it also needs a path convention `run.js` does not have and a handshake between a GTK dialog and N coordinators. A ≤60s park deadline releases every held task, adds no file and no channel, and costs one provider-less wrapper start per task per minute. Accepted.
- **The universal gate is in the wrapper layer, not the engine.** Every dispatch of every provider passes through a wrapper; the wrapper knows the account it bills, `finalize.sh` already emits the tested `rate-limited` + `resume_at` + 75 contract, and non-harness invocations get covered for free. Putting the gate in `v2/run.js` instead would cover only harness runs and only runs launched with `--account`.
- **Cap parks get their own counter.** Sharing the journal-persisted 8-park budget would convert a cap with no known reset instant — the live state of every 5h window today — into a task failure, the opposite of the requirement.
- **A capped account refuses new sessions but does not kill an open interactive session.** WIP preservation and auto-restart are meaningful for dispatched work and meaningless for a REPL; killing one is data loss, not a limit.
- **Rejected: a per-dispatch account chain in the engine.** v2 has no `cdx route` call and no per-dispatch account attribution — `--account` is run-level and reaches the wrapper as `--profile` (`seats.js:218`). Reintroducing a chain would be a new feature, not a cap.
- **Rejected: anything touching `modules/harness/src/`.** Dead by user order, untracked, absent from the live bundle `0.1.116`.
- **Rejected: a systray-side supervisor shelling out to `deckctl agents stop --all`.** Account-blind (kills every workload on every host) and has no resume.
- **Hold state is derived from the live dispatch loop, not persisted.** A park is an `await` inside the process that owns the task; there is no cross-process state to reconcile, and run cancellation tears it down for free.
- **The cap does not halt the fallback chain.** Per-account scope is what was asked; a fleet-wide halt would be a different feature with a different control.
