Files
stack/packages/plugins
jason.woltje ca15d73e9f limits: a daily token quota for the account the stack uses, and bus schema 3h to 3i (row 126, #1620)
The business file can name the account claude-code launches use,
`launch.account` = {name, credential}. `name` is an identifier and
`credential` names one login set's environment variables, never a value.
`launch.limits.tokens.daily.<account>` = {limit, since, reset, timeZone}
sets a daily quota for that account. A daily key that isn't the account's
name refuses the business read.

The new plug-in limit-check/daily-quota (interface 2, pure) starts a day at
the first instant at or after `reset` on that local date in `timeZone`. Across
DST that makes a 23- or 25-hour day, and a reset in a skipped hour starts at
the gap's end. It counts the account's known records of all four kinds since
the later of the day start and `since`, and counts unknown turns apart.

The launcher refuses account-credential-missing, account-credential-ambiguous
and account-is-credential (an account name equal to a session credential's
value). Messages name variables, never values. The account rides mayLaunch,
bindLaunch, the usage records, mustStop and the launch record. A pi launch
has no account, records NULL, and no daily quota covers it. `mosaic limits`
and the Console's Settings show one fixed line when a daily row shows:
"Daily quotas count only this stack's own use of the account, not its use
anywhere else."

Bus schema 3h to 3i: 3h reserved usage_records.account as
CHECK (account IS NULL). The new step 3h-3i.sql rebuilds the table in one
transaction, keeping every row's seq and the sqlite_sequence value, and
recreates the three append-only triggers. All of sqlite_master equals 3h's
apart from the account line. A 3h engine refuses a 3i store.
packages/bus/tests/account-migration.test.mjs covers all three, with a
step that fails partway and leaves 3h intact. 3i goes live only through
`stack-release.sh activate ... --migrate` with Jason present (P19 371); the
activate path takes the pre-migrate backup, and the way back is restoring
it (agents/dewey/work/queue-126/MIGRATION.md).

Mos's go mos-0835, six conditions: (1) one transaction, account-migration
test 2 and mutant D29; (2) sqlite_master against 3h, test 1 and mutant D28;
(3) account is never a credential, the launcher's account-is-credential
refusal (launch-account.test.mjs) and the business name pattern
(token-limit-settings.test.mjs); (4) MIGRATION.md, accepted by Mos in
mos-0837; (5) the packet's DESIGN.md "no schema change" line corrected;
(6) the public/ digest below.

The public/ change in bus.js (the daily line under the token-limit table)
carries Mos's decision 123 go mos-0833 on the +/- digest 03e6d9da...ba78,
unchanged at landing.

Owner Dewey, brief docs/plans/2026-10-14_prd-1.2-build.md, "Daily quota for
the credential the stack uses today". Review round 2 rebases the round 4
build onto 1df04f52f (row 167). Reviewer: Filbert approve 29068 on round 1
(manifest d18314f7...8e65); Filbert approve 29080 on
round 2 (manifest f332db76...6fa6).
Seat run on round 2: touched 158/0, singles 1388/0 with 1 esbuild skip,
test-land-work.sh 86/0, 30/30 mutants killed.
Sage gate on 54319f552: GATE_PASSED, 2474+422/0, all 15 packages, the queue
suite and 10 script selftests.

Nothing live.
2026-10-18 20:35:02 -05:00
..

Plug-ins

The plug-in layer (PRD REQ-PLUG-1, build step 6b, Gitea #1626). Every part the core calls out to is a plug-in kind. A module of a kind is a directory in this package, and the organization file says which modules are switched on. No core module imports a plug-in module by name; calls go through the loader.

Path What it is
src/kinds.mjs the kind registry and the check for the organization file's plugins key
src/loader.mjs loads the switched-on modules from this tree
<kind>/<name>/index.mjs one module of a kind

Kinds

Kind Interface Switch Exports
usage-source 1 list interfaceVersion, readTurn
limit-check 2 list interfaceVersion, mayLaunch, mustStop, report
search-source 1 one interfaceVersion, search
channel 1 list interfaceVersion, dispatch

Every module exports interfaceVersion, equal to its kind's interface version, plus the kind's functions. PRD REQ-LIMIT-4 says what the two kinds do: a usage source reads what a session used after each turn, and a limit check answers whether a launch may start (mayLaunch), whether a running session must stop at the end of its turn (mustStop), and what each limit has counted (report). Row 124 set the usage source's arguments and ships the transcript source. Row 125 set the limit check's at interface 2 (lead decision 150) and ships token-limit; a module built for interface 1 is refused with interface-version.

A search-source answers the broker's web.search (REQ-HARN-2, REQ-PLUG-2, decision 0b81b0c5): search(settings, query, deps, { signal }) resolves { query, total, results: [{ title, url, snippet }] } or throws a WebRefusal; signal is an AbortSignal the broker fires at its deadline, and a module must stop its request when it does. The kind switches on one module, and the module's name is the source: searxng-url queries the operator's SearXNG at the business var web.searxng; searxng-bundled queries the SearXNG the Stack installs, at a fixed loopback address (the install is REQ-INST-1's first component); harness-builtin is served by the harness itself: a granted claude-code role gets WebSearch and WebFetch, the broker serves no web verbs, and search only refuses. The choice and every change to it are the plugins setting, logged in changes.jsonl by the plug-in setter.

Adding a kind, or changing a kind's exports, interface version or switch, is a core change: a reviewed edit to src/kinds.mjs, with the code that calls the kind.

Adding a module

  1. Pick a name matching ^[a-z][a-z0-9-]*$.
  2. Create packages/plugins/<kind>/<name>/index.mjs. A real directory and a real file: the loader refuses a symlink anywhere from packages/plugins down, index.mjs included.
  3. Export interfaceVersion and every function the kind lists.
  4. Land it like any other change. A running stack sees it only after a re-pin, because scripts/stack-copy.sh copies the whole packages/plugins directory into the pinned copy.
  5. Switch it on in the organization file's plugins object, under its kind: mosaic plugins shows the setting and its revision, and mosaic plugins set --file PATCH ({ "revision": ..., "plugins": {...} }) changes it, one kind at a time: the patch names the kinds it changes, a kind with null is removed, and a kind it doesn't name keeps its setting. The change goes through the broker's human-only plugins and plugins.set verbs. The setter refuses a bad shape, a name with no module in this tree (unknown-plugin), an unknown kind, an empty patch and a stale revision, and logs actor and old and new names in changes.jsonl. A running broker reads the setting at bus start (REQ-WEB-3's reload will change that), so restart the bus after a switch.

