Skip to content

Build master @ bc50cd5bunreleased — 31 commits past v1.282.0Last reviewed Last changed Grade fresh (aging >90d, stale >180d)Source / History

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 ​

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 ​

secrets-inventory.json at the repo root, validated against schemas/secrets-inventory.schema.json:

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 ​

FieldMeaning
idStable kebab-case handle — what rotation todos, presence reports and runbooks refer to. Unique.
purposeWhat it lets its consumers do, one sentence.
environmentsOptional. 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.
classcloudflare-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.
locationsEvery place a copy is stored, source of truth first — see Location kinds.
consumersprojects (names in projects.json), machines (ids in machines.json), optional workflows (repo-relative workflow files).
scopeThe narrowest honest statement of what it may do — repos, permissions, zones, accounts.
ownerThe accountable human, as a GitHub login.
created / expiresISO dates, or null when unknown / no hard expiry.
rotation_intervalISO 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.
runbookRepo-relative path of the rotation runbook, or null while none exists.
last_rotatedISO date, or null; updated by the rotation PR — cred-check.js rotated <id> on a branch (#2239).
notesWhat 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 ​

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.

kindref notationmachine
gh-secretowner/repo:NAME—
wrangler-secretworker: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)—
1passwordVault/Item or Vault/Item/field—
gh-auth · claude-auth · ssh-key · keychain · env-file · filea path or accountrequired — a machines.json id
automaticplatform: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:

json
{ "kind": "wrangler-secret", "ref": "example-awards:STREAM_API_TOKEN", "env": "prod", "account": "default" }

account is a key of the operator's config/cloudflare-accounts.jsonaccounts 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 ​

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.

json
{
  "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.

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

The gate ​

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

FindingMeaning
secret-shapeA 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.
schemaThe document does not validate against the schema.
duplicate-idTwo entries share an id.
unknown-project / unknown-machineA 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-requiredA per-machine copy (gh-auth, claude-auth, ssh-key, keychain, env-file, file) names no machine.
bad-refA location's ref is not in its kind's notation; the message names the expected form.
account-on-kindA location other than wrangler-secret names a Cloudflare account (#3659).
missing-runbookA runbook path does not exist in the checkout.
duplicate-envAn entry declares the same environment twice (#3082).
env-undeclaredA location's env is not one of the entry's environments — or the entry declares none.
env-unlocatedA declared environment has no copy of its own, so the check could never say whether it holds the credential.
template-secretA 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 ​

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.

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:

  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.

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:

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

How each location kind is probed ​

kindProbeWhat it returns
gh-secretgh secret list -R owner/repo --json namethe repo's secret names
wrangler-secretwrangler 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-hashthe file is parsed and every leaf replaced by non-empty? before the json.path is resolvedwhether 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-filethe file's KEY= nameswhether the key is set
1passwordop 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-authgh auth status (prints the account, never the token)exit 0
claude-authfile existence — the file is never openedexists
filefile existence — the whole file is the value (a route's working key at mode 600, #4299); it is never openedexists
ssh-keya path ref: file existence; a 1Password SSH agent ref: ssh-add -lexists / the agent holds an identity
keychainsecurity find-generic-password -s <ref> on macOS (attributes only, never -w)exit 0 / 44
automaticnothing to store — present by constructionpresent

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 ​

FindingMeaning
missingA 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.
undeclaredA 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.
expiredexpires is before today.
rotation-overduelast_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 ​

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 ​

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 ​

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

StatusMeaning
overdueThe due date is before today.
dueThe due date is today or inside the window.
upcomingThe due date is after the window — listed, no action.
unknownAn 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).
unscheduledNo interval and no expiry — an identifier, a per-run platform token, a credential the entry's notes say is not rotated on a schedule.
bash
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.

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, 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 holdsdue does
An open todo with the marker and the same dateNothing (unchanged).
An open todo with the marker and another dateRe-stamps the marker and comments that the date moved (bumped) — still one record.
A closed todo with the marker and the same date, without completedNothing (skipped) — the human cancelled that cycle; it is never re-filed.
A closed todo with the marker and the same date, with completedNothing (skipped) — the rotation happened but last_rotated was not recorded; the row says so.
No todo for this dateFiles 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) ​

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:

json
{
  "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 ​

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

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

ClassRunbookEntries today
cloudflare-api-tokencloudflare-api-token-rotation — replace (or roll, which kills the old value instantly)cloudflare-api-token-ci
github-patgithub-pat-rotation — the fine-grained checkout token, and a fleet machine's gh loginbob-checkout-token, gh-cli-oauth
bridge-secretbridge-secret-rotation — binding the new value to the Worker is the revocationrdb-bridge-secret
tailscale-auth-keytailscale-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 tokenstellbob-token-lifecycletellbob-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) ​

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:

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

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:

bash
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:

FieldHolds
whycopy (a located copy), consumer (a consumer claim), or both
copies_herethe copies on the machine — kind, ref, note
other_copiesevery copy elsewhere, with its machine — what a rotation must re-land
revokeone 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
relandthe class runbook's per-store steps for the copies not on the machine
runbook, scope, purposefrom 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:

GapMeaning
no-locationconsumers.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-copya copy is on the machine but consumers.machines does not name it — add the machine
no-runbookthe entry has no runbook and its class and copy kinds have no specific revoke step — the drill cannot walk it
no-scopethe entry declares no scope — the drill cannot say what the lost machine could reach
emptynothing 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 ​

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 ​

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