Operators
Operators
Section titled “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.
Why a repo of its own
Section titled “Why a repo of its own”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.
What lives where
Section titled “What lives where”| 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.
What operator.json declares
Section titled “What operator.json declares”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 |
The product’s own upstream (#2441)
Section titled “The product’s own upstream (#2441)”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
$idURLs.make check-schemasfails an$idthat does not name its own file at the declared upstream. - The
$schemaURL a scaffoldeddev.jsonordocs-site.jsoncarries. - The
BOB_REPOdefault in the scaffoldeddocs-deploy.ymland operatorvalidate.yml. - The
bob operator init --upstreamdefault, so a new operator’sbob_upstream.repostarts at the product’s upstream. - The
cdr recover-globalclone 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.
How the operator repo is resolved
Section titled “How the operator repo is resolved”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:
$BOB_OPERATOR— an explicit export always wins. It wins even when the directory holds nooperator.json: that is the profile’s problem, reported bybob operator showas an invalid profile, never a silent fall-through to a rung the human did not ask for.- The pointer file
$BOB_HOME/.bob-operator— one line naming the operator root, written at install bybob operator init --link. Taken only when that directory holdsoperator.json; a stale pointer is skipped and the skip is recorded. - The checked default
~/projects/bob-operator, on the same rule. - 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.
Reaching the operator’s files (#2270)
Section titled “Reaching the operator’s files (#2270)”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:
- The operator repo, when it holds the file. Source
operator, no noise. - 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. - 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 atest sandbox.The refusal names the suite (BOB_TEST_SUITE), the root, and the fix. Three
details are deliberate:
- Reads are refused alongside writes.
operatorPathresolves 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.shrenders golden units fromtests/fixtures/tick-timer/— pointsBOB_OPERATORat the repo. A stray write there shows up ingit statuson the next command, which is the property the guard is protecting. BOB_TEST_ALLOW_REAL_OPERATOR=1opts back out, so a deliberate real-operator test never has to defeat the guard by unsettingBOB_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:
# shellcheck source=lib/operator-sandbox.shsource "$(cd "$(dirname "$0")" && pwd)/lib/operator-sandbox.sh"operator_sandboxrequire('./lib/operator-sandbox').autoSandbox(); // ESM: import the default, then call itOutside test mode the guard is a no-op, so production resolution is unchanged.
The bob operator noun
Section titled “The bob operator noun”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.
People and ownership
Section titled “People and ownership”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.
Where this leads
Section titled “Where this leads”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.