Adding a module changes no core file.

The organization file's plugins key

One key per kind. A list kind takes a list of names; a kind that switches on one module takes one name. An absent key switches the kind off.

"plugins": { "usage-source": ["meter"], "limit-check": [] }

validateBusinessDocument checks the shape: known kinds, the name pattern, no name twice. It doesn't check that a module exists; the loader does that when it loads. The setter writes through the organization writer, which logs actor and old and new names in changes.jsonl like a limit change. A refused write throws OrganizationError (invalid-plugins or invalid-request) and logs nothing.

The loader

import { createLoader } from "../packages/plugins/src/loader.mjs";

const loader = createLoader({ log: entry => record(entry) });
const { loaded, refused } = await loader.load(business.plugins);
loader.modules("limit-check");   // the switched-on modules, in order
loader.loaded();                 // { kind: [names actually switched on] }

The loader finds this package from its own location and takes no path from config. Until Jason rules on Vikunja #286 it loads modules from this tree only (lead decision 143). For each name it checks, in order:

  1. the name matches ^[a-z][a-z0-9-]*$ (bad-name);
  2. packages/plugins, <kind>, <name> and index.mjs exist, are a directory or a regular file as expected, and are not symlinks (missing, symlink); ancestors of packages/plugins are not checked;
  3. just before import, realpath(index.mjs) is still the expected path (symlink);
  4. the module imports (load-failed);
  5. it has every export, and the kind's functions are functions (missing-export);
  6. interfaceVersion equals the kind's (interface-version).

At steps 2 and 3, a path that can't be read for another reason, such as a permission error, refuses as unreadable.

A refusal refuses the whole kind for that load: the kind keeps the modules it had, and the loader calls log with {event: "plugin-refused", kind, name, code, message, kept}. A kind refused on its first load has nothing switched on. An unknown kind refuses only itself; a plugins value that isn't an object refuses every kind. Loads run one at a time.

Limits of the checks:

  • A module runs with the host's authority once imported. The checks keep the loader inside this tree; they don't sandbox the module.
  • A module's top-level code runs at import, before the export and interface-version checks. A module refused for missing-export or interface-version has still run, and Node's cache keeps it.
  • Someone who can write this tree can swap a file between the realpath check and the import. The checks catch mistakes and stale links, not a writer racing the host as the same user.
  • Node caches a module by URL, so a changed index.mjs is not re-read until the process restarts.
  • The bus host wires the loader: row 124 for usage sources, row 125 for limit checks.

Transcript usage source (row 124)

usage-source/transcript now ships interface 1's readTurn({harness, root}). The bus brackets each actual harness turn, reads all sources through the loader, and appends baselines and outcomes. See that module's README for JSONL formats, security/resource bounds and the best-reading limitation. Enable transcript through the human plug-in settings writer; a restart/re-pin remains a separate authorized window.

