Explains every value object and ADT in src/claudeloop/domain/ — CapacityState (Available/WindowExhausted/CreditsExhausted/AuthenticationFailed/BackendMisconfigured), TurnSignals classification, CompletionVerdict (Done/Continue/Blocked), the wait-probe policy in waiting.py, Budget/BudgetLedger, and the run-loop state machine in loop.py. Use this whenever reading, modifying, or extending anything in src/claudeloop/domain/ or tests/domain/, whenever the user asks about rate-limit classification,...
Installs into .claude/skills of the current project.
Are you the author of Claudeloop Domain Model?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/the-vibey-project-claudeloop-domain-model)
---
name: claudeloop-domain-model
description: Explains every value object and ADT in src/claudeloop/domain/ — CapacityState (Available/WindowExhausted/CreditsExhausted/AuthenticationFailed/BackendMisconfigured), TurnSignals classification, CompletionVerdict (Done/Continue/Blocked), the wait-probe policy in waiting.py, Budget/BudgetLedger, and the run-loop state machine in loop.py. Use this whenever reading, modifying, or extending anything in src/claudeloop/domain/ or tests/domain/, whenever the user asks about rate-limit classification, credits vs. rate limits, capacity states, completion detection, the waiting/backoff policy, or the run-loop state machine, and whenever adding a new domain type or branch. Make sure to consult this before touching domain/classify.py, domain/waiting.py, domain/completion.py, or domain/loop.py — the ordering of branches in each is deliberate and tested, and an out-of-order edit silently reintroduces bugs this project was specifically built to fix.
---
# claudeloop domain model
> **Codex skill mirror** of `.claude/skills/claudeloop-domain-model/SKILL.md`. When this guidance changes, update Claude skill, Cursor rule, and `.agents/skills/` in the same PR.
Everything in `src/claudeloop/domain/` is a frozen dataclass or a closed
union of them, has zero third-party imports, and requires 100% test
coverage. This skill is the map; for full prose explanation with rationale
see `docs/architecture/domain-model.md`.
## `capacity.py` — CapacityState
```python
CapacityState = (
Available | WindowExhausted | CreditsExhausted | AuthenticationFailed | BackendMisconfigured
)
```
**The single most important fact in this codebase**: `CreditsExhausted` has
**no `resets_at` field at all** — not `None`, the type literally doesn't
carry one — because waiting for a clock can never fix an empty credits
balance. `WindowExhausted` carries `resets_at: datetime | None`.
`is_waitable(state)` is `False` only for the two terminal states,
`AuthenticationFailed` and `BackendMisconfigured` (the backend, as configured,
cannot serve the run — unreachable, model missing, model failed to load; it
needs a human, and `run`/`resume` exit 78 for it).
**Never conflate these two states** and never add a `resets_at` field to
`CreditsExhausted` "for consistency" — that would silently reintroduce the
exact bug (`claudeloop` replacing sleeping-an-hour-forever with something
smarter) this project exists to fix.
## `classify.py` — TurnSignals → CapacityState
`classify(signals: TurnSignals) -> CapacityState`. Reads **three
independent SDK signals** (a `RateLimitEvent`, `ResultMessage
.api_error_status`, `AssistantMessage.error`) — never trust
`RateLimitEvent` alone; it's reportedly dropped on some adapter paths.
**Ordering is load-bearing, in this exact sequence:**
1. `assistant_error == "authentication_failed"` → `AuthenticationFailed`, checked FIRST, outranks everything.
2. `assistant_error == "billing_error"` → `CreditsExhausted`, before any window path.
3. `backend_misconfiguration(signals)` → `BackendMisconfigured`: `assistant_error == "model_not_found"` on any backend; and only when `local_backend` (a profile with `base_url`) a 404, a 500 (model failed to load), `invalid_request` "Prompt is too long" (`context_window` too small), or an `assistant_error` with no HTTP status whose text names a refused/unreachable connection. Always needs a real error signal — never text alone.
4. `local_backend` and `api_error_status == 503` (a full local queue) → `WindowExhausted(rate_limit_type="local", resets_at=None)` — the one local error a clock fixes.
5. `rate_limit_status == "allowed_warning"` → `Available` (NOT a rejection — this is the exact false positive that caused multi-day cooldowns in the legacy script).
6. A rejection signal present → split further: credit signals (`error_code == "credits_required"`, `disabled_reason == "out_of_credits"`, a set `overage_disabled_reason`) win over a stray `resets_at` — check credits BEFORE falling through to `WindowExhausted`.
7. Anything else rejected → `WindowExhausted`, falling back to `rate_limit_type="unknown", resets_at=None` if thin.
If you're editing `classify.py`, preserve this order and re-run
`tests/domain/test_classify.py` — every branch above has a dedicated test
asserting it, including the adversarial case where a `resets_at` is present
alongside a credits signal (credits must still win).
## `completion.py` — CompletionVerdict
```python
CompletionVerdict = Done | Continue | Blocked
```
Primary signal: `StructuredVerdict` from `ClaudeAgentOptions.output_format`
(`{complete, remaining_work, blocked_on, summary}`). `blocked_on` outranks
`complete` — never let a turn claim both — and **terminates the run**. It is
only for true external/human blockers; waitable self-started work belongs in
`remaining_work` with `blocked_on: null` (schema descriptions + autonomy
prompt reinforce this). Fallback (only when `structured is None`):
substring-match `CLAUDELOOP_TASK_FULLY_COMPLETE` (or the configured marker)
in raw output text — unless `marker_fallback=False` (a local backend's default,
`done_marker_fallback`: a model that cannot call tools once typed the marker
and "completed" work it never did) — this is a fallback, not the primary path, and must stay
that way; the substring approach has two documented failure modes (collision
with user prompt text, truncation inside a limit message).
## `waiting.py` — WaitPolicyConfig & next_probe_instant()
`next_probe_instant(state, *, now, started_waiting_at, probe_count, config)
-> datetime`. **Never returns a duration to sleep — always the next instant
to probe.** Behavior differs by `CapacityState` type:
- `CreditsExhausted` — exponential backoff, `credits_probe_interval`
(default 120s) to `credits_probe_ceiling` (default 600s). **Compute the
backoff in float seconds and clamp to the ceiling BEFORE constructing a
`timedelta`** — `interval * factor**probe_count` unclamped overflows
`timedelta`'s magnitude limit at realistic probe counts. A Hypothesis
property test caught this during development; don't reintroduce it.
- `WindowExhausted(resets_at=X)` — `min(X + reset_grace, now +
window_probe_interval)`. Never trust a far-future `resets_at` alone; the
interval bound is what catches an early overage lift or credit top-up.
- `WindowExhausted(resets_at=None)` — falls back to `window_probe_interval`.
`config.max_wait`, when set, clamps every candidate to
`started_waiting_at + max_wait`; `wait_exceeded()` is the paired "give up"
check.
**Any new numeric field on `WaitPolicyConfig` needs a `__post_init__`
validation AND a Hypothesis property test** covering its invariant across
the full input space — not just hand-picked examples. See
`tests/domain/test_waiting.py` for the existing property tests as a
template.
## `budget.py` — Budget, BudgetLedger
Immutable. `spend_turn()` / `spend_attempt()` return a **new** ledger, never
mutate. `any_exhausted` ORs `turns_exhausted | dollars_exhausted |
attempts_exhausted`. An unset cap (`None`) is never exhausted.
## `loop.py` — the run-loop state machine
`Phase = PREFLIGHT | RUNNING | WAITING | PROBING | COMPLETE | FAILED`.
Three pure decision functions:
- `decide_preflight(state, capacity, *, now)` — before spending the first
real turn.
- `decide_after_turn(state, *, capacity, verdict, now)` — **the single most
important invariant in the whole codebase**: capacity is checked BEFORE
verdict, always. A `Done` verdict on a turn that also hit a rejection is
discarded — see `test_after_turn_limit_outranks_completion_claim`. Never
reorder this check.
- `decide_after_probe(state, capacity, *, now, config)` — after a throwaway
probe while waiting.
`Decision = SendTurn | RunProbe | ScheduleProbe | Finish` — a closed union
the `application/runner.py` executor pattern-matches
exhaustively, never partially.
## `plan.py` / `session.py`
`WorkPlan.parse(markdown)` extracts checkbox items (`- [ ]` / `- [x]`, both
bullet styles, either case). `with_items_marked_done(frozenset)` reconciles
a turn's `remaining_work` back against the plan. `SessionSelector =
PlanFileSelector | ExplicitSessionSelector | MostRecentSessionSelector` —
the legacy script's three input modes as a closed union instead of
if/elif on optional fields.
## Full reference
`docs/architecture/domain-model.md` (complete prose walkthrough),
`docs/architecture/run-loop-state-machine.md` (the state machine in detail,
including the credit-top-up worked example),
`docs/architecture/decisions/0003-*.md` through `0004-*.md` (why
`CreditsExhausted` is distinct, why probing beats sleeping).