Files

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)

  1. Q1: refuse account-credential-ambiguous.
    • It applies only when launch.account is set. With no account, the launch behaves exactly as it does today.
    • The message names variable names, never values.
    • ANTHROPIC_BASE_URL is 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 sessionEnv stays out of the row.
  2. Q2: yes. Only a claude-code launch carries the account. A pi launch logs account: null, its records stay NULL, 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:121 gains account in its key list. A new checkAccount in launch.mjs uses rejectUnknownKeys(["name","credential"]). name uses requireId, the same rule as a tokens.keys key. credential must 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_CREDENTIALS from 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 are NULL and keys are not enforced. Existing files still load, and no migration is needed.
  • A tokens.daily.<a> leaf whose <a> isn't launch.account.name makes the read refuse, in the same way as the tokens.instances check 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 of SESSION_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 the mayLaunch op (:225), to bindLaunch (:337), and to the launch log record (:367), as account.

Bus:

  • runtime.mjs:187 passes account through to limits.mayLaunch.
  • broker.mjs bindLaunch (:410) takes an optional account, which must be an identifier. The session keeps it. session.launched's body doesn't change, so launch-record-mismatch on a rebind is unaffected.
  • broker.mjs:1231 mustStop passes account: s.account ?? null.
  • usage.mjs:65 reads account = null from the session. The NULL at :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 on 81f26551b went 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 checkTokenLimits gains daily. The keys use requireId. Each leaf is {limit, since, reset, timeZone}, all required:
    • limit and since follow tokenLimit's rules.
    • reset must match /^([01]\d|2[0-3]):[0-5]\d$/.
    • timeZone must match an IANA name shape (/^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/, so +05:00 is out), and new Intl.DateTimeFormat("en-US", {timeZone}) must accept it.
    • Anything else refuses the read.
  • daily: 1 is added to the DEPTH tables in bus limits.mjs:32 and business launch-settings.mjs:24. The writer stamps a new daily leaf's since and reset moves it, as for the other kinds.
  • token-limit's leaves() doesn't change: it walks named kinds and never reads daily.

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, reset 02:30): the reset is the first instant after the gap, 03:00 CDT (08:00Z). Temporal's compatible rule 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, reset 01: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 from Intl.DateTimeFormat formatToParts with hourCycle: "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, reset is 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 with account === <a>, summing every kind. Unknown turns add nothing and are counted apart, the same as token-limit.
  • mayLaunch and mustStop match on subject.account === <a>. A hit is {path, limit, count, unknownTurns}.
  • report gives one counted entry per daily leaf, with since being the leaf's since (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 tokens through (: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)
  • mustStop once 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 reset refuses the read
    • a bad timeZone refuses the read (a non-IANA name, an offset, an unknown zone)
    • a bad account refuses the read
    • a daily key that isn't the account refuses the read
  • launch-settings tests: a daily leaf gets its since stamped and reset.
  • bus:
    • limits.test.mjs: limitLeaves walks daily.
    • 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 account on the subject.
    • usage-broker.test.mjs: records carry the bound account.
  • launcher.test.mjs:
    • the launch log names the account
    • account-credential-missing
    • account-credential-ambiguous
    • a pi launch logs account: null
    • mayLaunch gets 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