Limit checks (row 125)

Interface 2. Core calls each switched-on check through the loader only, in the order the plugins setting lists them, with a copy of its arguments:

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} | {path, limit, since, notEnforced}]
  • limits is the business's launch.limits. subject is {instance, provider, model, account}. records are the per-turn usage records the usage sources wrote (usage.mjs turnRecords), each known with its four kinds or unknown. now is an ISO time.
  • hit is {path, limit, count, unknownTurns}. Its path must name a limit in limits with that limit. A count is a safe integer or a digit string.
  • The launcher asks mayLaunch before every launch, through the bus (mayLaunch launch op). A refusal is limit-reached with the hit as its message, or limit-check-failed. The bus asks mustStop after each turn's usage end. A stop lets the turn's result and reads land, then revokes the role claim (or releases the thread claim) with by limit-check and a reason limit: <check> <path> limit <n> count <c>; session.ended and the launcher's end record carry that reason.
  • report feeds Settings and mosaic limits. Core composes one entry per limit: counted, not-enforced (no check reported a count; a check's notEnforced text is the reason) or check-failed.

Fail closed (lead decision 150): a check that throws, answers in the wrong shape, answers with a promise, or is switched on but not loaded refuses the launch, stops the session at the end of its turn, and shows every limit as check failed: <code>. The code is the check's own thrown code when it matches the plug-in code shape, else check-threw or malformed-answer. Both refusals and stops are logged with the check and the code or hit.

token-limit

limit-check/token-limit enforces launch.limits.tokens:

"tokens": {
  "instances": {"coder": {"limit": 5000000, "since": "…"}},
  "providers": {"anthropic": {"limit": 50000000, "since": "…"}},
  "models": {"anthropic": {"claude-opus-5-5": {"limit": 20000000, "since": "…"}}},
  "keys": {"team-key": {"limit": 10000000, "since": "…"}}
}

A count is the sum of all four kinds over the subject's known records since since; unknown turns add nothing and are counted apart. The limits writer sets since when a limit is added; a changed limit keeps it, a caller's since that differs is refused, and only a reset update (a list of paths, mosaic limits reset tokens providers anthropic, Settings, or the chief's limits.set) moves it to now. There is no ceiling (Q150). A key matches a record's account; while no record names one, a key limit reports as not enforced. See limit-check/token-limit/README.md.

daily-quota

limit-check/daily-quota (row 126) enforces launch.limits.tokens.daily, one quota per account, each with the reset time of day and the IANA timeZone its day starts in:

"daily": {"jason-max": {"limit": 40000000, "since": "…", "reset": "05:00", "timeZone": "America/Chicago"}}

A count is the sum of all four kinds over the account's known records since the later of the day's start and since. Only a Claude Code launch carries an account; a pi launch's is null, so no quota covers it. The count is this stack's own use of the account, not its use anywhere else, and mosaic limits and Settings say so. See limit-check/daily-quota/README.md.

Human project/thread channels

channel modules carry project/thread operations between a human surface and its existing core bus caller. The shipped cli and webui modules implement dispatch({ verb, args }, { call }), returning the bus result or refusal unchanged. Only the fixed project/thread operations are supported; CLI also uses message.receive/read for thread talk/follow. Modules do not create a human proof, choose a business/owner, spawn the shim or write organization settings. The broker still sees the proved transport as via: 'cli' for both surfaces. Discord is not connected and no Discord module ships here.

The setting is "plugins": { "channel": ["cli", "webui"] }. An absent key or an empty list disables both converted chat surfaces. No silent default enables them. CLI off refuses with exit 3; web chat off returns 503, both naming recovery. Other CLI administration and Console views are independent. CLI project init/check and non-thread talk/inbox are core operations, outside this conversion.

Each chat operation reads the existing plugins setting, loads only its channel selection through the in-tree loader, captures a frozen snapshot('channel') array of { name, module }, then dispatches without a lock. A slow send does not block a list. A captured array never changes after a reload; loaded() and modules() keep their existing interfaces. Invalid module settings retain the loader's last good selection; the first refused load has nothing enabled.

The recovery commands never need a channel: from a human shell, run mosaic plugins --business BUSINESS --json, make a patch with its revision and {"channel":["cli","webui"]} as the plugins value, then run mosaic plugins set --business BUSINESS --file PATCH. This uses the existing proved human transport and organization writer, with revision checks and actor receipts. The next chat operation reads the restored selection. Disabling a channel cannot disable this write path. It is not a repair bypass for an invalid organization file or unavailable bus.

For the re-pin introducing this kind, Jason enables both channels through plugins.set immediately after activate in Mos's window, then smokes CLI thread listing and Console chat. No development seat performs that live step.