Skip to content

Cluster config mirror contract

This page is the single source of truth for two contracts consumed by cluster setup and monitoring code (M1 mirror tickets, CTL-1192 heartbeat quota):

  1. Config-mirror contract — every config item classified SHARED (copy verbatim to a new node) or PER-NODE (regenerate on each host), with exact file and key locations.
  2. Quota field-name schema — the dotted event-log keys emitted by ratelimit-event.mjs, pinned here so heartbeat and quota consumers (CTL-1192) share one field-name contract.

For the two-layer config model (.catalyst/config.json vs ~/.config/catalyst/config-{key}.json) see the configuration reference.


When you provision a second node, copy everything marked SHARED verbatim and regenerate everything marked PER-NODE. The classification is encoded in config.mjs getHostName (PER-NODE), config.mjs resolveClusterHosts (the roster — SHARED, resolved live from the catalyst-cluster repo via readClusterConfig), and config.mjs getLivenessAnchorIssue (SHARED).

Config itemFile / keyClassOn mirror
Bot OAuth orchestrator token~/.config/catalyst/config.jsoncatalyst.linear.bot.orchestrator.*SHAREDCopy the whole catalyst.linear.bot block (one Linear app per workspace; identical across nodes)
Bot OAuth worker token~/.config/catalyst/config.jsoncatalyst.linear.bot.worker.*SHAREDSame block — worker and orchestrator tokens are workspace-scoped, not host-scoped
Cluster rostercatalyst-cluster repo → cluster.json roster[]SHAREDAdd the new node’s name to cluster.json.roster and push. cluster-sync pulls it and the next scheduler tick honors it — no restart. (The legacy committed .catalyst/hosts.json roster was retired in CTL-1274; the daemon no longer reads it.)
Layer-1 project config.catalyst/config.jsonSHAREDCommitted to git; present after git clone
Liveness anchor issue~/.config/catalyst/config.jsoncatalyst.cluster.livenessAnchorIssueSHAREDCopy from the seed node; one Linear ticket identifier per fleet
Cloud token (CATALYST_CLOUD_TOKEN)catalyst-cluster repo → secrets/cluster-cloud.sops.json catalyst.cloud.tokenSHAREDOne shared catalyst-cloud service credential (CTL-1307). Add it once to the cluster repo (SOPS); cluster-sync decrypts it to ~/.config/catalyst/cluster-cloud.json and cloud-token-env.mjs projects it to the machine-level env (cluster.env + ~/.zshenv guard) on every node. Intentionally unread by catalyst core — a prerequisite for the opt-in cloud path, not a switch that turns it on.
Plugin source~/catalyst/plugin-source/SHAREDPull from the same git remote; setup-plugin-source.sh does this
Linear team/state mapLayer-1 catalyst.linear.teamKey / stateMapSHAREDPresent after git clone via .catalyst/config.json
catalyst.host.name~/.config/catalyst/config.jsoncatalyst.host.namePER-NODESet to the new node’s unique roster entry (must match an entry in the catalyst-cluster repo’s cluster.json.roster; a name that isn’t in the roster owns zero tickets under HRW)
repoRoot~/catalyst/execution-core/registry.jsonrepoRootPER-NODEThe absolute path on the new host; written by catalyst-execution-core register
Claude Code account loginmacOS Keychain or ~/.claude/.credentials.jsonPER-NODERun claude interactively on the new host; each node uses its own account
OTel endpoints~/.config/catalyst/config.json → OTel keysPER-NODETailscale addresses differ per node; set in Layer-2 on each host
execution-core.env~/catalyst/execution-core/execution-core.envPER-NODEProxy / tuning overrides are host-specific
Event log~/catalyst/events/YYYY-MM.jsonlPER-NODEEach node writes to its own log; nodes never share log files
SQLite databases~/catalyst/*.db (4 files)PER-NODEHost-local state; not replicated
Worktree trust~/.claude.json per worktree pathPER-NODEPaths differ; re-trust on each host
Linear personal token~/.config/catalyst/config-<key>.jsonlinear.apiKeyPER-NODEPersonal token is user-scoped; each operator provides their own
Webhook secrets~/.config/catalyst/config-<key>.json → webhook keysPER-NODERegenerate or copy securely; not managed by the mirror process

Why bot OAuth is SHARED: catalyst.linear.bot.orchestrator and catalyst.linear.bot.worker are credentials for a Linear OAuth application that is registered once per workspace. Every node in the fleet acts on behalf of the same app. The tokens live in machine-global ~/.config/catalyst/config.json (not in the per-project config-<key>.json) so all nodes can share them without per-project duplication.

Why the cloud token is SHARED + machine-level (CTL-1307): CATALYST_CLOUD_TOKEN is a single service credential (the catalyst-cloud ADMIN_TOKEN, interim per CTC-27 / ADR-0006) that must be identical on every node, so it lives once in the catalyst-cluster repo’s secrets/cluster-cloud.sops.json (a separate SOPS file from cluster-bots so its rotation/GC lifecycle is independent — it is superseded by per-tenant org-scoped keys per CTC-46). cluster-sync decrypts it to ~/.config/catalyst/cluster-cloud.json; cloud-token-env.mjs (run by catalyst-stack at boot + keep-alive, or on demand via catalyst-stack sync-cloud-env) projects it into the machine-level environment: the secret is written to a 0600 ~/.config/catalyst/cluster.env, and a single non-secret guard line in ~/.zshenv sources it — so every login shell, and any cloud daemon (re)started in a shell context (this fleet’s convention for env-key pickup), inherits CATALYST_CLOUD_TOKEN. Default behavior is unchanged: nothing in catalyst reads the variable; a node stays fully local-only until the operator separately opts into cloud services.


Single source of truth: ratelimit-event.mjs:63-70 (line 62 emits the account.email identity key, which is not part of the quota schema below).

Event name: account.ratelimit.sampled (severity INFO, emitted every poll tick).

The table below documents the eight dotted attribute keys emitted by buildRatelimitEnvelope. Consumers (orch-monitor, HUD, CTL-1192 heartbeat quota) must reference these names, not the camelCase params used internally by ratelimit-poller.mjs.

Attribute keyTypeMeaning
ratelimit.five_hour_pctnumber5-hour rolling usage as a percentage of the window limit (0–100+)
ratelimit.seven_day_pctnumber7-day rolling usage as a percentage of the window limit
ratelimit.five_hour_resets_atstring (ISO-8601)When the 5-hour window resets
ratelimit.seven_day_resets_atstring (ISO-8601)When the 7-day window resets
ratelimit.seven_day_opus_pctnumber7-day Opus usage as a percentage — the binding limit on Max 20x plans (exhausts before ratelimit.seven_day_pct on Opus-heavy allocations)
ratelimit.seven_day_sonnet_pctnumber7-day Sonnet usage as a percentage
subscription.typestringClaude subscription tier (e.g. "max")
rate_limit.tierstringAPI rate-limit tier identifier

All eight keys are conditional: a key is omitted from the attributes map when its source value is null or undefined. Consumers must treat absent keys as unknown, not as zero.

ratelimit-poller.mjs:257-262 passes values to emitRatelimitEvent using camelCase parameter names (fiveHourPct, sevenDayPct, opusPct, sonnetPct, etc.). These camelCase names are internal-only and must not appear in consumer code or heartbeat schemas. The dotted keys in the table above are the contract; the camelCase params are an implementation detail of the emitter.

CTL-1192 heartbeat quota{} shape (proposed)

Section titled “CTL-1192 heartbeat quota{} shape (proposed)”

When CTL-1192 extends the heartbeat Linear attachment with a quota{} block, it should map the dotted event keys directly:

{
"quota": {
"five_hour_pct": 42,
"seven_day_pct": 18,
"seven_day_opus_pct": 67,
"seven_day_sonnet_pct": 12,
"five_hour_resets_at": "2026-06-16T06:00:00Z",
"seven_day_resets_at": "2026-06-20T00:00:00Z",
"subscription_type": "max",
"rate_limit_tier": "usage_tier_2"
}
}

Use the snake_case field names (strip the ratelimit. prefix) so the heartbeat attachment stays human-readable. The source event keys remain the canonical names — this shape is a derived view.