# Row 125 (#1619): token limits per instance, model, provider and key. Design note Dewey, 2026-10-18. Brief: docs/plans/2026-10-14_prd-1.2-build.md § "Token limits per instance, model, provider and key", blob 6ff35c81d36c. Base 70504d23a. Reviewer Filbert. Requirements: REQ-LIMIT-3, REQ-LIMIT-4. Row claimed in-progress, rev 1766. This note answers Sage's four questions (sage-0743) before any code. Question 2 needs a ruling: the kind needs a change. Nothing is built until Sage answers. ## 1. Unknown turns: "a check counts known records only" Row 124 writes one `usage_records` row per (run, turn, source, phase). A turn is **known** for a source when that source's latest row for the turn is `phase='end'` and `status='known'`. Anything else is **unknown**: an `unknown` end row (source failed, transcript cut, `no-source` when no source is switched on, `baseline-unknown`), or a begin row with no end row yet. The check's count for a limit is the sum of input + output + cache_write + cache_read over the known end rows that match the subject and whose `at` is at or after the limit's `since` (decision 142, rulings 6 and 7). An unknown turn adds **nothing** to the count and never makes a check refuse or stop. It is counted separately as `unknownTurns`: the number of distinct (run, turn) pairs with an unknown row for that subject since `since`. This is the same per-turn selection `usage.mjs` `view()` already uses (latest row per run, turn and source), factored out so the Settings readout and the check count the same rows. What the user sees: - Settings and `mosaic limits` show, per token limit: the limit, the count, each kind (input, output, cache write, cache read), and "N unknown turns" next to the count, so a limit never looks further off than it is (REQ-LIMIT-4). - With no usage source switched on, every turn is `no-source`: the count stays 0, the unknown count grows by one per turn, nothing is refused or stopped, and each turn is logged `usage-unknown` (already row 124's behaviour, tested again here through the check). - A refusal or stop log entry carries the limit, the count and the unknown count. Two edges I'll document, not change: - A turn that started before a reset and ended after it counts in full after the reset (the end row's `at` decides). - Two sources reporting the same turn both count, as `view()` sums them today (my row 124 note 4). That errs toward stopping early. Only `transcript` ships. **API key subject.** `usage_records.account` is null until row 126 names accounts. A key limit counts known rows whose `account` equals the key's name. Until 126 fills `account`, every turn in the business is unattributed: the key limit shows count 0 and every turn as unknown, and Settings says no records name a key yet. Key names are free names (`^[a-z][a-z0-9-]*$`) in this row. Row 126 checks them against the named accounts. ## 2. Is the `limit-check` kind enough? No. It needs an export and a version bump kinds.mjs today: `limit-check`, interface 1, exports `mayLaunch` and `mustStop`, switch `list`. Those two cover the launch refusal and the end-of-turn stop. They don't cover two acceptance lines: - "A limit that no switched-on check enforces shows in Settings and `mosaic limits` as not enforced." - REQ-WEB-1 and REQ-LIMIT-4: Settings shows the usage counted against each limit, and the unknown count next to each total. With only `mayLaunch` and `mustStop`, core can learn which limits a check enforces, and their counts, only by asking about a particular launch or session. A limit whose subject isn't running or launching never gets an answer. The workarounds all break a rule: - core recounts the limits itself, which puts token-limit's semantics in core and breaks "no change to the launcher or bus" for row 126's daily quota; - core treats limits as enforced by name (`token-limit`), and REQ-PLUG-1 forbids naming a plug-in in core; - core calls an undeclared optional export. **Recommendation:** add a third export, `report`, and bump `limit-check` to interface version 2. No limit-check module exists, so nothing outside the tests breaks. The only v1 users are the fixture `cap` in packages/plugins/tests/helpers.mjs (loader.test.mjs) and the business plugin-settings tests, which switch on names only. The fixture moves to v2 with the three exports. kinds.mjs gets a comment with the call shapes, as search-source has. Proposed contract (v2). All three are pure functions of what core passes, with no store or file access of their own: ``` mayLaunch({ business, limits, subject, records, now }) → { allow: true } | { allow: false, hit } mustStop({ business, limits, subject, records, now }) → { stop: false } | { stop: true, hit } report({ business, limits, records, now }) → [{ path, limit, since, count, kinds, unknownTurns }] hit = { path, limit, count, unknownTurns } subject = { instance, provider, model, account } (account null until row 126) records = per-turn rows as in §1 (at, run, turn, source, instance, provider, model, account, status, input, output, cacheWrite, cacheRead) ``` `limits` is the organization file's `launch.limits` as validated. A limit `path` listed in no switched-on check's `report` shows as not enforced. A check that throws or returns a malformed answer counts as a refusal for `mayLaunch` (fail closed). For `mustStop`, it stops the session at the end of its turn, and the failure is logged. If Sage would rather it log and not stop, that is a one-line change. I need a ruling. **Alternative without a bump:** keep v1 and drop the per-limit count and "not enforced" from Settings for row 125. That fails the brief's acceptance line, so I don't recommend it. Per Sage's instruction I stop here until Sage rules on the bump. ## 3. Where limits are stored and written **Store: the organization file only.** No bus schema change, no migration after 3h, nothing for Mos's re-pin plan to migrate. Counts come from row 124's `usage_records` at ask time, filtered by `since`. **Organization-file schema** (packages/business/src/launch.mjs `checkLimits`, additive; files without `tokens` stay valid): ```json "launch": { "limits": { "total": 8, "providers": { … }, "models": { … }, "tokens": { "instances": { "coder-1": { "limit": 5000000, "since": "2026-10-18T16:00:00.000Z" } }, "providers": { "anthropic": { "limit": 0, "since": "…" } }, "models": { "anthropic": { "claude-opus-…": { "limit": …, "since": "…" } } }, "keys": { "max-plan": { "limit": …, "since": "…" } } } }} ``` `limit` is a whole number of 0 or more with no upper bound (Q150). 0 refuses every launch of that subject, because the count is at or over the limit. `since` is a canonical timestamp. Any subject may be absent, which means no limit. **Write path: step 5's, unchanged in shape.** - packages/business/src/launch-settings.mjs `set()` stamps `since` itself; the caller never chooses it. - A new token-limit subject gets `since = now`. - A changed `limit` value keeps its `since`. - A caller-supplied `since` that differs from the stored one is refused (`invalid-request`), so only a reset moves it. - **The reset** is a new optional `reset` key in the same update: a list of limit paths, for example `["tokens","instances","coder-1"]`. `set()` writes `since = now` for each. An unknown path is refused. The organization writer's change log records the actor and the old and new `since`. That is the "logged" the brief asks for. - The broker's `ORGANIZATION_KEYS` (packages/bus/src/broker.mjs:135) and the harness copy (packages/harness/src/tools.mjs:27) gain `reset`. The test that the two lists match already exists. - The human verb stays `limits.set`. - The chief of staff's verb stays `organization.set` (row 123). Its typed tool `change_organization` (packages/harness/src/tools.mjs:172, #1628) gains a `reset` parameter, and the description says what a reset does. - No new broker verb. - `mosaic limits` (packages/cli/src/limits.mjs) gains `reset ` and shows the token limits with count, kinds, unknown turns and "not enforced". - Settings: packages/webui/src/serve.mjs and the Settings view under packages/webui/src/public/ show the same, and send the reset through the existing send checks. Landing this needs Mos's decision 123 go. **Asking the checks.** - A new bus module, packages/bus/src/limits.mjs, holds one loader per business. - At each ask it re-reads the organization file through the settings reader, as the launcher re-reads limits per launch. It then calls `loader.load(plugins)` and asks `loader.modules('limit-check')`. A plug-in switch therefore applies at the next ask, with no restart. - Records come from the per-turn selection factored out of `usage.mjs`. - No core file names `token-limit`. A test greps launcher.mjs and limits.mjs for the name, as usage-runtime.test.mjs:33 does for the transcript source. **Launcher (packages/cli/src/launcher.mjs `check()`, line 217).** - After `limitHit`, the launcher asks a new launch op, `mayLaunch`, through `host.op({ op: "mayLaunch", business, instance, provider, model })`. That op is added to `LAUNCH_OPS` in packages/bus/src/process.mjs and to runtime.mjs, which calls limits.mjs. - A refusal is `Refusal("token-limit-reached")` carrying the `hit`. It is logged through the launch log and `refuse`, as `capacity-full` is today. - With no limit-check switched on, `mayLaunch` allows. - `check()` runs twice per launch (before checkouts and again at line 252). Both run the ask. **Out of this row:** no `daily-quota` (row 126), no change to Jason's file, and no ceiling anywhere. ## 4. The end-of-turn stop **Where it is asked.** - The runner calls `usage.end` once per turn, after `runTurn` has returned and the harness child has exited (packages/harness/src/runner.mjs:390). - In the broker, `#requestUsage` (packages/bus/src/broker.mjs:1182) calls `usage.collect(s, 'end', turn)`. - After the end rows are committed, it asks limits.mjs `mustStop` for the session's subject. That is the call site: once per turn, after the turn. **How it is enforced without cutting a turn.** - `usage.end` itself does not end the claim. If it did, the turn's own `message.send` RESULT and `message.read` (runner.mjs:392-397) would fail `not-holder`, the reply would be lost, and the message would be redelivered and run again. - A stop answer is logged with the limit, count and unknown turns, and the broker keeps it as a pending stop for that run. - The broker ends the claim on that run's next `message.receive` or `state` call. - Role runs end with op `revoke`. - Thread runs end with op `release`, because row 121 never revokes a thread claim. - `by` is `limit-check`, and `reason` carries the hit; `role_claims.reason` and `thread_claims.reason` already exist. - That same call then fails `not-holder`, which runner.mjs:48 treats as SESSION_GONE, so the runner exits and the launcher's normal end path runs. **Why it can't stop a turn midway.** - The runner calls `message.receive` (runner.mjs:349) and `state` (runner.mjs:371) only between turns: before reading new work, and before starting each message's turn. - Neither is called while `runTurn` runs (runner.mjs:386-388). - The only code that ends the claim for a limit lives in those two verb paths. - So the earliest a stop lands is after the current turn's reply and read. The 900-second turn clock bounds the overrun (ruling 8). Tests: - With a stub harness, the turn that crosses the limit still sends its RESULT and marks the message read. - The next `state` or `message.receive` fails `not-holder` with the claim row's reason. - A second delivered message in the same batch never starts a turn. **A pending stop lives in memory.** - A bus restart ends the host's sessions anyway (launcher `leftover`), so nothing runs on past it. - If a session somehow survives, the next `usage.end` asks again. ## Tests and mutants (plan) Tests: - Each subject kind (instance, provider, model, key): under the limit, at it, and over it, for both `mayLaunch` and `mustStop`. - The reset: `since` moves, the count drops, the change log has the actor and old and new `since`. A caller-supplied `since` is refused. The chief resets through `organization.set`. - Refusal: the launch is refused and logged with the limit and the count. - No limit-check switched on: the launch proceeds. - A fixture check that refuses: the launch is refused, with no core change. - The launcher reaches `token-limit` only through the loader (source grep plus a fixture swap). - A check counts known records only: a mix of known, unknown and begin-only rows. - No usage source switched on: every turn is unknown and nothing stops. - "Not enforced" in Settings and `mosaic limits` when no switched-on check reports a limit. - The end-of-turn stop, as in §4. Mutants: one per check, listed in the packet. Each must fail a test. Runs need Sage's slot. I'll ask before any. ## Rulings (sage-0746, lead decision 150): what the build follows **Q2, the bump: approved.** `limit-check` goes to interfaceVersion 2, with exports `mayLaunch`, `mustStop` and `report`. Conditions: - packages/plugins/README.md's kind table and its prose about the exports change in the same candidate. - The loader refuses a v1 limit-check module with `interface-version`. Tested with a v1 fixture. - "Not enforced" is computed in core from `report`'s output: a limit that no switched-on check lists is not enforced. Core never names `token-limit`. Tested with zero checks switched on, and with a fixture check whose report leaves out one limit. **Fail closed: approved.** - `mustStop`: a throw, a non-boolean `stop` or a malformed `hit` stops the session at the end of its turn. The log line names the check and the error code. No timeout is added: the checks are pure and get no I/O. - `mayLaunch`: a throw or a malformed answer refuses the launch, logged the same way. - `report`: a throw or a malformed answer shows each of that business's token limits as `check failed: ` in Settings and `mosaic limits`. A broken check is never shown as "not enforced" or as a count. - Each of the three is tested. **Q1: accepted, with one addition.** While every record's `account` is null (until row 126), a key limit shows as `not enforced: no records name a key`, through the same not-enforced path, never as count 0. Tested. **Q3: accepted.** The organization-key change (the new `reset` key) touches: - packages/bus/src/broker.mjs:135, `ORGANIZATION_KEYS` (limits.set and organization.set); - packages/harness/src/tools.mjs:27, `ORGANIZATION_KEYS`, and the `change_organization` parameters and description (line 172); - packages/business/src/launch-settings.mjs:34, the third copy of the list in `set()`'s `rejectUnknownKeys`; - packages/harness/tests/tools.test.mjs:92 and :99, the tests that the tool's parameters and the broker's list match. I add a check that launch-settings' list matches too; - packages/cli/src/limits.mjs (`mosaic limits reset`) and the webui Settings send path (packages/webui/src/cli.mjs:63 `settingsWrite`, serve.mjs, public/), which pass the key through. **Q4: accepted, with two more tests.** - The stop lands only between turns: a fixture turn finishes, and its RESULT and `message.read` both land before the claim ends. - The run's end reads as a limit stop. The log line names the subject, the limit and the count. The claim row (`role_claims` or `thread_claims`, op revoke or release) has `by='limit-check'` and a reason that starts `limit:` and names the check, the path, the limit and the count. The runner sees only `not-holder`. The cause is on the record, in the claim row and the bus log, and the launcher's end record reads the claim reason. The test asserts the claim row. ## Build, as built (base 19fe85c3e) The candidate is 36 paths, 24 changed and 12 new. They are listed in `candidate-manifest.sha256` and the diff is in `build.patch`, both against 19fe85c3e. I rebased the build from its first base because row 149's 03bbd2c98 changed packages/harness/src/tools.mjs, one of this row's paths. ### Where each piece lives - **Core**, packages/bus/src/limits.mjs (new): - `createLimits({store, read, log, makeLoader})` returns `refresh`, `mayLaunch`, `mustStop` and `report`. - Core reads the organization file and the per-turn records (`turnRecords`, the same records `usage` shows), then asks every switched-on `limit-check` through the plug-in loader. The first refusal wins. - Core computes "not enforced": a limit that no switched-on check's `report` lists. It never names `token-limit`. - **The shipped check**, packages/plugins/limit-check/token-limit/index.mjs and README.md. It is pure, takes no I/O, and keeps BigInt counts. - **The contract**, packages/plugins/src/kinds.mjs: `limit-check` is interface 2, with exports `mayLaunch`, `mustStop` and `report`. A v1 check is refused at load; this is tested in loader.test.mjs. - **The launcher**, packages/cli/src/launcher.mjs `check()`: - It asks the host's `mayLaunch` op (packages/bus/src/process.mjs, runtime.mjs). - A hit gives `Refusal("limit-reached")` with `error.limit`. - Anything else that is not `{allow:true}` gives `limit-check-failed ": "`, as does a failed op. - The run's end record carries the limit reason that `endLaunch` returns. - **The broker**, packages/bus/src/broker.mjs: - After the end phase of `usage.end`, `#limitCheck` asks `mustStop` and keeps the answer. - A throw or a malformed answer becomes a stop with check null. - `#limitStop` acts on the run's next `message.receive` or `state`. - `endLaunch` returns `{released, limit?}`. - **Settings**: - packages/business/src/launch-settings.mjs stamps `since` on a new or changed limit, keeps it otherwise, refuses a caller's `since`, and runs the `reset` key. - packages/business/src/launch.mjs validates `limits.tokens`. - packages/business/src/business.mjs refuses a token limit on an instance the file doesn't declare. - **Readers**: - `mosaic limits` and `mosaic limits reset ` (packages/cli/src/limits.mjs); - the bus `limits` view (views.mjs); - the Console `/api/bus/limits` route (serve.mjs) and the Settings token table and Reset (public/bus.js). ### Departures from the design, and why - **Code names.** The design (§3) named the refusal `token-limit-reached`. The build uses `limit-reached`, logged as `event: 'limit-reached'`, with `limit-check-failed` for failures. Every limit check uses these codes, so row 126's checks need no new code. The check's name is in the log line and the refusal. - **Double counting across usage sources.** Records are keyed by (run, turn, source). If two usage sources record the same turn, the turn counts twice. Only `pi-session` ships today, so this cannot happen yet. The check's README states the count rule, and a second source should be reviewed against it. - **Key limits until row 126.** Every record's `account` is null, so a key limit matches nothing. `report` shows it as `not enforced: no records name a key` (Q1), never as count 0. Tested in token-limit.test.mjs and in the Settings page test. ### Q3: every file the reset touches - packages/business/src/launch-settings.mjs:10, `SETTINGS_KEYS`, and the "at least one key" list in `set()`; - packages/bus/src/broker.mjs:135, `ORGANIZATION_KEYS`, used by limits.set and organization.set; - packages/harness/src/tools.mjs:28, `ORGANIZATION_KEYS`, and the `change_organization` parameters. harness/tests/tools.test.mjs checks that the tool's list, the broker's list and launch-settings' list match; - packages/cli/src/limits.mjs, `mosaic limits reset`; - packages/webui/src/serve.mjs, the Settings POST's writable keys; - packages/webui/src/public/bus.js, the Reset button, which posts `{revision, reset:[path]}`. packages/webui/src/cli.mjs passes the body to `limits.set` whole and needed no change. The organization writer logs the reset in its change log, with the actor and the old and new `since`. Tested in token-limit-settings.test.mjs. ### Q4 as built - The stop is decided after a turn's end and acted on at the run's next receive or state read. The turn's RESULT and its `message.read` therefore land first. Tested in limit-stop.test.mjs, test 1, with a stub harness in harness/tests/runner.test.mjs. - The claim ends by `#claimEnd(..., 'limit-check')`: op revoke for a role and release for a thread. The row has `by='limit-check'` and the reason `limit: limit count `. The runner sees only `not-holder`. - `endLaunch` reads that claim row and returns the reason as `limit`, and the launcher writes it on the end record. - Through the real runtime and the shipped check: limits-runtime.test.mjs, test 3. ### The state() fix (sage-0785), corrected - **What I claimed before the slot run.** `loader.modules(kind)` throws `unknown-kind` for a kind the loader has never set, so an unguarded `state()` threw before any load or after a refused first load. I added an `Object.hasOwn(loader.loaded(), KIND)` guard and said mutant L01, which removes it, would fail both tests below. - **What the run showed.** L01 survived. The claim was wrong: `createLoader` (packages/plugins/src/loader.mjs:90) seeds every kind in `KINDS` with an empty frozen list at construction, so `loaded()` always has `limit-check` and `modules('limit-check')` never throws `unknown-kind`. The guard could never change anything. I did not check the loader's constructor before writing the claim. - **Round 2.** The guard is gone. `state()` reads `loaded()[KIND]` and `modules(KIND)` straight, with a comment that names the seeding. L01 is retired, so there are 74 mutants. The two tests stay, because the behaviour they pin is what Sage asked for: a kind never loaded, or refused on its first load, has nothing switched on, so a switched-on check reads as `not-loaded` and with none switched on a limit shows as not enforced. - limit-checks.test.mjs:214, "REQ-LIMIT-4 state(), never-loaded case: …"; - limit-checks.test.mjs:223, "REQ-LIMIT-4 state(), first-load-refused case: …". - The L-family (L02-L15) was rerun on the new limits.mjs: 14 of 14 killed. ### Tests added after the design review These cover gaps I found while listing mutants: - webui/tests/limits-browser.test.mjs (new), Settings in a real browser: - each status shows in its own words: counted with kinds and unknown turns, `not enforced: `, bare `not enforced`, and `check failed: ()` with the check name escaped; - Reset is disabled in read-only mode; - Reset posts one path; - a form save keeps `limits.tokens`; - a refused read shows "Token limits unavailable". This is the acceptance line's "not enforced in Settings". - bus/tests/limits-runtime.test.mjs, test 3: the real runtime wires `limits` into the broker; the run at its limit stops on its next receive, and `endLaunch` returns the claim reason. - business/tests/token-limit-settings.test.mjs: a token limit on an undeclared instance is refused. - webui/tests/limits-route.test.mjs: the Console reader asks the bus `limits` verb with no arguments. - bus/tests/limit-checks.test.mjs: a check that returns a rejected promise is `malformed-answer`. Without the thenable guard the rejection goes unhandled and the test fails (mutant L15). The resolved-promise fixture alone could not tell the guard from the plain-object check. ### Tests that skip, corrected cli/tests/thread-launch.test.mjs skips when `sandboxProblem()` reports one. It did not skip in either gate run (10 of 10 ran). It kills A04 (the end record's limit). I also said it was the only test that kills P01 (the awaited host op) and P02 (the `mayLaunch` op route). That was wrong: thread-launch runs the host in process with its own op table (thread-launch.test.mjs:100-108) and never forks packages/bus/src/process.mjs, so both survived the first run. Round 2 adds bus/tests/process.test.mjs "REQ-LIMIT-4 the broker process answers the launcher's mayLaunch op with the limits' awaited decision", which forks process.mjs, boots it, sends `{op:'mayLaunch', id:7, …}` and expects `{ok:true, result:{allow:true}, id:7}`. P01 and P02 now name process.test.mjs and both are killed. ### Test files - bus: limit-checks, limit-stop, limits-runtime, process, usage; - business: token-limit-settings; - cli: launch-limit-checks, launch-limits, limits-tokens, thread-launch; - harness: runner, tools; - plugins: kinds, loader, token-limit (helpers.mjs changed); - webui: limits-route, limits-browser, shell (the pinned write list). ### Mutants There are 74, one per check (75 in the first run; L01 is retired, see the state() section). Each is an exact-once edit, listed with its killing test files in `mutants.mjs` (copied here). Groups: - L02-L15, core; - T01-T11, token-limit; - B01-B09, the broker; - A01-A04, the launcher; - S01-S07, launch-settings; - D01-D05, the schema and business file; - K01-K02, the contract; - W01-W10, the Console server, reader and page; - C01-C04, the CLI; - R01-R04, the runtime; - P01-P02, process; - H01, the harness tool; - U01, the record view. The driver checks the manifest before each mutant and again after each restore, and runs every named test file as a control first. A timeout is reported apart, not as a kill. Results (slot from sage-0796, run 2026-10-18T17:22Z on the round-1 candidate, then reruns from 17:31Z on round 2): | Run | Mutants | Killed | Survived | Timeout | |---|---|---|---|---| | Round 1, all | 75 | 71 | 4 | 0 | | Round 2, the three fixed survivors (T07, P01, P02) | 3 | 3 | 0 | 0 | | Round 2, L02-L15 on the new limits.mjs | 14 | 14 | 0 | 0 | The four round-1 survivors and what round 2 did: - **L01-state-guard-reverted.** Equivalent: the guard was dead code. Removed, and L01 retired. See the state() section. - **T07-provider-matches-all** (the token-limit check counts every provider's records for a provider limit). No test had a provider limit with another provider's records. token-limit.test.mjs, "REQ-LIMIT-3 a limit covers only its own subject's records and launches", now has one: records of provider `other` count 0 toward an `anthropic` limit and allow, a launch of `other` is allowed under a full `anthropic` limit, and the full limit refuses an `anthropic` launch. - **P01-op-not-awaited** and **P02-op-missing.** Wrong target test, see "Tests that skip, corrected". Killed by the new process.test.mjs test. Round 2's other changes since round 1 are test additions and the guard removal, so the other 57 round-1 kills are on unchanged source lines. Their test files changed only by added cases. ### Gate results Totals are awk sums over each round's summary.txt. - **Round 1 (17:22:02Z, wt at the 34-path candidate plus the line 206 fix):** package totals 2235 tests, 2234 pass, 1 fail. The failure was pkg-webui shell.test.mjs (see "Corrections from the slot run"). 0 containers left under dewey-r125. - **Round 2 (17:32:24Z to 17:39:27Z, wt at this packet's 36-path manifest):** - 17 single-file runs: 140 tests, 140 pass, 0 fail. - 16 packages: 2236 tests, 2236 pass, 0 fail, 0 skipped. - 10 scripts/test-*.sh: every one rc=0. - 0 containers and no network left under dewey-r125. The manifest re-check after the gate passed. Gate exit 0. ### Base drift Since base 19fe85c3e, row 129 (850484a7d) landed on three of the 36 paths: bus.js, serve.mjs and shell.test.mjs. The patch applies to HEAD fdb1d4b34 with `git apply --3way --cached` in a scratch index, with no conflicts. Row 129 adds no write `fetch` to bus.js, so the pinned write list in shell.test.mjs is unaffected. I have not gated the candidate on fdb1d4b34. The round 1 review is of the 19fe85c3e candidate, and the row's gate already needs Sage's full rerun at the landing target. ### Corrections from the slot run - **limit-checks.test.mjs:206.** The first-load-refused test expected the refused v1 module to log `interface-version`. The loader checks exports before the version, so a v1 module, which lacks `report`, is refused as `missing-export`. I fixed the expectation before the gate, and the comment above it says why. - **shell.test.mjs.** The first gate failed one test: "shipped strings are unchanged, and the web keeps its write boundary" pins bus.js's write `fetch` calls, and the Reset adds a second POST to `/api/settings`. My singles had not included shell.test.mjs because it was not a candidate path. Round 2 adds the Reset's call to the pinned list, raises the `method:` count from 3 to 4 and names row 125 in the comment. The route is the one Q3 approved ("Reset posts one path"), and no new endpoint is added. - **Busy check, first launch attempt (17:20:15Z).** I wrote the launch as one line, `busy.sh && rm … && (gate) & …; (mutants) &`. The `&` put the busy check and the gate in the background together, and the mutant half after `;` started at once with no check ahead of it. The busy check then saw the mutant process (pid 536943) and its own wrapper, and failed, so the gate never started. The mutant half passed one control file, failed the limit-checks control (the line 206 fault above) and exited 2 before it applied any mutant. My own check at 17:20:03Z had said CLEAR, and the slot was mine (sage-0796). After the fix I relaunched from a script file with the check gated by `&&` (launch.sh, CLEAR at 17:22:02Z). Recorded because the rule was broken, not because harm followed. ### Packet - `build.patch` sha256 81e47c43fb8b51148d60fb4c35569158017c0d9a1ae23e49728bfd079fc6eddb: 36 paths (24 changed, 12 new) against 19fe85c3e2a4f53d863fa9f65c2fdeba0d74a43c. Applied to that base in a scratch index, every one of the 36 blobs matches the manifest. - `candidate-manifest.sha256` sha256 e688651be8070e48c9ede5e239149342145804cf803dccad84dffb91beac93da, the candidate digest. It replaces the 34-path draft (patch 4800f2eb…, manifest bb948903…), which predates the slot run's fixes. - `mutants.mjs` (`list`, `check`, `apply`), `mutants.sh` (the driver) and `gate.sh`. - The gate runs each candidate test file alone, every package's tests, then scripts/test-*.sh. - Docker runs under Compose project `dewey-r125`; the gate removes only that project's network. ## Round 2 (Filbert's changes, comment 28965) The base stays 19fe85c3e (sage-0829). The round 2 manifest has 38 paths: the 36 from round 1 plus `docs/TOOLS.md` and `packages/cli/README.md`. Nine paths changed from round 1 (see "Packet, round 2"). ### Change 1: token limits at their schema depth only Round 1 took any object with a `limit` key, at any depth of `launch.limits`, as a token-limit leaf. A concurrency provider or model named `limit` was taken for one, and a business holding that couldn't save from Settings. - `packages/business/src/launch-settings.mjs`: `tokenLeaves` walks only `tokens.instances.X`, `tokens.providers.X`, `tokens.keys.X` and `tokens.models.P.M` (a `DEPTH` table). `stamp` sets `since` only on those leaves. `reset` accepts only a path that is one of them; any other path is refused `invalid-request`, a concurrency limit's or one above a token leaf included. An entry that isn't an object is left for the schema to refuse. - `packages/bus/src/limits.mjs`: `limitLeaves` walks the same `DEPTH` table, so the core and the writer agree on what a token limit is. - Choice: Filbert offered walking at schema depth or refusing `limit` as a reserved name. I took the depth walk. Concurrency names stay free strings, as `launch.mjs:5` allows, and the reserved-name option would refuse files that are valid on base. Tests, each failing on the round 1 candidate (see "Runs, round 2"): - `token-limit-settings.test.mjs:61`: Filbert's four base-valid concurrency shapes save as sent. Token limits whose provider, model or key is named `limit`, beside concurrency limits of the same name, are stamped and reset. A reset of a concurrency path or a path above a token leaf is refused, and the file's bytes are unchanged. - `limit-checks.test.mjs:102`: `limitLeaves` returns the token leaves named `limit` under providers, models and keys, and nothing for concurrency limits of that name. - `limit-checks.test.mjs:303`: a token limit for a provider named `limit` is counted and refuses the launch, beside a concurrency limit of the same name, with no `limit-check-failed`. Not tested: an instance named `limit`. Instance names must be roles the business file declares (`business.mjs:135`), so it needs a role of that name. Both files handle `instances` through the same `DEPTH` walk as `providers` and `keys`, and the other tests run `tokens.instances.coder` at depth 1 throughout. A test can be added if the reviewer wants one. Mutants: S08 and S09 put round 1's any-depth walk back into `stamp` and `reset`; L16 puts it back into `limitLeaves`. ### Change 2: the CLI docs - `packages/cli/README.md`, "Agent limits and models": where token limits sit and their `{limit}` shape; `since` set and kept by the writer, a different `since` refused; `mosaic limits reset WORD... [--business ID]` with an example; enforcement only by a switched-on `limit-check`, `not enforced`, and fail-closed `check failed`. It also says that the plain output is JSON without `tokens` followed by a `token limits:` text section, so it isn't JSON as a whole and scripts use `--json`. - `docs/TOOLS.md`, "Agent limits": the same points, short. - `packages/plugins/limit-check/token-limit/README.md`: with no usage source every turn's record is unknown (source `@none`), so every limit counts zero. Round 1 said no record was written, which was wrong. `host.test.mjs:826` reads the CLI README; it passed in the round 2 gate (pkg-cli). ### Filbert's survivors and the no-source note | Item | Test added | Mutant | |---|---|---| | f2, no `limits.refresh` after `plugins.set` (`runtime.mjs:97`) | `limits-runtime.test.mjs`, "switching a limit check on with plugins.set loads it without a launch or a turn" | R05 | | f3, a check gets `args` without `structuredClone` (`limits.mjs:85`) | `limit-checks.test.mjs:176`: `echo` nulls its tokens, and `refuse` after it still reads limit 100 | L17 | | f4, a reset under a dirty draft replaces the snapshot (`bus.js:609`) | `limits-browser.test.mjs`: a draft typed before the reset is kept, and the save sends the revision it was typed against | W11 | | No usage source, limit 1 | `limits-runtime.test.mjs`, "with no usage source a turn is unknown, counts zero and stops nothing under a limit of 1" | T12 | ### Runs, round 2 Slot granted by Sage (sage-0830). The run was `r2run.sh`, launched by `busy.sh && setsid -w timeout -k 10 2400 ./r2run.sh` (CLEAR 18:36:32Z, exit 0 at 18:46:08Z). The round 1 candidate fails the new change 1 tests. Each changed test file was run singly against round 1's 36 files, in a second worktree that the run restored and re-checked: - `token-limit-settings.test.mjs`: 4 tests, 3 pass, 1 fail. The failing test is "only tokens.* at schema depth holds token limits…". - `limit-checks.test.mjs`: 16 tests, 14 pass, 2 fail. The failing tests are "a token limit for a provider named limit…" and "limitLeaves takes token limits at schema depth only…". - `limits-runtime.test.mjs` 5/5 and `limits-browser.test.mjs` 1/1 pass on round 1. These two files hold the survivor tests, which the mutants below cover, not change 1. Gate (`gate.sh`, unchanged from round 1), 43 runs, all rc=0. Totals are an awk sum over `summary.txt`: - each candidate test file alone: 17 runs, 145 tests, 145 pass; - every package's tests: 16 runs, 2241 tests, 2241 pass; - `scripts/test-*.sh`: 10 runs, all rc=0. Only test-queue prints counts (156/156). - Afterwards there were 0 containers under `dewey-r125`, and the network was removed. Mutants (`mutants.mjs`, `mutants.sh`), on the round 2 worktree: - `check`: 81 mutants, 0 problems. - Every control file passed before any mutant was applied. - Result: 81 killed, 0 survived, 0 timeouts. The new ones are L16, S08, S09, L17, R05, W11 and T12. - Round 1's 74 are all still killed. - At the end, the worktree matched the round 2 manifest again. Busy check fix. The first launch, at 18:36:00Z, stopped before running anything. `r2run.sh`'s own busy check matched its launcher, because `r2run.sh ` matches the `*run.sh\ *` pattern. `busy.sh` now skips its own ancestors by walking the `PPid` chain in `/proc//status`. Control: a process started as `exec -a "control gate.sh" sleep 4` was still reported BUSY (rc=1), and a plain run with nothing else live said CLEAR (rc=0). Sage accepted the fix (sage-0834). ### Packet, round 2 - `build.patch` sha256 0efb5cb68c20c71622809bfbd1c9e8ef8c0b8e1229701c795e66dc5eb6e59f6a: 38 paths (26 changed, 12 new) against 19fe85c3e2a4f53d863fa9f65c2fdeba0d74a43c. I applied it to that base in a scratch index (`mkpatch-r2.sh`), and every one of the 38 blobs matches the manifest. It replaces round 1's patch 81e47c43…. - `candidate-manifest.sha256` sha256 71c55638fc1abe29024c4511e75b4c1e73fd9aa1a289c663d15d3600eb8388a8 is the candidate digest. It replaces round 1's e688651b…. These nine paths differ from round 1: `docs/TOOLS.md` and `packages/cli/README.md` (both new to the manifest), `packages/business/src/launch-settings.mjs`, `packages/business/tests/token-limit-settings.test.mjs`, `packages/bus/src/limits.mjs`, `packages/bus/tests/limit-checks.test.mjs`, `packages/bus/tests/limits-runtime.test.mjs`, `packages/plugins/limit-check/token-limit/README.md` and `packages/webui/tests/limits-browser.test.mjs`. - `mutants.mjs` has 81 mutants (round 1 had 74). `mutants.sh` and `gate.sh` are unchanged. ### Base drift, round 2 Since base 19fe85c3e, row 129 (850484a7d) has landed on four of the 38 paths. Three are bus.js, serve.mjs and shell.test.mjs, as in round 1. The fourth is `docs/TOOLS.md`, new to the manifest this round; row 129 added 26 lines to it. The round 2 patch applies to HEAD 6f963fd65 with `git apply --3way --cached` in a scratch index, with no conflicts. As in round 1, the candidate is gated on 19fe85c3e only; Sage's full rerun at the landing target is the row's gate (sage-0829). ## Round 3 (rebase onto row 164, sage-0847 and sage-0860) Confirm-only: the round 2 candidate rebased onto `cfde62f9ff45e818ee44671ce23e29f644733d36` (row 164 landed as 008746b6e, packet cfde62f9f), plus Filbert's bracket from comment 28981. No other source change. The scripts and their outputs are in `r3/`. ### Rebase `r3/mkwt3.sh` checks that the packet patch is round 2's (0efb5cb6…). It makes a detached worktree at cfde62f9f and applies the patch with `git apply --3way`. 36 paths went in clean (`r3/apply-3way.log`). The two conflicts are both on paths row 164 also changed, and `r3/resolve-r3.py` resolves them: - `packages/plugins/tests/kinds.test.mjs:9-10`: round 2's test title ("usage-source at interface version 1, limit-check at 2 (row 125)") with row 164's key list `["usage-source", "limit-check", "search-source", "channel"]`. Round 2 never changed the key-list line; it conflicted only because it sits next to the title. - `packages/plugins/README.md`: - :134-136 hold round 2's three-line tail of the transcript paragraph, which drops "No limit check ships with this row."; - :138-194 hold round 2's "## Limit checks (row 125)" section, unchanged; - row 164's "## Human project/thread channels" section follows from :196, unchanged. - Round 2 put its section after that paragraph, and row 164 put its own there too, hence the conflict. ### Filbert's bracket `docs/TOOLS.md:621` now reads `` `scripts/mosaic limits reset WORD... [--business ID]` ``, matching `packages/cli/README.md:811`. The line is 80 columns, so the paragraph is not rewrapped. No test or script reads that line (`grep` over `packages/` and `scripts/`, `*.mjs` and `*.sh`). ### Round 2 against round 3, line by line `r3/cmp3.sh` compares two diffs per path, `+`/`-` lines only and not context: - round 2's patch, split per file; - round 3's worktree diffed against cfde62f9f. The result is `r3/plusminus-compare.txt` and `r3/plusminus-per-path.txt`. 37 of the 38 paths are byte-identical. The one difference is the TOOLS.md:621 bracket line. Sage named eight paths, the ones that changed between round 2's base 19fe85c3e and cfde62f9f: - row 129: docs/TOOLS.md, public/bus.js, serve.mjs, shell.test.mjs; - row 164: plugins/README.md, kinds.mjs, kinds.test.mjs, loader.test.mjs. Those eight are also exactly the manifest blobs that differ from round 2. Seven of them have `+`/`-` lines identical to round 2, and TOOLS.md differs only by the bracket. So the rebase changed no row 125 line beyond the bracket. The four row 164 paths and the three other row 129 paths differ from round 2 only in the base lines underneath. `node mutants.mjs check` (read only, no tests) on the round 3 tree gives "81 mutants, 0 problems" (`r3/mutants-check.txt`): every mutant's old text still matches exactly once. ### Runs, round 3 Slot granted by Sage (sage-0867, after Darkwing cleared at 19:46:14Z). The run was `r3/singles-r3.sh`, launched by `busy.sh && setsid -w timeout -k 10 5400 ./singles-r3.sh` (CLEAR 19:46:52Z, end 19:47:36Z, exit 0). - `wt3` matched the round 3 manifest (8b71c405…) before the first test and after the last. - Each of the 17 touched test files ran alone with the spec reporter. TMPDIR was in my scratch. - All 17 had rc=0. An awk sum over the 17 logs gives 145 tests, 145 pass, 0 fail, 0 cancelled, 0 skipped. Round 2's single-file runs gave the same 17 runs and 145/145. - Per-file counts are in `r3/singles.out`, and the logs are in `r3/singles/`. - No process of mine was left with a cwd under the run directory. This round reran no package suites, `scripts/test-*.sh` or mutants. The scope is confirm-only (sage-0847), and Sage's gate at the landing target is the row's full run. The mutant table still applies as text (`check` above). ### Packet, round 3 - `build.patch` sha256 2d310327c5a138810a420b15c9f8240fd879d440f049bc96a390d7d2c21ed7bb: - 38 paths (26 changed, 12 new) against cfde62f9ff45e818ee44671ce23e29f644733d36; - `r3/mkpatch-r3.sh` applied it to that base in a scratch index, and all 38 blobs match the manifest; - no path outside the 38 changed. - `candidate-manifest.sha256` sha256 8b71c405ed2365e2cf62eab8f8863535025c5bd4f037db5870b9c59a704bfe56 is the candidate digest. - Round 2's patch and manifest are kept in `r2/`, unchanged (0efb5cb6…, 71c55638…). - `mutants.mjs`, `mutants.sh` and `gate.sh` are unchanged; none of them names a base.