14 KiB
Row 126 (#1620): design
Dewey, 2026-10-18T22:27Z. Base origin/refactor 70c9131f0 (sage-0940). Brief:
docs/plans/2026-10-14_prd-1.2-build.md:480-547, blob 6b473b9304, with the
sage-0910 rulings in DESIGN-NOTES.md. REQ-LIMIT-2, REQ-LIMIT-4. Nothing here
has been run; there is no slot yet. Line numbers are on 70c9131f0.
Two questions for Sage before the build
Q1. The named set and the other credential variables. sessionEnv
(packages/seat/src/session.mjs:101) passes every ENV_ALLOW credential
variable into the session, whichever set the organization file names. If the
file names CLAUDE_CODE_OAUTH_TOKEN and the bus environment also holds
ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN, Claude Code logs in with
those first, as far as I know its precedence (ANTHROPIC_AUTH_TOKEN, then
ANTHROPIC_API_KEY, then CLAUDE_CODE_OAUTH_TOKEN). The usage records would
then name an account that isn't the one in use, which is the false record
sage-0910 refused to allow for a missing set. My recommendation is that the
launcher refuses, before anything starts, when any SESSION_CREDENTIALS
variable outside the named set is set, ANTHROPIC_BASE_URL included. That's
a second code beside the missing-set one. The other fix is to pass only the
named set in sessionEnv, but that changes what enters the sandbox, and the
brief says no sandbox change. Is the refusal right?
Q2. Pi launches. SESSION_CREDENTIALS are Claude Code's login sets
(session.mjs:94-98). My reading is that the account applies only to a
claude-code launch. A pi launch would log account: null, its records
keep NULL, and the credential checks don't run for it, so a daily quota
never counts Pi turns. Is that right?
Unless Sage says otherwise, I build with both of these recommendations.
Sage's answers (sage-0942)
- Q1: refuse
account-credential-ambiguous.- It applies only when
launch.accountis set. With no account, the launch behaves exactly as it does today. - The message names variable names, never values.
ANTHROPIC_BASE_URLis included.- The design doesn't rest on Claude Code's credential precedence. The refusal stands whatever that order is, so the precedence remark above is background only.
- Narrowing
sessionEnvstays out of the row.
- It applies only when
- Q2: yes. Only a
claude-codelaunch carries the account. Apilaunch logsaccount: null, its records stayNULL, and the credential checks don't run for it. The cli README says so, and a test asserts the pi-null case.
The other design choices stand. The public/bus.js line needs Mos's decision 123 go before it
lands; public.diff goes in this packet. 126 is rebased onto 156's landing before I ask for its
slot, and nothing runs before then.
Item 1: the account
Organization file. A new optional launch.account, written by hand
(Settings doesn't edit it, so SETTINGS_KEYS doesn't change):
"launch": { "by": "chief", "instances": [...], "models": {...},
"account": { "name": "jason-max", "credential": "CLAUDE_CODE_OAUTH_TOKEN" } }
business.mjs:121gainsaccountin its key list. A newcheckAccountin launch.mjs usesrejectUnknownKeys(["name","credential"]).nameusesrequireId, the same rule as atokens.keyskey.credentialmust be variable names joined with "+" (sage-0910), checked by/^[A-Z][A-Z0-9_]*(\+[A-Z][A-Z0-9_]*)*$/. A secret can't pass that check.- The organization-file read checks only the shape. Business can't import
SESSION_CREDENTIALSfrom seat, because seat already imports business (session.mjs:37, sandbox.mjs:40). So the launcher checks that the name is one of the three sets, under the same refusal code. - With no
account, everything stays as it is today: records areNULLand keys are not enforced. Existing files still load, and no migration is needed. - A
tokens.daily.<a>leaf whose<a>isn'tlaunch.account.namemakes the read refuse, in the same way as thetokens.instancescheck at business.mjs:135. Without that check, the quota would match no record and still show as counted, a limit that never applies.
Launcher (packages/cli/src/launcher.mjs check(), after the
no-session-credential check at :213):
account-credential-missing: the file names a set that isn't one ofSESSION_CREDENTIALS, or whose variables the bus environment doesn't all hold. The message gives the set name, never a value.account-credential-ambiguous(if Q1 is accepted): the environment holds a credential variable outside the named set.- Both go in the refusal table at packages/cli/README.md:332-347, after
no-session-credential. account = model.harness === "claude-code" ? b.launch.account?.name ?? null : null(Q2). It goes to themayLaunchop (:225), tobindLaunch(:337), and to thelaunchlog record (:367), asaccount.
Bus:
- runtime.mjs:187 passes
accountthrough tolimits.mayLaunch. - broker.mjs
bindLaunch(:410) takes an optionalaccount, which must be an identifier. The session keeps it.session.launched's body doesn't change, solaunch-record-mismatchon a rebind is unaffected. - broker.mjs:1231
mustStoppassesaccount: s.account ?? null. - usage.mjs:65 reads
account = nullfrom the session. TheNULLat :94 becomes a bound?. - Corrected in round 4 (sage-0970, sage-0972). This line used to say "The column already exists,
so there is no schema change and no migration". That was wrong: 3h made the column
account TEXT CHECK (account IS NULL)(3g-3h.sql:10), and the first slot run on81f26551bwent red on that CHECK. Row 126 needs schema 3i, a rebuild of usage_records without the CHECK. See MIGRATION.md and BUILD.md "Round 4".
Item 2: tokens.daily.<account>
- launch.mjs
checkTokenLimitsgainsdaily. The keys userequireId. Each leaf is{limit, since, reset, timeZone}, all required:limitandsincefollowtokenLimit's rules.resetmust match/^([01]\d|2[0-3]):[0-5]\d$/.timeZonemust match an IANA name shape (/^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/, so+05:00is out), andnew Intl.DateTimeFormat("en-US", {timeZone})must accept it.- Anything else refuses the read.
daily: 1is added to theDEPTHtables in bus limits.mjs:32 and business launch-settings.mjs:24. The writer stamps a new daily leaf'ssinceandresetmoves it, as for the other kinds.- token-limit's
leaves()doesn't change: it walks named kinds and never readsdaily.
Item 3: packages/plugins/limit-check/daily-quota/
The module is self-contained (index.mjs, README.md), interfaceVersion = 2,
pure, with the same answer shapes and thrown {code} errors as token-limit.
It copies token-limit's small tally helpers instead of importing a sibling
check, so each check loads alone. The cost is about 20 duplicated lines.
The day window. A local date D's reset is the earliest instant whose
date in timeZone is D and whose wall-clock time there is at or after
reset. The window start is today's reset if it is at or before now;
otherwise it is yesterday's. That one rule covers both DST cases:
- Skipped (America/Chicago 2026-03-08,
reset02:30): the reset is the first instant after the gap, 03:00 CDT (08:00Z). Temporal'scompatiblerule would give 03:30 instead, which leaves the old day running for half an hour after the wall clock passed the reset. - Repeated (2026-11-01,
reset01:30): the reset is the first 01:30, CDT (06:30Z). The second 01:30, CST (07:30Z), doesn't start another window.
The implementation:
offset(t)comes fromIntl.DateTimeFormatformatToPartswithhourCycle: "h23".- A candidate is
Date.UTC(D, reset)minus the offset in force a day before and minus the offset a day after. - If some candidates read back as
(D, reset), the earliest wins. - If none does,
resetis in a gap, and the reset is found by binary search between the two candidates for the first instant whose local reading is at or past(D, reset). - The count runs from
max(windowStart, since)over records withaccount === <a>, summing every kind. Unknown turns add nothing and are counted apart, the same as token-limit. mayLaunchandmustStopmatch onsubject.account === <a>. A hit is{path, limit, count, unknownTurns}.reportgives one counted entry per daily leaf, withsincebeing the leaf'ssince(core's validEntry requires that), not the window start.
Item 4: refusal and stop
Both use core's existing mayLaunch/mustStop, logged as limit-reached
with check: "daily-quota". Nothing in core changes beyond the DEPTH entry
and the account passing.
Item 5: the fixed line
Both lines are keyed on path[1] === "daily" and print once when any daily
row is present. The text is a draft for review:
Daily quotas count only this stack's own use of the account, not its use anywhere else.
- CLI (packages/cli/src/limits.mjs
formatTokens): the line goes after the list. - webui (packages/webui/src/public/bus.js
tokenLimits, :593-603): a<p class="small muted" id="token-limits-daily">under the table. Its test is packages/webui/tests/limits-browser.test.mjs. - This is a public/ change, so it needs Mos's decision 123 go before landing. The diff goes in this packet as public.diff.
- serve.mjs doesn't change; it passes
tokensthrough (:78-79).
Files
New:
- packages/plugins/limit-check/daily-quota/index.mjs
- packages/plugins/limit-check/daily-quota/README.md
- packages/plugins/tests/daily-quota.test.mjs
Changed source:
- packages/business/src/launch.mjs
- packages/business/src/business.mjs
- packages/business/src/launch-settings.mjs
- packages/bus/src/limits.mjs
- packages/bus/src/usage.mjs
- packages/bus/src/broker.mjs
- packages/bus/src/runtime.mjs
- packages/cli/src/launcher.mjs
- packages/cli/src/limits.mjs
- packages/webui/src/public/bus.js
Tests and docs: the tests below; packages/plugins/README.md, packages/business/README.md, packages/cli/README.md.
The org-file change for Jason (the packet gives it; I don't edit the
file): add "account": {"name": "<name>", "credential": "<set>"} to
launch. <set> is whichever of CLAUDE_CODE_OAUTH_TOKEN,
ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN+ANTHROPIC_BASE_URL the bus
unit's EnvironmentFile sets. I won't read that file. If Q1 is accepted, the
file must set only that set.
No sandbox change. session.mjs, sandbox.mjs and ENV_ALLOW stay
untouched.
Paths shared with other rows
- Row 165 (Darkwing, on the slot; paths from
agents/darkwing/work/queue-165/BUILD.md):
- packages/bus/src/broker.mjs
- packages/bus/src/runtime.mjs
- packages/webui/src/public/bus.js
- packages/cli/README.md
- possibly packages/bus/README.md, if 126 touches it (not planned)
- Row 156 (mine, in review, candidate afea1df3):
- packages/bus/src/broker.mjs
- packages/bus/src/runtime.mjs
- packages/cli/src/launcher.mjs
- packages/cli/tests/launcher.test.mjs
- packages/cli/README.md
- Whichever lands first, 126 rebases onto it and reruns the whole gate on the new base (candidate-base-drift rule).
Tests (each run singly)
New, packages/plugins/tests/daily-quota.test.mjs (REQ-LIMIT-2, REQ-LIMIT-4):
- the count: every kind; only this account; from
max(start, since); unknown turns kept apart - the America/Chicago boundary at 05:00: a record at 04:59 CDT is outside today's window, and one at 05:00 is inside
- the spring DST change with a 00:00 reset: the window is 23 h
- the fall DST change with a 00:00 reset: the window is 25 h
- a reset DST skips (02:30 on 03-08), starting at 08:00Z
- a reset DST repeats (01:30 on 11-01): one window, from 06:30Z
- a refusal at the quota (
count === limit) mustStoponce the turn's records reach it- an allow after the next reset
- bad leaves throw a code (fail closed)
Changed:
- token-limit.test.mjs: token-limit ignores
tokens.daily. - packages/business/tests (the launch schema test file, picked at build
time):
- a bad
resetrefuses the read - a bad
timeZonerefuses the read (a non-IANA name, an offset, an unknown zone) - a bad
accountrefuses the read - a daily key that isn't the account refuses the read
- a bad
- launch-settings tests: a daily leaf gets its
sincestamped and reset. - bus:
- limits.test.mjs:
limitLeaveswalksdaily. - limit-checks.test.mjs or limits-runtime.test.mjs: daily-quota loaded through the loader refuses at the quota and allows after the reset.
- limit-stop.test.mjs: a stop at the end of the turn that crosses it, with
accounton the subject. - usage-broker.test.mjs: records carry the bound account.
- limits.test.mjs:
- launcher.test.mjs:
- the launch log names the account
account-credential-missingaccount-credential-ambiguous- a pi launch logs
account: null mayLaunchgets the account
- cli limits test: the fixed line.
- webui limits-browser.test.mjs: the fixed line.
- Hash pins and path-reading tests: I grep tests for every manifest path and for 64-hex pins on the changed files before in-review.
Mutants (one each, with the test that kills it)
| # | Mutant | Killed by |
|---|---|---|
| D01 | never steps back to yesterday | the boundary test |
| D02 | window computed in UTC | the Chicago boundary test |
| D03 | offset taken once from now |
the two DST-change tests |
| D04 | skipped reset uses Temporal-style +gap (03:30) | the skip test |
| D05 | repeated reset takes the later 01:30 | the repeat test |
| D06 | ignores since |
the count test |
| D07 | ignores account | the count test |
| D08 | >= becomes > |
the refusal-at-quota test |
| D09 | token-limit walks daily |
the ignores-daily test |
| D10 | schema drops the reset check |
the bad reset test |
| D11 | schema drops the timeZone check |
the bad zone test |
| D12 | no cross-check of the daily key against the account | the cross-check test |
| D13 | launcher skips the missing-set refusal | the missing-set refusal test |
| D14 | launcher skips the ambiguity refusal | the ambiguity refusal test |
| D15 | launch log omits the account | the log test |
| D16 | usage inserts NULL |
the usage-broker test |
| D17 | broker mustStop omits the account |
the limit-stop test |
| D18 | runtime drops the account from mayLaunch |
the bus refusal test |
| D19 | bus DEPTH lacks daily |
the limitLeaves and bus refusal tests |
| D20 | settings DEPTH lacks daily |
the stamp test |
| D21 | CLI line removed | the CLI test |
| D22 | webui line removed | the browser test |