Skip to content

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).

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.

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 …"
}
]
}
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.

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.

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 own created, expires, rotation_interval and last_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 env the 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.

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 list on tellbob-intake and remote-decision-bridge;
  • the env keys 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-lifecycle was the one rotation runbook that existed at seeding time and the one runbook set (#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.json membership.

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.

Terminal window
make check-secrets-inventory # in make check
node scripts/checks/check-secrets-inventory.js --json # bob-secrets-inventory-check/1
node 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.

Three rules, applied to every string value in the file:

  1. Known prefixes — a GitHub token (ghp_… and github_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.
  2. Hex digests — any run of 32 or more hex characters.
  3. 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.

Declare it here before it is stored anywhere — the secrets-management skill makes this the first step of adding any key, token or credential:

  1. Add an entry with a stable id, its class, every location as a reference, its consumers and the narrowest honest scope.
  2. make check-secrets-inventory — clean, with at most the no-runbook warning until the class has a runbook.
  3. 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.

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:

Terminal window
node scripts/security/cred-check.js # this machine, human report
node 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/1
node 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.

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.

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.

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.

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).

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.
Terminal window
node scripts/security/cred-check.js due # file (or bump) todos; human report
node 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 write
node scripts/security/cred-check.js due --within P30D # a wider window
node 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.

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, or it 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.json path 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-file no longer lists it, and each store shows the name updated;
  • Context: target, consumers, the schedule arithmetic, the source doc, and the completed label as the done-signal; the #2239 link 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.

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.

  • /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. --report adds 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.

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.

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:

Terminal window
git switch -c chore/rotate-<id>
node scripts/security/cred-check.js rotated <id> # sets last_rotated to today
node scripts/security/cred-check.js rotated <id> --date 2026-09-14
node scripts/security/cred-check.js rotated <id> --env prod # one environment's rotation (#3082)
make check-secrets-inventory
git commit -am "chore(#2239): rotate <id>" && gh pr create --fill

rotated 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.

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:

Terminal window
node scripts/security/cred-check.js revocation --machine macbook-air # the markdown checklist the run issue embeds
node scripts/security/cred-check.js revocation --machine macbook-air --json # bob-revocation-list/1

It 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).

  • 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.js proves 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 check target.
  • scripts/fleet/distribute-secret.sh — the fleet-wide distributor for BOB_CHECKOUT_TOKEN (#1265).