# LIMITS.md — caps, their enforcers, and the local build budget

Two budgets in this repository. The **runtime budget** is the twelve hard caps from `docs/CANON.md` section 3, inherited from `docs/GATES.md` section 6. The **local build budget** is what a person may spend on one machine to build and verify this tree — one network step, nothing else.

Rule: a cap without an enforcer is a hope. Every runtime row below names the file and the symbol that refuses to exceed it.

---

## 1. Runtime caps and their enforcers

| # | Resource | Cap | Constant | Enforcing file | Symbol | Action at the cap | State |
|---|---|---|---|---|---|---|---|
| 1 | Tokens per job | 400000 | `src/caps.ts` :: `CAPS.tokensPerJob` | `src/policy.ts` | `checkBudget` | stop model calls, finish from receipts | code present |
| 2 | Wall time per job | 90 min (5400 s) | `src/caps.ts` :: `CAPS.runWallClockS` | `src/policy.ts` | `checkBudget` | stop improve, keep the last approved patch | code present |
| 3 | Wall time per command | 600 s | `src/caps.ts` :: `CAPS.wallClockS` | `sandbox/runner.py` | `run` (timeout path) | kill the process group, record a timeout | PENDING, B-1 |
| 4 | Sandboxes per job | 6 | `src/caps.ts` :: `CAPS.sandboxesPerJob` | `src/policy.ts` | `decide` | stop the job, require a new job id | code present |
| 5 | Concurrent sandboxes | 2 | `src/caps.ts` :: `CAPS.concurrentSandboxes` | `src/policy.ts` :: `decide` + `resolveLimits`, `src/tools/scan-codebase.ts` :: `ScanOptions.concurrency`, `cordis.patch.yml` | `resolveLimits` clamps `config.maxConcurrentSandboxes`, the gate reserves `min(cost, limit)`, and the batch pool runs at exactly that injected limit | queue the request | enforced + `pool_limit_ok` |
| 6 | Spend per job | 5 USD | `src/caps.ts` :: `CAPS.spendUsdPerJob` | `src/policy.ts` | `checkBudget` | stop the job, ask the operator | code present |
| 7 | Spend per month | 200 USD | `src/caps.ts` :: `CAPS.spendUsdPerMonth` | `control-plane/vultr-api.sh` | (shell guard) | block new jobs until the next month | PENDING, B-3 |
| 8 | Disk per sandbox | 8 GiB | `src/caps.ts` :: `CAPS.diskGb` | `sandbox/runner.py` | `BUDGET` | kill the run and roll back | PENDING, B-1 |
| 9 | Memory per sandbox | 4096 MiB | `src/caps.ts` :: `CAPS.memoryMb` | `sandbox/runner.py` | `Caps` | out-of-memory kill and rollback | PENDING, B-1 |
| 10 | CPU per sandbox | 2 vCPU | `src/caps.ts` :: `CAPS.cpus` | `sandbox/runner.py` | `Caps` | throttle, record a warning | PENDING, B-1 |
| 11 | Processes per sandbox | 256 | `src/caps.ts` :: `CAPS.pids` | `sandbox/runner.py` | `Caps` | kill the process group, `E_PROC_LIMIT` | PENDING, B-1 |
| 12 | Retries per stage | 2 | `src/caps.ts` :: `CAPS.retriesPerStage` | `src/policy.ts` | `decide` | escalate to a human at gate G2 | code present |

`src/caps.ts` freezes the table with `Object.freeze` (line 4) and `validateCaps` (line 46) throws rather than accept a value above the ceiling. The `caps_ok` check in `scripts/verify.mjs` compares `src/caps.ts` against the five sandbox caps `{cpus: 2, memoryMb: 4096, diskGb: 8, pids: 256, wallClockS: 600}` and requires the literals `4096`, `256` and `600` to appear in `sandbox/runner.py`. That check is the reason the CPU cap is 2 and not 1: `docs/CROSSCHECK.md` line 86 resolves the conflict that way.

Each cap maps to exactly one error code through `src/caps.ts` :: `LIMIT_CODES` and `limitCode`: `E_TIMEOUT` for wall-clock, `E_MEM_LIMIT` for memory, `E_PROC_LIMIT` for CPU and pids, `E_DISK_LIMIT` for disk, `E_BUDGET` for tokens, spend, sandboxes, concurrency and job wall time, and `E_GATE_FAIL` for retries.

