Credential Inventory
Credential Inventory
Section titled “Credential Inventory”What exists, where it is stored and who uses it — declared once, in
secrets-inventory.json, with no values. Rotation (#2239), the presence check (#2238) and the revocation drills (#2240) all start here (#2237).
Why an inventory
Section titled “Why an inventory”Rotation, drills and revocation all begin with knowing what exists. Before
this file, that knowledge was scattered across gh secret list on each repo,
wrangler secret list on each Worker, the .mcp.json on each machine, the
workflows’ secrets.* references and one runbook. The inventory is the
declared state those places must agree with: a credential is declared here
first, then present where the entry says (the presence check reports the
difference), then rotated on the interval the entry declares.
It is version-controlled and public-repo safe because it holds references, never values — and the gate makes that structural, not a convention.
The file
Section titled “The file”secrets-inventory.json at the repo root, validated against
schemas/secrets-inventory.schema.json:
{ "_meta": { "version": 1, "convention": "…" }, "credentials": [ { "id": "rdb-bridge-secret", "purpose": "Shared secret a caller presents to the remote-decision-bridge Worker …", "class": "bridge-secret", "locations": [ { "kind": "wrangler-secret", "ref": "remote-decision-bridge:BRIDGE_SECRET" }, { "kind": "mcp-json", "ref": "~/projects/bigbrain/.mcp.json#mcpServers.remote-decision-bridge.env.BRIDGE_SECRET", "machine": "farm-01" }, { "kind": "gh-secret", "ref": "paulirv/bigbrain:BRIDGE_SECRET", "note": "declared by auto-release.yml; not set as of 2026-09-15" } ], "consumers": { "projects": ["bigbrain", "remote-decision-bridge"], "machines": ["farm-01", "macbook-pro-16", "macbook-air"], "workflows": [".github/workflows/auto-release.yml"] }, "scope": "Any holder may ask and notify through the bridge as BoB …", "owner": "paulirv", "created": null, "expires": null, "rotation_interval": "P90D", "runbook": null, "last_rotated": null, "notes": "Rotation touches every copy at once …" } ]}Entry fields
Section titled “Entry fields”| Field | Meaning |
|---|---|
id |
Stable kebab-case handle — what rotation todos, presence reports and runbooks refer to. Unique. |
purpose |
What it lets its consumers do, one sentence. |
environments |
Optional. The environments the credential is set in separately — [{"name": "test"}, {"name": "prod", "last_rotated": "…"}] — see The environment axis. Absent: one credential, graded as a whole. |
class |
cloudflare-api-token · github-pat · github-actions-secret · bridge-secret · tailscale-auth-key · other. A class is what a rotation runbook is written for (#2239); other carries its detail in purpose and notes. |
locations |
Every place a copy is stored, source of truth first — see Location kinds. |
consumers |
projects (names in projects.json), machines (ids in machines.json), optional workflows (repo-relative workflow files). |
scope |
The narrowest honest statement of what it may do — repos, permissions, zones, accounts. |
owner |
The accountable human, as a GitHub login. |
created / expires |
ISO dates, or null when unknown / no hard expiry. |
rotation_interval |
ISO 8601 duration in whole days, weeks, months or years (P90D, P6M, P1Y), or null when it is not rotated on a schedule — say why in notes. |
runbook |
Repo-relative path of the rotation runbook, or null while none exists. |
last_rotated |
ISO date, or null; updated by the rotation PR — cred-check.js rotated <id> on a branch (#2239). |
notes |
What the fields cannot say — an unconfirmed assumption, a copy declared but not yet set, a family of per-reporter tokens. |
The schema is additionalProperties: false at every level: there is no field a
value could be slipped into.
Location kinds
Section titled “Location kinds”A location is where a copy is, written in that kind’s own notation. The gate checks the notation, so a ref cannot degrade into a free-text blob.
kind |
ref notation |
machine |
|---|---|---|
gh-secret |
owner/repo:NAME |
— |
wrangler-secret |
worker:NAME — plus an optional account, below |
— |
mcp-json |
<file>#<dotted.json.path> |
optional (one copy per machine) |
config-hash |
<file>#<json.path> — a committed digest of the value, e.g. the tellbob reporter tokens; also any JSON record whose key marks a copy, e.g. the self-hosted runner’s .credentials#data.clientId (a UTF-8 BOM is tolerated, #3661) |
— |
1password |
Vault/Item or Vault/Item/field |
— |
gh-auth · claude-auth · ssh-key · keychain · env-file · file |
a path or account | required — a machines.json id |
automatic |
platform:NAME — a credential the platform mints per run (github-actions:GITHUB_TOKEN) |
— |
account on a wrangler-secret location (#3659). wrangler secret list
runs against one Cloudflare account, and on a machine logged in to more than
one wrangler cannot pick an account non-interactively — it exits, and every
Worker location on that machine grades unknown while the undeclared sweep
never reads a Worker at all. A wrangler-secret location may therefore name
the account its Worker lives in:
{ "kind": "wrangler-secret", "ref": "example-awards:STREAM_API_TOKEN", "env": "prod", "account": "default" }account is a key of the operator’s config/cloudflare-accounts.json
accounts map (read through the operator resolver, #2270), or the 32-hex
account id itself. cred-check.js resolves it once per run and passes the id
to wrangler as CLOUDFLARE_ACCOUNT_ID — in the presence probe and in the
undeclared sweep, whose store is then that Worker in that account, so one
Worker name in two accounts is two probes. A key the file does not declare,
or no file, is unknown naming the key and the file; wrangler is never run
against a guessed account. A location without account behaves as before,
and the field is refused on every other kind (account-on-kind). The report
row carries the account the location declared.
A note on a location records what the presence check will want to know:
declared by a workflow but not set, declared from machines.json membership,
not observed. On an entry that declares environments, a location also
names the environment it serves in env.
The environment axis
Section titled “The environment axis”A secret set separately on a test Worker and a prod Worker is one entry
with two environments, not two entries and not one entry with one date
(#3082). The entry declares environments; each copy says which one it
serves in env; a copy with no env (the 1Password source of truth, say) is
shared by all of them.
{ "id": "example-awards-stream-api-token", "class": "cloudflare-api-token", "environments": [ { "name": "test", "note": "env.test is scaffolded by #3080 and provisioned by #3069 — not set as of 2026-09-29" }, { "name": "prod" } ], "locations": [ { "kind": "wrangler-secret", "ref": "example-awards-test:STREAM_API_TOKEN", "env": "test" }, { "kind": "wrangler-secret", "ref": "example-awards:STREAM_API_TOKEN", "env": "prod" } ], "created": null, "rotation_interval": "P1Y", "last_rotated": null, …}- An environment item is
{name}plus, where that environment differs from the entry, its owncreated,expires,rotation_intervalandlast_rotated. An absent date inherits the entry’s. - The presence check grades each environment from its
own copies — a shared copy is still graded, but never counts as an
environment holding it — so the report says
test present · prod missing. - Rotation is judged per environment from its own dates: prod can be overdue while test, rotated last month, is not.
- The rotation schedule files one todo per
environment that is due, and
rotated <id> --env <name>records one environment’s rotation. - The gate refuses an environment named twice, a copy whose
envthe entry does not declare, and a declared environment with no copy of its own.
example-awards is the first consumer: its Worker secrets (STREAM_API_TOKEN,
WAE_SQL_API_TOKEN, CRON_SECRET, the Eloqua login and identifiers) are
declared per environment for the example-awards-test and example-awards Workers,
and its GitHub Packages token (example-awards-github-packages-token, the
NODE_AUTH_TOKEN Actions secret) is dated, so the lapse that stopped its
production deploys from 2026-08-26 (example-co/example-awards#331) is a
scheduled rotation next time.
How it was seeded
Section titled “How it was seeded”Names only, on 2026-09-15, from what each store lists without revealing a value:
gh secret list -R <owner>/<repo>across every managed repo reachable from farm-01 (CLOUDFLARE_API_TOKEN,CLOUDFLARE_ACCOUNT_ID,SENTRY_AUTH_TOKEN, the Acquia trio,DRUPAL_BASE_URL);wrangler secret listontellbob-intakeandremote-decision-bridge;- the
envkeys of.mcp.json(BRIDGE_SECRET,BRIDGE_WORKER_URL); - the
secrets.*references in.github/workflows/*.yml(BOB_CHECKOUT_TOKEN,GITHUB_TOKEN, the bridge pair); - the existing runbooks —
tellbob-token-lifecyclewas the one rotation runbook that existed at seeding time and the onerunbookset (#2239 added the class runbooks); - the per-machine grants every fleet machine needs to run BoB at all (gh CLI,
Claude Code, SSH identities), declared from
machines.jsonmembership.
Two seeding rules, both visible in the notes: a name stored on several repos
is one entry with several locations until the provider’s dashboard proves
they are distinct tokens (then split the entry); and a copy a workflow
declares but no store holds is recorded with a note, never invented as
present — that is the presence check’s first finding (#2238), not a fix this
file makes. Non-secret identifiers stored as secrets by convention
(CLOUDFLARE_ACCOUNT_ID, the GitHub App ids, endpoint URLs) are inventoried
with rotation_interval: null so the presence check does not report them as
undeclared.
The gate
Section titled “The gate”make check-secrets-inventory # in make checknode scripts/checks/check-secrets-inventory.js --json # bob-secrets-inventory-check/1node scripts/checks/check-secrets-inventory.js --root DIR # grade another checkout (tests)Exit 0 clean · 1 violations · 2 a file it needs is unreadable
(secrets-inventory.json, its schema, projects.json, machines.json).
check-schemas also validates the file against the schema; this gate adds
what a schema cannot say.
| Finding | Meaning |
|---|---|
secret-shape |
A string, anywhere in the document, is shaped like a secret value — see below. The JSON path is named; the string is never printed, and a schema error that would echo it is withheld. |
schema |
The document does not validate against the schema. |
duplicate-id |
Two entries share an id. |
unknown-project / unknown-machine |
A consumer or location names a project projects.json does not register, or a machine machines.json does not declare. Both are operator data (#2270, #3432): with no operator registry resolved, that name goes unchecked rather than flagged. |
machine-required |
A per-machine copy (gh-auth, claude-auth, ssh-key, keychain, env-file, file) names no machine. |
bad-ref |
A location’s ref is not in its kind’s notation; the message names the expected form. |
account-on-kind |
A location other than wrangler-secret names a Cloudflare account (#3659). |
missing-runbook |
A runbook path does not exist in the checkout. |
duplicate-env |
An entry declares the same environment twice (#3082). |
env-undeclared |
A location’s env is not one of the entry’s environments — or the entry declares none. |
env-unlocated |
A declared environment has no copy of its own, so the check could never say whether it holds the credential. |
template-secret |
A line of a template that reads an inventoried credential by name — the backup Worker, templates/backup-worker/ (#3084) — is shaped like a secret. The file:line is named; the line is never printed. A template carries a secret’s name, never its value. |
Warnings never fail the gate and are summarised on one line (--json lists
each): no-runbook (a null runbook — the gap #2239 fills) and
no-rotation-interval.
What counts as a secret-shaped string
Section titled “What counts as a secret-shaped string”Three rules, applied to every string value in the file:
- Known prefixes — a GitHub token (
ghp_… andgithub_pat_…), an Anthropic or OpenAI key, a Stripe secret, an AWS access key id, a Google API key, a Slack token, a Tailscale auth key, a Telegram bot token, a GitLab or npm token, a JWT, a private-key block. - Hex digests — any run of 32 or more hex characters.
- Entropy — any run of 20 or more characters containing both letters and digits, with no separator, whose Shannon entropy is at least 3.5 bits per character.
Runs are split on whitespace, punctuation and the separators refs and paths
use (/ . - _ : # @), so a path, an ALL_CAPS_NAME, a date, a duration or a
sentence never trips the entropy rule — a word has no digits, a digest has no
separators. The committed fixtures in tests/fixtures/secrets-inventory/
(leaked-prefix, leaked-entropy, leaked-hex, each a deliberately shaped
placeholder, not a real credential) prove each rule refuses, names the path
and withholds the value; tests/test-secrets-inventory.js runs them.
If the gate ever fires on a real value: the value is compromised the moment it was committed — rotate it first (see the secrets-management skill), then remove it.
Adding a credential
Section titled “Adding a credential”Declare it here before it is stored anywhere — the secrets-management skill makes this the first step of adding any key, token or credential:
- Add an entry with a stable
id, itsclass, every location as a reference, its consumers and the narrowest honestscope. make check-secrets-inventory— clean, with at most theno-runbookwarning until the class has a runbook.- Only then store the value where the entry says (
wrangler secret put,gh secret set, the 1Password item).
A credential stored without an entry is what the presence check reports as undeclared — see The presence check.
The presence check
Section titled “The presence check”An inventory drifts unless something compares it to reality. The presence check does that by name and metadata only — it lists secret names, tests whether a key exists, asks whether an item has a field, and never reads a value out loud:
node scripts/security/cred-check.js # this machine, human reportnode scripts/security/cred-check.js --machine farm-01 # as that machine (its per-machine copies)node scripts/security/cred-check.js --json # bob-cred-check/1node scripts/security/cred-check.js --listing FILE # replay a captured listing instead of probing (tests, offline)node scripts/security/cred-check.js --today YYYY-MM-DD # judge rotation against this date (tests)node scripts/security/cred-check.js --root DIR # another checkout (tests)Exit 0 clean · 1 drift · 2 usage error, an unreadable inventory or a
--machine that machines.json does not declare · 3 unknown — no drift
found, but at least one store could not be read. It is report-only: nothing
is set, rotated or removed.
Its per-operator sibling is bob secrets check (#2286,
bob-secrets.md): where cred-check.js compares the inventory
to every store by name, bob secrets check reads the operator profile’s
secrets[] and asks whether each one is reachable from this machine through
its present_at locator, naming the declared source for anything missing. The
two agree through an entry’s inventory_id; neither ever holds a value.
How each location kind is probed
Section titled “How each location kind is probed”kind |
Probe | What it returns |
|---|---|---|
gh-secret |
gh secret list -R owner/repo --json name |
the repo’s secret names |
wrangler-secret |
wrangler secret list --name worker, with CLOUDFLARE_ACCOUNT_ID set to the location’s resolved account when it names one (#3659) |
the Worker’s secret names |
mcp-json · config-hash |
the file is parsed and every leaf replaced by non-empty? before the json.path is resolved |
whether the path reaches a non-empty leaf; [] fans out over an array’s elements or an object’s values (the intake config keys projects by slug) |
env-file |
the file’s KEY= names |
whether the key is set |
1password |
op item list --vault <Vault> --format json — item metadata only (ids and titles); never op item get, which returns every field with its value (#3663) |
whether the item exists; a field named in the ref is not checked |
gh-auth |
gh auth status (prints the account, never the token) |
exit 0 |
claude-auth |
file existence — the file is never opened | exists |
file |
file existence — the whole file is the value (a route’s working key at mode 600, #4299); it is never opened | exists |
ssh-key |
a path ref: file existence; a 1Password SSH agent ref: ssh-add -l |
exists / the agent holds an identity |
keychain |
security find-generic-password -s <ref> on macOS (attributes only, never -w) |
exit 0 / 44 |
automatic |
nothing to store — present by construction | present |
A copy declared on another machine (machine set to a different id) is
skipped, never probed: the check runs as one machine at a time, and each
fleet machine’s snapshot carries its own result. The shared stores (gh,
wrangler, 1Password) are the same from any machine.
A probe failure is reported by name, never in the tool’s words (#3663). A
store that cannot be read is unknown, and its reason is the command, its
exit status and one name from a closed set — auth, not-found,
rate-limit, network, multiple-accounts or failed — for example
gh secret list -R example/app exited 1 (auth). A file that does not parse is
named by its error class (.mcp.json: SyntaxError). Stderr can echo a token
and a parse error quotes the text it stopped on, so neither reaches the JSON
document, the human report or the fleet snapshot. Run the named command by
hand to read the full message.
Findings
Section titled “Findings”| Finding | Meaning |
|---|---|
missing |
A declared copy is not where the inventory says — the name is absent from the repo, Worker or file, the key is empty, gh is logged out, the file does not exist. The location’s note is echoed so a declared by a workflow; not set copy explains itself. |
undeclared |
A store holds a name the inventory does not declare. Every gh-secret and wrangler-secret name counts (a stored Actions or Worker secret is a secret by construction). An mcp-json or env-file key counts only when its name looks like a credential (SECRET, TOKEN, KEY, PASS, AUTH, CRED, PRIVATE, CERT, DSN, URL, ENDPOINT); the rest are listed under ignored — never silently dropped. The project’s .mcp.json and $BOB_HOME/.mcp.json are scanned even when no entry names them. |
expired |
expires is before today. |
rotation-overdue |
last_rotated (or created when never rotated) + rotation_interval is before today. |
On an entry set per environment every finding carries env and its message
names it (credential "x" [prod] …), credentials[].environments[] reports
each environment’s {name, status, rotation} — status is present,
missing, unknown or skipped from that environment’s own copies — and
the human report prints one line per such entry: x: test present · prod missing. See The environment axis.
unknown is not a finding: a store that cannot be read — gh or wrangler
absent or logged out, the forge unreachable, op not installed, a probe that
timed out, a ref the check has no probe for — is reported per location and
per source with its reason, and never counted as missing. A run with
unknowns and no drift is verdict unknown (exit 3), not clean.
Warnings never fail the check: rotation-baseline-unknown — an interval is
declared but neither last_rotated nor created is known, so overdue cannot
be judged; set one.
No value, structurally
Section titled “No value, structurally”Every probe returns names, booleans and exit codes. Before the report is
printed — human or --json — every string in it passes through the
inventory gate’s secret-shape detector (above);
a string that trips it is replaced by a marker and counted in redacted, and
the report says so: a probe that returned a value means that credential is
exposed and should be rotated. tests/test-cred-check.js runs the check
against tests/fixtures/cred-check/planted-mcp.json, a .mcp.json whose
undeclared key holds a secret-shaped placeholder, and asserts the key is
named and the value appears nowhere; tests/test-fleet-audit.js asserts the
same of the snapshot form and the rendered fleet view.
In the fleet snapshot
Section titled “In the fleet snapshot”scripts/fleet/audit.js snapshot runs the check as the snapshotting machine
and carries summary.cred_check — verdict, machine, checked_at,
location counts (declared / checked / present / missing / unknown / skipped), findings by kind, unknown, redacted, the finding handles
(items[]: kind, id, store, name) and the unreachable[] stores.
audit.js view rolls it up as a Credential presence table (one row per
machine, a FLEET row, the handles beneath). null means opted out
(BOB_FLEET_CRED_CHECK=0) or the engine is absent; an engine that fails is
{verdict: "error"} inside the snapshot, never a failed snapshot.
BOB_FLEET_CRED_CHECK_LISTING=<file> replays a captured listing instead of
probing (what the tests do); BOB_FLEET_CRED_CHECK_TIMEOUT_MS bounds each
probe (default 15000; wrangler gets at least 30000).
The rotation schedule
Section titled “The rotation schedule”A rotation should arrive as a scheduled, followable procedure with a
verification step — not as archaeology after an expiry. The schedule is
derived from the inventory alone: for every entry with a rotation_interval,
the next rotation is last_rotated (or created, when it has never been
rotated) plus the interval; an expires date counts too, and the earlier of
the two is the due date. cred-check.js due grades each credential against
today and a window (default P14D):
| Status | Meaning |
|---|---|
overdue |
The due date is before today. |
due |
The due date is today or inside the window. |
upcoming |
The due date is after the window — listed, no action. |
unknown |
An interval is declared but neither last_rotated nor created is known, so the due date cannot be judged. The schedule never guesses: set created (or rotate once and record it). |
unscheduled |
No interval and no expiry — an identifier, a per-run platform token, a credential the entry’s notes say is not rotated on a schedule. |
node scripts/security/cred-check.js due # file (or bump) todos; human reportnode scripts/security/cred-check.js due --json # bob-rotation-due/1 — the briefing collector's source (#2243)node scripts/security/cred-check.js due --no-file # report only, no forge writenode scripts/security/cred-check.js due --within P30D # a wider windownode scripts/security/cred-check.js due --today YYYY-MM-DD # judge against this date (tests)node scripts/security/cred-check.js due --repo owner/repo # where the todos go (default: the checkout's origin)Exit 0 nothing due · 1 something due or overdue (todos filed, bumped or
reported) · 2 usage error or unreadable inventory · 3 unknown only — a
scheduled credential has no baseline date, so nothing could be judged due.
An entry set per environment is one row per environment (env on the
row), each dated from its own dates, and each due environment gets its own
todo — titled TODO: rotate <id> [<env>] (…), marked
<!-- rotation-due: <id>@<env> due=… --> and deduplicated on that marker,
with steps for that environment’s copies and the shared ones only
(#3082). A test copy and a prod copy never share a todo.
The todo
Section titled “The todo”Each due or overdue credential gets one todo issue on the
inventory’s repo (paulirv/bigbrain — the checkout’s origin, or --repo),
assigned to the operator, p3-medium when due and p2-high when overdue.
The body is written to the todo-writing
conventions and validated in the tests against both layers — the
todo format contract and todo-lint.sh:
- a one-sentence summary naming the credential, class, due date and why
(
last rotated 2026-01-01 + P90D, orit expires on …); - numbered Instructions: the class runbook to follow (or, until one
exists, the secrets-management skill’s rotate step — said, never hidden),
where to mint the replacement, then one CLI step per declared location
with the exact identifiers —
gh secret set NAME -R owner/repo,wrangler secret put NAME --name worker, the.mcp.jsonpath on the named machine, the 1Password item — then revoke the old value, then record the rotation on a branch and open the PR; - a Verify section: the presence check reports no finding for the id,
due --no-fileno longer lists it, and each store shows the name updated; - Context: target, consumers, the schedule arithmetic, the source doc, and
the
completedlabel as the done-signal; the#2239link the contract requires.
De-duplication is by the body marker <!-- rotation-due: <id> due=<date> -->:
| The forge holds | due does |
|---|---|
| An open todo with the marker and the same date | Nothing (unchanged). |
| An open todo with the marker and another date | Re-stamps the marker and comments that the date moved (bumped) — still one record. |
A closed todo with the marker and the same date, without completed |
Nothing (skipped) — the human cancelled that cycle; it is never re-filed. |
A closed todo with the marker and the same date, with completed |
Nothing (skipped) — the rotation happened but last_rotated was not recorded; the row says so. |
| No todo for this date | Files one (created). |
A forge failure — gh absent, the repo unresolvable, a refused create — is
the row’s todo.action: "error" and counted in summary.todos.error; the
schedule is still reported and the exit code still says what is due. Nothing
in the document or the todo is ever a value: due runs through the same
secret-shape scrub as the presence check.
The document (bob-rotation-due/1)
Section titled “The document (bob-rotation-due/1)”due --json is the stable source the briefing collector (#2243) reads
for its Needs you section — rendering it in the briefing is owned by #2224:
{ "schema": "bob-rotation-due/1", "verdict": "due", "today": "2026-09-15", "within": "P14D", "window_end": "2026-09-29", "repo": "paulirv/bigbrain", "summary": { "credentials": 19, "overdue": 0, "due": 1, "upcoming": 3, "unknown": 0, "unscheduled": 6, "todos": { "created": 1, "bumped": 0, "unchanged": 0, "skipped": 0, "reported": 0, "error": 0 } }, "rotations": [ { "id": "rdb-bridge-secret", "class": "bridge-secret", "status": "due", "reason": "rotation", "interval": "P90D", "baseline": "2026-06-20", "baseline_kind": "last_rotated", "next_due": "2026-09-18", "expires": null, "due_on": "2026-09-18", "days": 3, "runbook": null, "locations": [ { "kind": "wrangler-secret", "ref": "remote-decision-bridge:BRIDGE_SECRET", "machine": null } ], "consumers": { "projects": ["bigbrain"], "machines": ["farm-01"] }, "todo": { "action": "created", "repo": "paulirv/bigbrain", "number": 2450, "url": "…" } } ], "todos": [ { "id": "rdb-bridge-secret", "status": "due", "due_on": "2026-09-18", "priority": "p3-medium", "title": "TODO: rotate rdb-bridge-secret (bridge-secret) — due 2026-09-18", "action": "created", "number": 2450 } ]}rotations[] is ordered overdue first, then due by date, then upcoming,
unknown, unscheduled. todos[] repeats the filed rows with their outcome.
Where it surfaces
Section titled “Where it surfaces”/what-next— due and overdue rotations lead the queue in their own block, beside overdue rituals; the operator’s rotation todos also appear in Your action items.--reportadds a Rotations block with the next 14 days. See the what-next command.- Briefings (#2224) — the collector reads
due --json(#2243). - The farm — the rotation itself is human-attended; unattended runs only ever file the todo.
The runbooks
Section titled “The runbooks”One runbook per credential class, under registry/runbooks/ (next to
tellbob-token-lifecycle), each with the same spine — mint, land in every
declared store, verify the consumers, revoke the old value, record
through a PR, close the loop — and with every UI step verified against the
provider’s current docs and stamped Source: … (verified YYYY-MM-DD):
| Class | Runbook | Entries today |
|---|---|---|
cloudflare-api-token |
cloudflare-api-token-rotation — replace (or roll, which kills the old value instantly) |
cloudflare-api-token-ci |
github-pat |
github-pat-rotation — the fine-grained checkout token, and a fleet machine’s gh login |
bob-checkout-token, gh-cli-oauth |
bridge-secret |
bridge-secret-rotation — binding the new value to the Worker is the revocation |
rdb-bridge-secret |
tailscale-auth-key |
tailscale-auth-key-rotation — declare-first: no pre-auth key is in use, so none is inventoried; the node keys are tailscale-node-keys (class other, re-login is the rotation, #3661) |
— |
| reporter tokens | tellbob-token-lifecycle |
tellbob-reporter-tokens |
The entry’s runbook field points at its runbook, so the todo names it; a
class still without one (other) is said in the todo, never hidden. The
five are provisioned together by the security session kit
(session kits — kits carry a runbooks list since
#2239), and bob provision add runbook <name> declares one on a project
permanently.
Recording a rotation (rotated)
Section titled “Recording a rotation (rotated)”Completing a rotation updates last_rotated through a PR, never a
local-only edit, so the schedule moves only when the change is reviewed and
merged:
git switch -c chore/rotate-<id>node scripts/security/cred-check.js rotated <id> # sets last_rotated to todaynode scripts/security/cred-check.js rotated <id> --date 2026-09-14node scripts/security/cred-check.js rotated <id> --env prod # one environment's rotation (#3082)make check-secrets-inventorygit commit -am "chore(#2239): rotate <id>" && gh pr create --fillrotated rewrites exactly one field of one entry and refuses on the
default branch (master / main, exit 3) with the branch command to run —
the record cannot bypass review. It reports the previous value and the next
due date; recording the same date again is a no-op (exit 0); an unknown id or
a bad date is exit 2. An entry set per environment needs --env <name>: it
sets that environment’s last_rotated only; no --env, an undeclared
environment, or --env on an entry without environments is exit 2. When the PR merges, add completed to the rotation
todo; the next due run sees the moved date and files nothing.
The revocation list
Section titled “The revocation list”A lost laptop is the inventory’s first real test: can it say, from declared state alone, everything that machine held and what to revoke? The lost-laptop drill (#2240, rituals.md → Drills) starts by generating that list — never writing it by hand:
node scripts/security/cred-check.js revocation --machine macbook-air # the markdown checklist the run issue embedsnode scripts/security/cred-check.js revocation --machine macbook-air --json # bob-revocation-list/1It is a projection of secrets-inventory.json — no store is read, no value
is touched. A credential is on the machine’s list when a locations[] row is
on it (machine) or consumers.machines names it. Each row carries:
| Field | Holds |
|---|---|
why |
copy (a located copy), consumer (a consumer claim), or both |
copies_here |
the copies on the machine — kind, ref, note |
other_copies |
every copy elsewhere, with its machine — what a rotation must re-land |
revoke |
one step per copy on the machine, by its kind: a gh-auth / gh keychain copy revokes the GitHub CLI grant at GitHub (the page’s labels verified 2026-09-15); a claude-auth copy says Claude Code documents /logout on the device only and no remote revocation — not possible from here; an ssh-key copy deletes the key at GitHub (and the farm-01 authorized_keys line); an mcp-json / env-file copy means the holder has the value, so rotate — the class’s revoke hint says how; a consumer with no located copy also revokes by rotating |
reland |
the class runbook’s per-store steps for the copies not on the machine |
runbook, scope, purpose |
from the entry — the runbook line says when there is none |
Then the gaps — what the inventory itself shows it cannot answer. Every gap is a finding on the drill run:
| Gap | Meaning |
|---|---|
no-location |
consumers.machines names the machine but no locations[] copy is on it — the inventory cannot say where to revoke; add the location or drop the consumer |
unclaimed-copy |
a copy is on the machine but consumers.machines does not name it — add the machine |
no-runbook |
the entry has no runbook and its class and copy kinds have no specific revoke step — the drill cannot walk it |
no-scope |
the entry declares no scope — the drill cannot say what the lost machine could reach |
empty |
nothing is attributed to the machine at all — either it holds nothing (unlikely for a fleet member) or the inventory was never filled in for it; the first finding of the drill |
Exit codes: 0 complete (no gaps) · 1 gaps, or an empty list · 2
usage, an undeclared machine (the machines.json ids are named), or an
unreadable inventory. The document is scrubbed through the gate’s
secret-shape detector like every other report, and counts redactions.
bob-revocation-list/1: verdict (complete | gaps | empty |
error), machine, generated_at, inventory, summary (credentials,
with_copy_here, consumer_only, gaps, by_kind), credentials[]
(the rows above), gaps[] ({kind, id, message}) and redacted.
bob ritual open drill --drill lost-laptop --machine <id> embeds the
rendered list in the run issue under Revocation list, so the session
walks it row by row and closes with each gap filed (Closing a run).
On today’s inventory both laptops are named as consumers of the bridge
secret and worker URL with no copy located on them — the generator reports
them, which is exactly what the drill exists to surface.
Why the file is not called credentials.json
Section titled “Why the file is not called credentials.json”The autonomous permission profiles (profiles/autonomous.json,
profiles/full-auto.json) deny every tool read of **/*credential* by name —
any credential-named file is treated as a secret store. The inventory holds no
values, but the rotation, presence-check and drill work is exactly what
autonomous runs must be able to read it for, so it is named for what it
indexes. The floor stays intact (decision #2400).
See also
Section titled “See also”- Secrets management skill — where a secret lives, pre-commit scanning, leaked-secret remediation.
- Tailscale remote ops — secrets rule — verify presence, never transfer; the presence check is its probe for the whole inventory.
scripts/security/cred-check.js— the presence check (#2238) and the rotation schedule (due/rotated, #2239);tests/test-cred-check.jsproves each grade, the todo against both layers of the todo conventions, the de-duplication, and that a planted value never reaches the report.- Todo format contract and the todo-writing skill — the two layers every rotation todo satisfies.
- Verification system — every
make checktarget. scripts/fleet/distribute-secret.sh— the fleet-wide distributor forBOB_CHECKOUT_TOKEN(#1265).