Skip to content

Operators

An operator is whoever runs a BoB install: a person, a company, a client. IWPI is one operator; a client that adopts BoB is another. BoB itself is the product — skills, registry, scripts, schemas, validators — and it must ship the same to every operator. Everything that is true of one operator and not of BoB is operator data: who the people are, which forge org owns the repos, which machines run BoB, which projects are provisioned with what, and where an agent sends a decision it cannot make.

Decision #2266 puts that data in a separate git repository per operator, owned by that operator’s forge org. This page explains the contract #2267 gives it: the profile schema, the resolver every consumer reads through, and the bob operator noun that shows and scaffolds it. The requirements that follow in #2214 move the readers of machines.json, provisions/, ports, mirrors and routes onto this resolver; this page is the seam they all cross.

Before #2266 the operator’s data lived inside BOB_SOURCE: machines.json listed IWPI’s machines, provisions/ held forty-odd IWPI manifests, and deploy.sh synced both into BOB_HOME on every machine. A stable release was therefore a release of IWPI’s fleet as much as of BoB, and #2215 — no release may carry one client’s data to another — could not hold.

The alternatives were weighed in the decision. An operators/<id>/ overlay inside BOB_SOURCE still ships every operator’s data in every release. BOB_HOME is a deploy target that deploy.sh overwrites and that no git history covers, so state kept there breaks the Prime Directive. Each consuming project’s own repo fits project manifests but has nowhere to put org-level state — machines, people, routes — and would scatter the config-review PR flow across every repo. A repo per operator is the one shape where the product ships clean, the data is version-controlled, and one operator’s change is one operator’s PR.