### What a cap never does

| Rule | Statement |
|---|---|
| L-1 | The agent never raises a cap. A human does, at gate G2, and the approval id lands in a receipt row. |
| L-2 | A cap breach destroys the microVM. There is no path from `ROLLBACK` back to `RUNNING`. |
| L-3 | A cap is a number from this table. A cap inferred from a document that disagrees with `docs/CANON.md` section 3 is void. |
| L-4 | Two counters count sandboxes: `src/policy.ts` :: `Counters.perRun` against 6 per job, and `Counters.running` against 2 concurrent. Neither is advisory: a batch charges one unit per command on both, and `tools/result` releases exactly what the call reserved (`src/index.ts` reservation queue), never a slot for an uncharged call. |
| L-5 | `docs/GATES.md` section 6 has 11 rows and no process row; the process cap of 256 comes from `docs/SKELETON.md` through the `docs/CROSSCHECK.md` line 86 resolution and appears here as row 11. Twelve caps, not eleven. |

---

## 2. Local build budget

One machine, no Vultr VM, no `/dev/kvm`, no credential. This is the whole budget.

| Step | Command | Network | Prerequisite | What it proves |
|---|---|---|---|---|
| 1 | `npm install` | **yes — the only network step** | Node 20+ | installs `typescript`, the single devDependency in `package.json` |
| 2 | `npm run build` | no | step 1 | `scripts/build.mjs` emits `dist/` |
| 3 | `npm test` | no | step 1 | `scripts/verify.mjs` runs every offline check and exits non-zero on any failure |
| 4 | `node scripts/gate.mjs all` | no | none | the file and contract-shape gate; prints `GATE_OK` when every check passes |
| 5 | `python -m compileall -q sandbox tests` | no | Python 3 | every Python file parses |
| 6 | `python -m tests.runner` | no | Python 3 | prints `TESTS passed=N failed=N skipped=N`; containment cases are skipped without `/dev/kvm` and never counted as passed |

**No test framework is installed and none may be required.** `pytest`, `pytest-bdd` and `hypothesis` never appear on the local build path. `tests/runner.py` is a standard-library harness, and `tests/conftest.py` exists only so the same modules stay collectable if pytest is ever present on a machine. That is why step 6 works on a laptop with a bare Python.

**Network statement.** Steps 2 to 6 open no socket. `scripts/verify.mjs` imports only `node:fs`, `node:os`, `node:path`, `node:child_process`, `node:url` and `./canon.mjs`. The one exception inside step 3 is `ts_compile`, which shells out to `npx tsc` **only if** `node_modules/typescript` already exists; when it does not, the check records `SKIP` and the build continues. So a machine with no `npm install` still gets a meaningful `SUMMARY`, just without the typecheck.

Windows and Linux both run these six commands unchanged. `scripts/verify.mjs` normalises separators with `replace(/\\/g, '/')` and builds paths with `path.join`; `python -m tests.runner` is invoked as a module so it works from any working directory inside the repository.

### What this budget does not cover

`msb`, `/dev/kvm`, the OCI image pull, nginx, TLS, systemd and every model call sit outside it. Their commands and pass conditions are in `docs/BLOCKED-EXTERNAL.md`. A green local build is evidence that the code parses, the names are canonical, the caps agree, and the receipt chain links. It is **not** evidence that anything was contained. Containment is B-4, and B-4 needs a KVM host.

---

## 3. Cost budget, in the unit the operator pays

| Line | Rate basis | Cap | Enforcer | State |
|---|---|---|---|---|
| Model spend per job | Vultr Serverless Inference, metered per token | 5 USD | `src/policy.ts` :: `checkBudget` | code present |
| Model spend per month | same meter | 200 USD | `control-plane/vultr-api.sh` | PENDING, B-3 |
| Compute | the single VX1 instance, billed while it exists | outside the 200 USD line | `vultr-cli instance delete` at the end of the demo, `docs/BLOCKED-EXTERNAL.md` B-3 | PENDING |

No rate, no per-token price and no hourly figure appears in this file, because none was measured. The only money numbers here are the two caps from `docs/CANON.md` section 3. A cost estimate that is not a measured invoice is a guess, and this repository does not print guesses.
