diff --git a/cli/internal/doctor/checks_bw_mapping.go b/cli/internal/doctor/checks_bw_mapping.go new file mode 100644 index 00000000..4b8dfbcc --- /dev/null +++ b/cli/internal/doctor/checks_bw_mapping.go @@ -0,0 +1,83 @@ +package doctor + +import ( + "fmt" + "sort" + "strings" + + "github.com/mlorentedev/dotfiles/cli/internal/secrets" +) + +// checkBWMapping asserts that every Bitwarden item the registry names actually +// exists in the vault — the drift between the mapping SSOT and the store it maps +// into. +// +// It exists because that drift is not a cosmetic mismatch: `dotf secrets run` +// resolves the WHOLE registry when the caller passes no --only and fails fast on +// the first unresolvable entry, by design (a child must never launch with a +// partially-populated secret set). So one stale item name takes down every +// unscoped run — which is the `pi` shell wrapper and, worse, `dotf spec review`, +// whose launcher builds an unscoped run. On 2026-08-15 a single entry naming +// `dockerhub` while the vault held `DockerHub` made the adversarial-review gate +// unrunnable for every spec in every repo, and the only symptom anyone saw was a +// review that produced an empty transcript. +// +// The check is name-only: it lists item names through the daemon and compares +// sets. It never reads a field, never resolves a secret, and never sees a value — +// so it stays cheap enough for the full sweep and safe to run anywhere. +// +// Severity mirrors checkBitwardenReach's rule: an unreachable or locked vault is +// not a finding here (that section owns it), but a reachable vault missing an +// item a live entry depends on is a FAIL, because something is already broken. +func checkBWMapping(sys *System, cfg *Config, rep *Report) { + rep.Section("Bitwarden mapping (registry -> vault)") + + reg, err := loadRegistry(cfg) + if err != nil { + rep.Skip("secrets/registry.yaml not readable — checkSecrets owns that failure") + return + } + + declared := map[string][]string{} // item name -> secret ids naming it + for i := range reg.Secrets { + s := ®.Secrets[i] + if s.Backend != secrets.BackendBW || s.BW == nil || s.BW.Item == "" { + continue + } + declared[s.BW.Item] = append(declared[s.BW.Item], s.ID) + } + if len(declared) == 0 { + rep.Skip("no bw-backed secrets in the registry") + return + } + + present, err := sys.BWItemNames() + if err != nil { + // Locked, absent daemon, transport error: not this section's finding. + rep.Skip(fmt.Sprintf("vault item list unavailable (%v) — mapping unverifiable", err)) + return + } + have := make(map[string]bool, len(present)) + for _, n := range present { + have[n] = true + } + + missing := make([]string, 0, len(declared)) + for item := range declared { + if !have[item] { + missing = append(missing, item) + } + } + sort.Strings(missing) + + for _, item := range missing { + ids := declared[item] + sort.Strings(ids) + rep.Fail(fmt.Sprintf( + "%s: no such item in the vault, named by %s — every `dotf secrets run` without --only fails on it, including `dotf spec review`", + item, strings.Join(ids, ", "))) + } + if len(missing) == 0 { + rep.Pass(fmt.Sprintf("all %d bw item(s) named by the registry exist in the vault", len(declared))) + } +} diff --git a/cli/internal/doctor/checks_bw_mapping_test.go b/cli/internal/doctor/checks_bw_mapping_test.go new file mode 100644 index 00000000..f36ee80f --- /dev/null +++ b/cli/internal/doctor/checks_bw_mapping_test.go @@ -0,0 +1,113 @@ +package doctor + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +// The exact live state that took the archive gate down on 2026-08-15: the +// registry names `dockerhub`, the vault holds `DockerHub`. Nothing resolved it +// because item lookup is an exact-name match, and nothing reported it because no +// check compared the two sets — the only symptom was `dotf spec review` +// producing an empty transcript. +func TestCheckBWMapping_MissingItemFails(t *testing.T) { + registry := "version: 1\nsecrets:\n" + + " - {id: DOCKERHUB_TOKEN, plane: app, backend: bw, bw: {item: dockerhub, field: PAT}, expose: {env: DOCKERHUB_TOKEN}}\n" + + " - {id: NAN_API_KEY, plane: app, backend: bw, bw: {item: nan-api-key, field: api-key}, expose: {env: NAN_API_KEY}}\n" + + sys := newSys(nil, nil, nil) + sys.BWItemNames = func() ([]string, error) { + return []string{"DockerHub", "nan-api-key", "Stripe"}, nil + } + + var buf bytes.Buffer + rep := capture(&buf) + checkBWMapping(sys, patCfg(t, registry), rep) + + if rep.Failures() != 1 { + t.Fatalf("one declared item is absent from the vault; want 1 failure, got %d\n%s", rep.Failures(), buf.String()) + } + out := buf.String() + // The message has to be actionable: which item, and which secret named it. + for _, want := range []string{"dockerhub", "DOCKERHUB_TOKEN"} { + if !strings.Contains(out, want) { + t.Errorf("output must name %q\n%s", want, out) + } + } + // And it must state the consequence, because the symptom never points here. + if !strings.Contains(out, "without --only") { + t.Errorf("output must say what breaks, not just what mismatches\n%s", out) + } + // The item that DOES exist must not be reported. + if strings.Contains(out, "nan-api-key: no such item") { + t.Errorf("a present item must not be reported missing\n%s", out) + } +} + +// Every declared item present is the healthy case: one PASS, no failures, and no +// per-item noise in a section that is clean. +func TestCheckBWMapping_AllPresentPasses(t *testing.T) { + registry := "version: 1\nsecrets:\n" + + " - {id: NAN_API_KEY, plane: app, backend: bw, bw: {item: nan-api-key, field: api-key}, expose: {env: NAN_API_KEY}}\n" + + sys := newSys(nil, nil, nil) + sys.BWItemNames = func() ([]string, error) { return []string{"nan-api-key", "unrelated"}, nil } + + var buf bytes.Buffer + rep := capture(&buf) + checkBWMapping(sys, patCfg(t, registry), rep) + + if rep.Failures() != 0 { + t.Fatalf("all items present; want 0 failures, got %d\n%s", rep.Failures(), buf.String()) + } + if !strings.Contains(buf.String(), "exist in the vault") { + t.Errorf("expected the clean-state PASS\n%s", buf.String()) + } +} + +// A locked or unreachable vault is not this section's finding — checkBitwardenReach +// owns that severity. Reporting it twice inflates the failure count and trains the +// reader to ignore the section that is actually specific. +func TestCheckBWMapping_UnavailableVaultSkips(t *testing.T) { + registry := "version: 1\nsecrets:\n" + + " - {id: NAN_API_KEY, plane: app, backend: bw, bw: {item: nan-api-key, field: api-key}, expose: {env: NAN_API_KEY}}\n" + + sys := newSys(nil, nil, nil) + sys.BWItemNames = func() ([]string, error) { return nil, errors.New("vault is locked") } + + var buf bytes.Buffer + rep := capture(&buf) + checkBWMapping(sys, patCfg(t, registry), rep) + + if rep.Failures() != 0 { + t.Fatalf("an unavailable vault is not a mapping failure; got %d\n%s", rep.Failures(), buf.String()) + } + if !strings.Contains(buf.String(), "mapping unverifiable") { + t.Errorf("expected the unverifiable SKIP\n%s", buf.String()) + } +} + +// An age-only registry has nothing to compare: SKIP, never a PASS implying the +// mapping was checked. "Nothing to check" and "checked, all good" are different +// statements and only one of them is evidence. +func TestCheckBWMapping_NoBwSecretsSkips(t *testing.T) { + registry := "version: 1\nsecrets:\n" + + " - {id: SSH_KEY, plane: floor, backend: age-offline, age: ssh.key, expose: {env: SSH_KEY}}\n" + + sys := newSys(nil, nil, nil) + var called bool + sys.BWItemNames = func() ([]string, error) { called = true; return nil, nil } + + var buf bytes.Buffer + rep := capture(&buf) + checkBWMapping(sys, patCfg(t, registry), rep) + + if called { + t.Error("must not touch the vault when no bw-backed secret is declared") + } + if rep.Failures() != 0 || !strings.Contains(buf.String(), "no bw-backed secrets") { + t.Errorf("expected the no-bw-secrets SKIP\n%s", buf.String()) + } +} diff --git a/cli/internal/doctor/doctor.go b/cli/internal/doctor/doctor.go index 26316005..9e0a9c66 100644 --- a/cli/internal/doctor/doctor.go +++ b/cli/internal/doctor/doctor.go @@ -88,6 +88,7 @@ func Run(opts Options) (int, error) { checkSecretsTooling(sys, rep) checkBitwardenReach(sys, rep) checkBWServeDaemon(sys, rep) + checkBWMapping(sys, cfg, rep) checkDisasterRecovery(sys, cfg, rep) checkPATExpiry(sys, cfg, rep) checkGuardHooks(sys, cfg, rep, opts.Fix) diff --git a/cli/internal/doctor/system.go b/cli/internal/doctor/system.go index 2780a4e9..d2ce14d2 100644 --- a/cli/internal/doctor/system.go +++ b/cli/internal/doctor/system.go @@ -113,6 +113,12 @@ type System struct { // it for one Authorization header. Returns secrets.ErrSecretAbsent (wrapped) // when the secret is genuinely not provisioned on this machine. ResolveSecret func(e secrets.Entry) (string, error) + // BWItemNames lists every item name in the Bitwarden vault — names only, no + // fields and no values. It exists so the registry->vault mapping can be + // asserted without resolving a secret: a registry entry naming an item the + // vault does not have takes down every unscoped `dotf secrets run`, and the + // only symptom is whatever that run was driving (BUG-080). + BWItemNames func() ([]string, error) } // resolveSecret is the production ResolveSecret: the age store (checkout-first, @@ -185,6 +191,9 @@ func realSystem() *System { return (&secrets.BWServeDaemon{Client: secrets.BWServeClient{}}).Status() }, ResolveSecret: resolveSecret, + BWItemNames: func() ([]string, error) { + return secrets.BWServeReader{Client: secrets.BWServeClient{}}.ItemNames() + }, CommandOutputBounded: func(d time.Duration, name string, args ...string) (string, string, error) { ctx, cancel := context.WithTimeout(context.Background(), d) defer cancel() diff --git a/cli/internal/doctor/testhelpers_test.go b/cli/internal/doctor/testhelpers_test.go index d956224c..31bf1af3 100644 --- a/cli/internal/doctor/testhelpers_test.go +++ b/cli/internal/doctor/testhelpers_test.go @@ -88,6 +88,9 @@ func newSys(env map[string]string, onPath []string, cmdOut map[string]string) *S // default (no token in the environment ⇒ SKIP), so full-sweep tests that // do not care about PATs behave as they always did. Tests exercising // resolution inject their own. + // Default: the vault holds exactly what the registry declares, so the + // mapping check is quiet unless a test deliberately introduces drift. + BWItemNames: func() ([]string, error) { return nil, errors.New("no vault in tests") }, ResolveSecret: func(e secrets.Entry) (string, error) { return "", fmt.Errorf("%w: %s", secrets.ErrSecretAbsent, e.Var) }, diff --git a/cli/internal/secrets/bwserve.go b/cli/internal/secrets/bwserve.go index 956bbf23..195922da 100644 --- a/cli/internal/secrets/bwserve.go +++ b/cli/internal/secrets/bwserve.go @@ -260,6 +260,31 @@ type bwServeListData struct { Data []bwServeListItem `json:"data"` } +// ItemNames lists every item name in the vault, for callers that need to compare +// the registry's declared items against what the store actually holds rather than +// resolve any of them. It is the one read here that touches no field and returns +// no value — only names — so a health check can assert the mapping without ever +// holding a secret. +// +// It deliberately does NOT use the ?search= filter: the point is the whole set, +// and search is a fuzzy substring match (see getItemJSON), which would make an +// absent item indistinguishable from one whose name merely failed to match. +func (r BWServeReader) ItemNames() ([]string, error) { + data, err := r.Client.call(http.MethodGet, "/list/object/items", nil) + if err != nil { + return nil, fmt.Errorf("bw serve list items: %w", err) + } + var list bwServeListData + if err := json.Unmarshal(data, &list); err != nil { + return nil, fmt.Errorf("bw serve list items: unparseable data: %w", err) + } + names := make([]string, 0, len(list.Data)) + for _, it := range list.Data { + names = append(names, it.Name) + } + return names, nil +} + func (r BWServeReader) getItemJSON(item string) ([]byte, error) { data, err := r.Client.call(http.MethodGet, "/list/object/items?search="+url.QueryEscape(item), nil) if err != nil { diff --git a/secrets/registry.yaml b/secrets/registry.yaml index d37e0c7c..5e6c9268 100644 --- a/secrets/registry.yaml +++ b/secrets/registry.yaml @@ -53,11 +53,15 @@ secrets: rotate: 90d validate: github-token # sync ci probes `gh api user` before upload (#635 follow-up) - # DockerHub — login pair, one bw item: token→password, username→username. + # DockerHub — one bw item: token→PAT (a scoped token), username→username. - id: DOCKERHUB_TOKEN plane: app backend: bw - bw: { item: dockerhub, field: password, folder: apps } + # PAT, not password: the item carries both, and the account password + # authenticates against the Hub API exactly as the scoped token does — which is + # why mapping the wrong one stayed invisible. Verified 2026-08-15: both return + # HTTP 200, so this is a blast-radius fix, not a repair of a broken credential. + bw: { item: dockerhub, field: PAT, folder: apps } expose: { env: DOCKERHUB_TOKEN } consumers: ["ci:image-push"] rotate: 180d diff --git a/specs/BUG-080-registry-vault-drift/proposal.md b/specs/BUG-080-registry-vault-drift/proposal.md new file mode 100644 index 00000000..60c8026c --- /dev/null +++ b/specs/BUG-080-registry-vault-drift/proposal.md @@ -0,0 +1,117 @@ +--- +id: "BUG-080-registry-vault-drift" +type: spec +status: implementing # draft | implementing | verifying | archived +created: "2026-08-15" +issue: "mlorentedev/dotfiles#985" +tags: [spec, proposal, secrets, doctor, bitwarden] +template_version: "1.0" +--- + +# BUG-080-registry-vault-drift + + + +## Why + +`secrets/registry.yaml` is the mapping SSOT; Bitwarden is the store it maps into. +Nothing checks that the two agree. On 2026-08-15 they did not: the registry names +a vault item `dockerhub`, the vault holds `DockerHub`, and item lookup is an +exact-name match. + +That single mismatch is not local. `dotf secrets run` with no `--only` resolves +the **whole** registry and fails fast on the first unresolvable entry — by +design, so a child never launches with a partially-populated secret set. So one +stale name takes down every unscoped run: the `pi` shell wrapper, and +`dotf spec review`, whose launcher builds an unscoped run. The adversarial-review +gate was therefore unrunnable **for every spec in every repo**, which in turn +blocks `dotf spec archive`, which blocks `spec-gate` on any PR closing a spec's +issue. Two PRs were stuck behind it before anyone identified the cause. + +The failure gave no usable signal. The review launcher printed `[OK] Review +running detached` over a process that had already died, leaving a 0-byte +transcript; one session diagnosed a locked vault and was heading for a fix that +would have changed nothing. A mapping error surfaced as "the reviewer produced +nothing". + +## What + +Two changes: one correctness fix in the registry, and the guard that makes this +class visible instead of inferred. + +**1. `DOCKERHUB_TOKEN` maps to the item's `PAT` field, not `password`.** Found +while reading the item that the registry could not resolve. The entry pointed at +the **account password** while a scoped personal access token sat beside it in +the same item. Verified live: both authenticate against the Hub API with HTTP +200, which is exactly why nothing ever surfaced it — the wrong credential worked. +This is a blast-radius fix, not the repair of a broken credential. + +**2. A `dotf doctor` section asserting the registry→vault mapping.** For every +`backend: bw` entry, the item it names must exist. Name-only: it lists item names +through the `bw serve` daemon and compares sets, never reading a field, resolving +a secret, or seeing a value — so it is cheap enough for the full sweep and safe +anywhere. It reports which item is missing, which secret ids named it, and what +breaks, because the symptom never points back here. + +The vault-side rename (`DockerHub` → `dockerhub`, matching ADR-028's kebab +`-` convention and the other 16 managed items) is the operator +action that clears the current instance. It is not in this PR: it mutates a live +password vault and belongs to its own authorised step. + +## Out of scope + +- **Making `dotf secrets run` tolerant of an unresolvable secret the caller never + asked for.** This is the deeper fix, and it is a deliberate reversal of the + fail-fast contract added in #612 A1 — a decision, not a bug fix. Until it is + taken, the next registry/vault drift breaks the same things; this spec makes + that drift *visible before* it does, which is the part that can be shipped + without re-opening a settled design. +- **The review launcher announcing success over a dead child.** Filed as #989. + Scoping or fixing the mapping changes the current cause of death; it does not + teach the launcher to tell a live review from a corpse. +- **The `Dotfiles/` folder-prefix rename.** PR #982, another session. +- **The broader vault deduplication** (manual items shadowing managed ones, + literal duplicates, the 11-field `GitHub` item). Its own spec, unstarted, and + it requires the CR overlay because it mutates a live vault. + +## Risks / open questions + +- **The check needs a reachable, unlocked vault.** When the daemon is absent or + locked it SKIPs rather than failing — `checkBitwardenReach` owns that severity, + and reporting it twice inflates the failure count and trains the reader to + ignore the specific section. Consequence, stated: on a locked machine this + check proves nothing, which is correct but worth knowing. +- **It cannot run in CI**, for the same reason. It is a local-operator check, and + the drift it catches is between a checkout and a personal vault — a pairing CI + never has. +- **`dotf secrets backup` is currently unusable**, so the sanctioned DR escrow + could not be taken before any vault work: `bw export` goes through the CLI's + own session, which is locked, while the daemon holds a separate unlocked one. + Not caused by this change, but it is the reason the vault rename is deferred + rather than done here, and it deserves its own ticket. +- **Two other PRs touch `secrets/registry.yaml`** (#982 folders, #984 the + `validate:` marker). Conflicts are line-local and trivial; noted so whoever + merges second expects them. + +## Acceptance criteria + +- [ ] **AC1** `DOCKERHUB_TOKEN` resolves the item's `PAT` field, and the change is + recorded with the evidence that both credentials authenticate — so a later + reader does not "fix" it back on the assumption the old one was broken. +- [ ] **AC2** A doctor section FAILs when a `backend: bw` entry names an item the + vault does not hold, naming the item, every secret id that named it, and the + consequence (unscoped `dotf secrets run` fails, including `dotf spec review`). +- [ ] **AC3** The check never reads a field or a value — names only. +- [ ] **AC4** An unavailable vault (locked, absent daemon, transport error) SKIPs + and produces no failure; a registry with no `bw` entries SKIPs without + touching the vault at all, and does not emit a PASS implying it checked. +- [ ] **AC5** Observed failing against the **real** vault state, not only a + fixture: the live run reports the `dockerhub` drift. + +## References + +- Bitácora board: mlorentedev/dotfiles#985 +- #989 — the launcher reporting success over a dead review +- #612 A1 — the fail-fast contract that turns one bad entry into a total outage +- `docs/adr/adr-028-secrets-two-tier-bitwarden-age` — the mapping SSOT and the naming convention +- #982 (folder prefix), #984 (`validate:` marker) — the other two live edits to the registry diff --git a/specs/BUG-080-registry-vault-drift/tasks.md b/specs/BUG-080-registry-vault-drift/tasks.md new file mode 100644 index 00000000..0c54105b --- /dev/null +++ b/specs/BUG-080-registry-vault-drift/tasks.md @@ -0,0 +1,60 @@ +--- +tags: [spec, tasks, templates] +created: "2026-08-15" +--- + +# Tasks - BUG-080-registry-vault-drift + +> TDD order. One task = one focused commit. Tick as you go. Reorder freely while spec is in `draft` state; freeze once you start `implementing`. +> +> **Inline markers** (optional, additive — borrowed from `github/spec-kit`, adapt-not-adopt per #141): +> - `[P]` — this task has **no dependency on another unchecked task**, so it is safe to run in parallel (fan out to a `Workflow`, or just batch). TDD chains (test → implement → refactor of the *same* behavior) are sequential and must NOT carry `[P]`; independent behaviors can. +> - `[AC]` — this task helps satisfy **acceptance criterion #``** from `proposal.md`. Lets `/spec check` map coverage deterministically; omit it and the check falls back to semantic judgment. + +## Setup + +- [ ] Branch created from main: `feat/BUG-080-registry-vault-drift` +- [ ] `proposal.md` is complete and acceptance criteria are testable +- [ ] No open questions left in `proposal.md` "Risks / open questions" + +## Implementation + +> Replace these with the actual steps for this feature. Keep them small (one commit each) and in TDD order. +> The `[P]` / `[AC]` markers are optional — see the legend above. Behaviors 1 and 2 below are independent, so their *first* test task carries `[P]`. + +- [ ] [P] [AC1] Write failing test for +- [ ] [AC1] Implement to make it pass +- [ ] Refactor for clarity (extract, rename, dedupe) +- [ ] [P] [AC2] Write failing test for +- [ ] [AC2] Implement to make it pass +- [ ] ... + +## Closing + +- [ ] Every acceptance criterion from `proposal.md` is covered by at least one test +- [ ] Every acceptance criterion has a matching entry in `features.json` (see below) with a non-vacuous verification command +- [ ] Type checks pass +- [ ] Lint passes +- [ ] No unrelated changes in the diff (no scope creep) +- [ ] `verification.md` filled in +- [ ] PR opened referencing this spec folder + +## Machine-readable features + +This spec emits a sibling `features.json` (alongside this file) following [[pattern-feature-list-as-primitive]]. The JSON is the harness-facing contract: each acceptance criterion maps to ≥1 feature with `id`, `behavior`, `verification` (executable command), `state` (lifecycle), and `evidence` (harness-captured output). + +**Pass-state gating:** the agent CANNOT write `"state": "passing"` — only the harness, after running `verification` and capturing exit code 0, may set that terminal state. Reviewers must reject PRs where features.json contains `passing` entries with empty `evidence`. + +Minimal `features.json` skeleton (drop into `/specs/BUG-080-registry-vault-drift/features.json`): + +```json +[ + { + "id": "BUG-080-registry-vault-drift-f1", + "behavior": "", + "verification": "", + "state": "pending", + "evidence": "" + } +] +``` diff --git a/specs/BUG-080-registry-vault-drift/verification.md b/specs/BUG-080-registry-vault-drift/verification.md new file mode 100644 index 00000000..c84a0223 --- /dev/null +++ b/specs/BUG-080-registry-vault-drift/verification.md @@ -0,0 +1,64 @@ +--- +tags: [spec, verification] +created: "2026-08-15" +--- + +# Verification - BUG-080-registry-vault-drift + +## Evidence + +| AC | Proof | +|---|---| +| **AC1** | `secrets/registry.yaml` `DOCKERHUB_TOKEN` → `field: PAT`. Live probe, both credentials, 2026-08-15: `PAT -> HTTP 200`, `pass -> HTTP 200` against `hub.docker.com/v2/repositories//`. The comment in the registry records this so the change is not reverted on the assumption the old field was broken. | +| **AC2** | `TestCheckBWMapping_MissingItemFails` — asserts the item name, the naming secret ids, and the `without --only` consequence all appear. | +| **AC3** | `checkBWMapping` calls only `sys.BWItemNames()`; `BWServeReader.ItemNames` hits `/list/object/items` and returns `it.Name` only. No field access, no `Field(...)` call, no value in any code path. | +| **AC4** | `TestCheckBWMapping_UnavailableVaultSkips` (error → SKIP, 0 failures) and `TestCheckBWMapping_NoBwSecretsSkips` (asserts the vault is **not** touched, and that the output is a SKIP rather than a PASS). | +| **AC5** | Live, below. | + +## Test status + +``` +go build ./... OK +go vet ./... OK +go test ./... OK (all packages) +``` + +## AC5 — observed against real state, not a fixture + +`dotf doctor`, built from this branch, against the live vault: + +``` +[Bitwarden mapping (registry -> vault)] + [FAIL] dockerhub: no such item in the vault, named by DOCKERHUB_TOKEN, DOCKERHUB_USERNAME — every `dotf secrets run` without --only fails on it, including `dotf spec review` +``` + +Independently corroborated by the failure this check exists to explain: + +``` +$ dotf secrets run -- true +Error: bw resolve dockerhub/password: bw item not found: bw serve item "dockerhub": not found + +$ dotf secrets run --only NAN_API_KEY -- true ; echo $? +0 +``` + +Scoped resolution works; unscoped dies. That is the whole blast radius in two commands. + +## Decisions made during implementation + +- **The vault rename is not in this PR.** `DockerHub` → `dockerhub` is what clears the current instance, and it was authorised — but the CR overlay's precondition could not be met: `dotf secrets backup` fails with `Vault is locked` because `bw export` uses the CLI session while the daemon holds a separate unlocked one. Mutating a live password vault with no working escrow is not a trade worth making for a rename that takes an operator 30 seconds in the UI. The tooling gap is its own finding. +- **A bare `"bw"` literal in the new check**, deliberately: #984 introduces `secrets.BackendBW` and will absorb it with the others. This PR must be mergeable alone, because it is what unblocks that one. +- **SKIP, not PASS, when there is nothing to compare.** "Nothing to check" and "checked, all good" are different statements and only one is evidence — the same distinction the PAT-expiry check got wrong for months (#972). + +## Promotion candidates + +- [ ] Lesson for `docs/lessons.md` — deferred to the deeper fix. The transferable rule ("a fail-fast set-resolution turns any single mapping error into a total outage of everything that resolves the set") belongs with the decision on `dotf secrets run`'s tolerance, not with the detector. +- [ ] ADR — no. ADR-028 already governs; this conforms. +- [ ] Vault pattern — no. Single-repo so far. + +## Archive checklist + +- [ ] `proposal.md` frontmatter set to `status: archived` +- [ ] Folder moved to `specs/archive/BUG-080-registry-vault-drift/` +- [ ] #985 closed with the PR link and the operator action (item rename) confirmed done +- [ ] `/adversarial-review` run and `review.md` signed by a pool model — possible only once this very fix has landed, since it is what makes the reviewer launchable