Location Holds Owner
BOB_SOURCE — the product skills, registry, scripts, templates, every schema (including schemas/operator.schema.json), the base provisions/_bob-home.json and _default.json, the product’s own upstream (scripts/bob/upstream.json, below), and the validators operator repos run in their CI BoB’s maintainers
Operator repo operator.json (identity, forge, people, routes, BoB upstream, secret names and sources), machines.json, provisions/<project>.json, per-machine and per-role _bob-home.*.json, ports.json, mirrors.json, projects.json (the derived project registry, #3432), intake project and access config the operator
BOB_HOME — per machine, protected settings.local.json, the deployed copies of the product, and secret values in the stores the profile points at the machine

The line that matters most is the last one. The operator repo declares that a credential exists, what it is called and where its value is kept — never the value. The schema has no field for one, secrets[] refuses any key it does not name, and the validator refuses any string in the file that is shaped like a token, so a profile that carries a value fails before it is read. A committed fixture, schemas/fixtures/operator.rejected.json, exists only to prove that refusal in make check-schemas.

The root file of the operator repo, validated by schemas/operator.schema.json:

Field Declares
id, label a stable kebab slug (iwpi) and a display name
forge host, owner, default_org — named forge-neutrally so a GHES or Gitea operator, and #1746’s second host, fit without a rename
people[] id, name, forge_login, optional email, and roles[] from a closed set: assignee (may be assigned todos), doc-owner (may own a doc’s frontmatter), approver (may approve cap/req work), decision-recipient (receives the routes below)
routes decisions and notifications, each a transport (telegram, github, email, or none — declaring that no route exists yet), an endpoint reference, and the person ids it delivers to; a recipient must hold the decision-recipient role
bob_upstream the repo (and optional host, channel) this operator installs BoB from
secrets[] name and source — where the value is kept, in the credential inventory’s reference notation — plus, since #2286, an optional required: true and a present_at: {kind, ref} presence locator (env / file / keychain / gh-secret / op) that bob secrets check probes for existence and metadata only; nothing else, and no key that could carry a value — see bob-secrets.md
tenants[] optional (#2290) — the operator’s clients: id (opaque, e.g. client-a), label and literals[] (repo names, org names, domains, product names). The release content gate reads client identifiers only from here and refuses a release whose tree names one, so the product repo never names a client to keep it out
seed optional (#2272) — superuser.email and superuser.password_secret, the NAME of a secrets[] entry whose value is the dev superuser’s password; the validator refuses a name secrets[] does not declare, and the schema refuses a literal password. Absent, every project’s generated seed/users.json carries the product default, admin@test.local / admin123 — see dev-lifecycle.md

bob_upstream is operator data (decision #3424): the BoB source this operator’s machines install from, which may be a fork. It is not where BoB itself is published. That is product data, declared once in scripts/bob/upstream.json (bob-upstream/1: repo, host, branch) and read through scripts/lib/bob-upstream.js. The same address goes to every operator.

Every product-side reference derives from it:

  • Schema $id URLs. make check-schemas fails an $id that does not name its own file at the declared upstream.
  • The $schema URL a scaffolded dev.json or docs-site.json carries.
  • The BOB_REPO default in the scaffolded docs-deploy.yml and operator validate.yml.
  • The bob operator init --upstream default, so a new operator’s bob_upstream.repo starts at the product’s upstream.
  • The cdr recover-global clone URL.
  • The Documentation= URL in the systemd units.
  • The issue links the generated references render.

A template carries a {{BOB_UPSTREAM_REPO}}-style token and is filled when it is copied, by bob operator init, docs-site init, render.js for units, or dev-recipe.js. A shell caller reads a field with node scripts/lib/bob-upstream.js get repo|web|raw|ssh, or renders a file with … render <file>. If BoB’s upstream moves, record that as a decision and then change one line. Nothing else in the product names it.

Beside it sit the files the rest of BoB already knows: machines.json (machines.schema.json) and provisions/ (manifest.schema.json). A machines.json entry may declare dashboard.bind (localhost | tailnet, #2636) — the Project Monitor’s bind on that machine when neither a flag nor DASH_BIND names one; a headless farm box declares tailnet so bob warp viz and bob viz print a URL a laptop can open (see project-monitor-exposure.md). scripts/checks/check-operator.js <root> validates all of them together; the scaffolded repo’s validate workflow runs that same script on every push, checking BoB out beside the operator repo to get it.

Every consumer finds the repo through one resolver — scripts/lib/operator-resolve.js, with a sourceable bash twin scripts/lib/operator-resolve.sh for shell callers — and never by joining a path. The resolver is deliberately shaped like bob-source-resolve.sh (#1220), so anyone who knows how BOB_SOURCE is found knows how the operator is found:

  1. $BOB_OPERATOR — an explicit export always wins. It wins even when the directory holds no operator.json: that is the profile’s problem, reported by bob operator show as an invalid profile, never a silent fall-through to a rung the human did not ask for.
  2. The pointer file $BOB_HOME/.bob-operator — one line naming the operator root, written at install by bob operator init --link. Taken only when that directory holds operator.json; a stale pointer is skipped and the skip is recorded.
  3. The checked default ~/projects/bob-operator, on the same rule.
  4. Unresolved. No fallback into BOB_SOURCE or BOB_HOME — the two places operator data must not live. Reads degrade to an explicit no operator declared; writes of operator data are refused, as #1220 refuses a manifest write that would land in the deploy target.

The result is one document, bob-operator-resolve/1: the root, the rung that won, and every rung tried with the reason it was or was not taken. The two implementations are held to the same rungs by tests/test-operator-resolve.js, which runs both against the same sandboxes and fails on any disagreement.

Resolving the repo is half of it; code also has to reach the files inside it without ever joining a path of its own. The same resolver owns that too. operatorPath(<name>) in operator-resolve.js — bob_operator_path <name> in the shell twin, node scripts/lib/operator-resolve.js path <name> from anywhere — is the one way to machines.json, mirrors.json, provisions (the directory) and any provisions/<x>.json, ports.json, cloudflare-accounts.json and intake-config (apps/intake/config). For each name it answers in order:

  1. The operator repo, when it holds the file. Source operator, no noise.
  2. The legacy BOB_SOURCE location, for one release. Existing machines keep working while their files move, but the read is announced: a deprecation warning on stderr, once per process per name, naming the file, the operator repo it belongs in (or how to declare one) and the dated retirement — this fallback retires 2026-10-31 (#2270). After that date the rung goes.
  3. Neither present. The operator location when an operator resolved — that is the write target — else the legacy location, both with exists: false. A writer that finds no operator resolved refuses, the way #1220 refuses a manifest write that would land in the deploy target.

Two manifests are the product’s, not the operator’s: provisions/_bob-home.json (the base global skillset) and provisions/_default.json (the manifest template). They always resolve into BOB_SOURCE, source product, and never warn — an operator repo carrying a copy does not win. listManifests() reads every manifest exactly once across the two directories: the operator’s provisions/ wins a file-name collision, the legacy dir fills the gaps, and the two product globals come from BOB_SOURCE. A half-moved fleet therefore sees one consistent set.

One more rule keeps tests honest. A BOB_SOURCE tree that itself carries operator.json is self-contained — the shape of a test fixture, since the product repo never carries a profile — and is its own operator, whatever this machine’s pointer or $BOB_OPERATOR say. A consumer handed a fixture root therefore reads that fixture and nothing else: not this checkout’s machines.json, not a real manifest, and never the legacy rung whose warning would land in a parsed output. That is how the tests satisfy #2270’s rule that no test reads the real files — each fixture mkdtemp writes an operator.json beside its provisions/.

What keeps every consumer on this path is the operator-path rule in check-abs-paths: a path.join(bobDir, 'provisions', …) or a "$BOB_SOURCE"/machines.json in any synced script, hook, template, registry item or app fails make check. The resolver’s two files are the rule’s only exemption; a check that grades the checkout’s own copy (check-schemas over provisions/_default.json) is an allow-list entry with that reason.

Writes go the same way, one step further. A manifest edit — the staged config branch of config-branch.sh (_cb_root), the #1478 fast path, --now and the ungated route in provision.js — lands in the operator repo: a self-contained BOB_SOURCE counts, otherwise the resolver’s rungs, and with no operator resolved the write is refused with a #1220-style error naming bob operator init <dir> --link, never landing in the product repo or the deploy target. The fast path therefore has no deploy step any more: it merges, fast-forwards the operator checkout and refreshes; deploy.sh no longer syncs provisions/ or machines.json at all (it retires a copy an earlier deploy left in BOB_HOME), and bob doctor, whose surface list is derived from deploy.sh, no longer grades them.

A manifest declares workflow levers too (#2703)

Section titled “A manifest declares workflow levers too (#2703)”

A manifest says which registry items a project needs — and, since #2703, which _workflow levers it runs with. A _workflow block in provisions/<project>.json is the project’s version-controlled answer to a question that until then only the machine-local, never-committed .claude/settings.local.json could answer: is delegate_mechanical_chunks on for this project? Declaring it in the manifest means the answer travels with the operator repo, reviews like any other config change, and is the same on every machine that clones it.

The lever resolves through four layers, lowest first — WORKFLOW_DEFAULTS (the product floor) < provisions/_default.json _workflow (the product’s fleet-wide declaration, so it resolves into BOB_SOURCE like every other product manifest) < provisions/<project>.json _workflow (the operator’s) < settings.local.json _workflow (this machine’s). Each layer overrides the one before it key by key, so an explicit 0 or false in a manifest still wins over a truthy default, and a machine keeps the last word for a one-off. config get <key> --json names the winning layer as source (default / fleet-manifest / manifest / settings), config list --sources prints it per key, and bob warp config shows it as a SOURCE column.

Both manifests are read through operatorPath(), so the layer inherits every rule above — including the legacy rung and its warning. It is also entirely fail-open: no operator resolved, no manifest, an unreadable or malformed file, or a _workflow that is not an object all resolve to {}. A warp run never fails because a manifest could not be read; it falls back to the behaviour that existed before the layer. A key the running BoB does not declare is likewise ignored at resolve time, so an operator repo written against a newer BoB cannot poison an older machine’s run.

Refusal happens at validation time instead: the _workflow block of schemas/manifest.schema.json enumerates exactly the keys WORKFLOW_DEFAULTS declares, generated from that table by check-workflow-keys.js --write. An undeclared key therefore fails make check here and check-operator.js in the operator repo’s own CI — the same rule config get applies, applied one layer earlier. The key reference is Workflow config (_workflow).

Two consumers need a word. The TellBoB intake Worker bundles its config at build time, so apps/intake/scripts/sync-config.mjs (the predev, predeploy, pretypecheck and pretest npm hooks; npm run config:sync / config:check by hand) resolves intake-config and copies the operator’s apps/intake/config/ into the app before wrangler, tsc or vitest runs — a no-op while the legacy location is that directory, and a copy of the neutral config.example/ samples when nothing resolves, as in CI (#2635). And the tests read one sample operator repo, tests/fixtures/operator/sample/ — a neutral operator with a three-machine fleet (laptop-a, laptop-b, farm-01), a mirror declaration, a farm-worker role manifest, a port ledger and an intake config — copying from it or pinning BOB_OPERATOR at a copy, so no test reads this checkout’s machines.json or a real manifest.

That last sentence is a rule, and since #2776 the resolver enforces it rather than trusting it. The enforcement sits in the resolver, not in the test runner, because a runner-only guard is one the normal path walks around: scripts/run-tests.sh sandboxed the operator repo and diffed it before and after the run, which made make test safe and left bash tests/test-x.sh — how you iterate on a single red suite — writing manifests and ports.json into the real repo. make check then went red on check-projects and stayed red.

So operatorPath(), the one door every operator-data read and write goes through, calls assertSandboxedOperator() first. When BOB_TEST is set, the resolved operator root must be a test operator — a throwaway under the temp dir, or a committed fixture under tests/fixtures/ — and anything else is refused by name:

operator-resolve: REFUSED — tests/test-demo.sh tried to reach operator data
"provisions/bigbrain.json" in /Users/you/projects/bob-operator, which is not a
test sandbox.

The refusal names the suite (BOB_TEST_SUITE), the root, and the fix. Three details are deliberate:

  • Reads are refused alongside writes. operatorPath resolves a path without knowing the caller’s intent, and a suite whose outcome depends on the developer’s own operator repo is its own defect.
  • A committed fixture operator is allowed. A suite that wants a fixed, reviewable fleet — test-tick-timer.sh renders golden units from tests/fixtures/tick-timer/ — points BOB_OPERATOR at the repo. A stray write there shows up in git status on the next command, which is the property the guard is protecting.
  • BOB_TEST_ALLOW_REAL_OPERATOR=1 opts back out, so a deliberate real-operator test never has to defeat the guard by unsetting BOB_TEST.

BOB_TEST is exported by run-tests.sh and by both operator_sandbox helpers, so a suite arms the guard for itself at the moment it takes its sandbox. The helpers are idempotent and clean up on exit, which makes adopting one a two-line change:

Terminal window
# shellcheck source=lib/operator-sandbox.sh
source "$(cd "$(dirname "$0")" && pwd)/lib/operator-sandbox.sh"
operator_sandbox
require('./lib/operator-sandbox').autoSandbox(); // ESM: import the default, then call it

Outside test mode the guard is a no-op, so production resolution is unchanged.

Doing it for the first time? Set Up Your Operator Repo gives the steps in order, and the recovery for a scaffold that shadows a live fleet.

bob operator show [--json] resolves, validates and prints the profile with the winning rung — exit 0 resolved and valid, 3 no operator declared, 2 an invalid profile. An invalid profile is reported by error path; the file is never echoed, because the thing that made it invalid may be the value the schema exists to refuse.

bob operator init <dir> [--link] scaffolds an operator repo from templates/operator/ — the profile, an example machines.json to replace, provisions/, a README, and the validate workflow — git-inits it, validates the result, and writes the pointer only with --link. Without the flag it says how to link; with it, bob operator show resolves the new repo at once.

bob operator migrate [--source <checkout>] [--apply] [--force] [--json] moves the operator’s data out of a BoB source checkout into the resolved operator repo with history (#2270): every commit that touched machines.json, mirrors.json, config/cloudflare-accounts.json, apps/intake/config/ or a non-product provisions/*.json is replayed there (git format-patch --root from the source, git am --3way in the operator repo, authors and dates intact), the init scaffold’s placeholder machines.json makes room for the real fleet, and the source removes the files on the branch chore/2270-move-operator-data for a PR — _bob-home.json and _default.json stay, .gitignore learns apps/intake/config/. It is a dry run until --apply; a dirty tree, a file the operator repo already holds (--force replaces it), an in-flight move branch and no operator are each an explicit refusal (exit 1 / 3), and a failed replay is aborted with nothing applied. The IWPI move waits on a human’s choice of the repo’s name and home — the todo #2270 filed; once it carries completed, the move is this one command.

bob doctor names the operator this machine resolves as one ungraded line: INFO operator: iwpi — IWPI (pointer: …), WARN for a resolved profile that does not validate, or INFO operator: no operator declared. The bob-host-drift/1 document carries it as operator, and the fleet snapshot keeps the compact form, so which operator each machine runs is visible from any other machine. It is never drift: until the legacy in-repo fallback is retired, a machine with no operator is legal, and the doctor’s job is to say so, not to fail.

Every place BoB used to name one person reads it from people[] through one module, scripts/lib/operator-people.js (#2269). The first person listed with a role holds it — people[] order is precedence — and unresolved is a state the caller reports, never a guess:

Consumer Role With no operator resolved
Todo assignee — setup-promotion/human-gate.js, warp-drive/pm-frontend.js emit-todo, warp-drive/preflight.js blocker TODOs assignee → forge_login Filed unassigned; the plan carries assignee_reason and the command prints it (filed unassigned — no operator declared). A profile whose people hold no assignee role files unassigned too, and says so.
BoB upstream — warp-drive/trace-mine-session.sh when gh repo view cannot answer bob_upstream.repo The checkout’s own origin remote (owner/name, derived from git); with neither, the friction issue is not filed and the script says why.
Doc owner: — the doc-bootstrap drafts (doc-bootstrap/generate.js) and the runbook scaffold (runbook-gen/scaffold.js), which write into an operator’s own projects; an explicit --owner still wins. BoB’s own generated references (bob/help.js --reference, checks/check-workflow-keys.js --write, docs-site/lib/gen-reference.js --warp-flags) are product pages and never consult the profile: each takes owner: from its own committed frontmatter (productDocOwner(), #2689), so the product ships the same to every operator doc-owner → forge_login The owner of the repo’s origin remote (one accountable handle, derived from git); with no remote either, the visible placeholder unassigned. A product reference is unaffected — it reads its own page.
Settings owner — projectContext.owner / .company in a new project’s .claude/settings.json (templates/project-settings.json ships OWNER_PLACEHOLDER / COMPANY_PLACEHOLDER; bob init fills them with operator-people.js fill-settings) doc-owner → name, the profile label git config user.name, company empty; with no name, owner left empty.

The same lookups are one command for shell callers — node scripts/lib/operator-people.js assignee|doc-owner|upstream|settings-owner [--repo DIR] [--json] prints the value (empty when none) with the reason on stderr, --json one bob-operator-people/1 document; exit 0 resolved · 3 none · 2 usage.

Proving it: the clean-install acceptance (#2273)

Section titled “Proving it: the clean-install acceptance (#2273)”

The contract is only as good as a second operator’s first install. The acceptance for it, #2273, is one script, tests/acceptance/operator-clean-install.sh, and one how-to that walks the same steps by hand, Run BoB as a New Operator.

The script installs BoB the way a newcomer would — clone, the newest stable tag, deploy.sh --first-run, bob-install.sh — into a fresh HOME, under an OS user it is told to expect (--expect-user; it refuses to run as anyone else). It links an operator repo built from bob operator init and the fixture profile tests/fixtures/operator/scratch-tenant/ (a different forge owner, --org), declares the host in that repo’s machines.json, makes a sample project, and runs bob doctor and bob ready in a new interactive shell, so PATH comes from the installed rc line and nothing of the invoking shell leaks in. Both must exit 0, and nothing may be edited outside the operator repo: the BoB source checkout stays the tag, and the script snapshots the whole fresh HOME before bob doctor and diffs it after bob ready. Each changed path outside the operator repo must be on an explicit list of BoB’s own derived writes — runtime state in BOB_HOME and ~/.local/state, the project’s .claude/ links, its managed .gitignore block, the git hooks bob init installs — each with the reason it is not an edit; any other path fails the run.

Then it scans for the first operator. Everything the run printed (every report and every --json document), and every file in the BOB_HOME it produced, is matched against the operator-literal set (#2268) read through abs-path-lint.js’s own loader — never a second list. Matching ignores case and also sees percent-encoded and \u-escaped forms; binary and UTF-16 files are matched, not skipped; a symlink out of the tree is followed; a file the scan cannot read fails it rather than counting as clean. The one exemption is derived, not typed: the declared upstream repo from scripts/bob/upstream.json (#2441), removed from each line only as a whole token, because where the product is published is not who operates it — another repo whose name merely starts with it is still a leak. Any other hit fails the run and is listed by file and line. The lint’s *.md exclusion does not apply here: a runbook or command page that names one operator is shipped to every operator all the same.

The run writes operator-clean-install/1 evidence (--evidence <file>): tag, org, user, host, whether the user was really isolated, the doctor and ready verdicts, the leak list, the edits made inside the operator repo, and the abs-path-allow.json entries still carried until a requirement (AC3 is reported, never edited). --local runs it as yourself in a throwaway HOME for development; that evidence records user_isolation: false and acceptance_eligible: false, so it can never stand in for the run on a host with a second user.

This requirement created the contract and the lookup and moved no consumer. The chain it heads (#2214) did the moving, and most of it has landed. The operator-literal gate keeps identity literals out of the product (#2268). People and ownership — assignees, doc owners, the settings owner and the upstream fallback — read from people[] and bob_upstream (People and ownership, #2269); the product’s own upstream references are declared once (The product’s own upstream, #2441), and the fixture sample data names a fictional operator (#2442). Machines, manifests, ports, mirrors and intake config resolve from the operator repo, with a deprecation fallback to the in-repo location until 2026-10-31 (Reaching the operator’s files, #2270). The IWPI operator repo exists at ~/projects/bob-operator, linked through the ~/.claude/.bob-operator pointer, and its files were moved there with bob operator migrate. The seed-user convention is an operator default (seed.superuser, #2272).

Two pieces remain. Decision and notification routes that deliver to the declared recipients (#2271) wait on per-recipient delivery in the remote-decision bridge. And the clean-install acceptance (#2273) proves that a second operator, under a different user and forge org, passes bob doctor and bob ready on a clean install with no manual edits. The decision record itself, #2266, is still an open issue.