Files

303 lines
14 KiB
Markdown

# 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 6b473b93044e, 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):
```json
"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 |