Files
2026-10-18 15:22:02 -05:00

42 KiB

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 6ff35c81d3. 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):

"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 <path> 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: <code> 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 "<check|bus>: <code|malformed-answer>", 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 <path…> (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: <check> <path> limit <n> count <n>. 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: <reason>, bare not enforced, and check failed: <code> (<check>) 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 19fe85c3e2. 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/<pid>/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 19fe85c3e2. 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.