feat(bin): add Bitwarden rollout runbook and no-secret ceremony validator - #13
Open
rega10 wants to merge 10 commits into
Open
feat(bin): add Bitwarden rollout runbook and no-secret ceremony validator#13rega10 wants to merge 10 commits into
rega10 wants to merge 10 commits into
Conversation
added 10 commits
August 29, 2026 02:41
…remony-record validator Add docs/bitwarden-rollout.md (operator-current): the staged, gate-approved path from temporary local-only custody to organization-owned Bitwarden custody for production and team credentials. It preserves the hardened local-vault boundary, keeps Bitwarden Secrets Manager an explicit undecided later choice, requires two independent owner recovery paths and hardware-backed MFA before any credential moves, inventories credential classes without values, defines the per-batch migration ceremony with captain approval and verify-before-retire ordering, and designs encrypted export and recovery drills with split ownership and no plaintext staging. Add bin/fm-bitwarden-ceremony.sh: a no-secret structure/status validator for per-batch ceremony records. It never touches any credential store, refuses secret-shaped input without echoing it, enforces the preflight -> approval -> moved -> verified -> retired order so retirement can never be recorded before verification and approval, is idempotent for interrupted-ceremony replay, and reports corrupt record lines by number with content withheld. Behavior coverage in tests/fm-bitwarden-ceremony.test.sh: malicious batch ids, secret-shape redaction and non-persistence, step-order and retirement gates, idempotent retries, partial-ceremony recovery, and corrupt-record withholding. Pointers: inventory entry in docs/documentation-audiences.json, a toolbelt row in docs/scripts.md, and a README documentation link. Claude-Session: https://claude.ai/code/session_01ABrW9CXMaE5jmeYYwEA6wo
tests/fm-public-followup.test.sh pinned the repro fixture's thread window to an absolute 2026-08-28T01:12:00Z. Once the wall clock passed that instant the rechain tests started refusing with "the thread can no longer be reached", failing the portable serial shard on calendar date alone. Derive received_at/followup_expires_at/reservation_expires_at and both obligation --expires-at stamps from the run's own clock, and let the expiry escalation test read the seeded deadline instead of repeating the constant, so the window is always live and the expiry cases stay pinned by FMX_NOW_OVERRIDE.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Prepare the captain-selected staged adoption of Bitwarden as the canonical managed store for production and team credentials, as a preparation-only deliverable: the smallest practical operator-usable rollout package moving from temporary local-only custody toward organization-owned Bitwarden custody, while preserving current access until each migration batch is explicitly approved and verified. Deliverables chosen: (1) docs/bitwarden-rollout.md, classified operator-current in docs/documentation-audiences.json, as the authoritative rollout runbook with prerequisites, phase gates (phases 0-4), human roles, recovery/drill procedures, and a no-secret drill evidence template; (2) bin/fm-bitwarden-ceremony.sh, a deliberately small helper justified by the repeated per-batch ceremony steps - it validates ceremony-record structure/status ONLY and must never accept, echo, log, persist, or transform secret values (it refuses secret-shaped input with redacted messages, reports corrupt lines by number with content withheld, and never contacts Bitwarden or any credential store); (3) colocated behavior tests tests/fm-bitwarden-ceremony.test.sh exercising malicious paths/filenames, redaction, no-secret logging, idempotent retries, partial ceremony recovery, and refusal before destructive retirement; (4) pointer-only updates (documentation-audiences.json inventory entry, one docs/scripts.md row, one README Documentation link) rather than duplicating contracts across README, AGENTS, skills, and docs - AGENTS.md is deliberately untouched per the repo's size discipline, and the record format/step gates are owned solely by the script header per the one-owner rule. Hard boundaries the captain set, all deliberate: do NOT create Bitwarden accounts or organizations, choose or purchase a subscription, invite users, alter billing, move/copy/read/print/export any secret value, change production access, rotate credentials, modify Automic Vault custody (Automic Vault keeps local secrets - this rollout must not weaken hardened local access), or run a real migration ceremony - those need the captain's direct login/billing participation and later approval. Bitwarden Password Manager (human/team) and Secrets Manager (machine/unattended) are treated as separate custody models; Secrets Manager is deliberately NOT selected or enabled, and the runbook surfaces that as an open captain decision with its service-account/access-token consequences and a safe interim boundary (machine/unattended and CI/CD secrets stay on current paths, no Password Manager workaround copies). The runbook requires at least two independent owner/admin recovery paths, hardware-backed MFA for privileged accounts, least-privilege collections/groups, joiner/mover/leaver procedures, and no single-person recovery dependency; encrypted export and recovery drills are designed (offline custody, encryption, split ownership, restore verification, rotation after exposure, drill cadence, deletion/retention evidence) WITHOUT creating any export or plaintext staging step; credential classes are inventoried by label/owner only, never values, and the classes staying outside the initial migration (local development, machine/unattended, CI/CD) are named with reasons. The phased ceremony has explicit preflight, per-item ownership/collection target, captain approval, dual control where appropriate, post-move access verification, rollback, old-custody retirement only after verification, and an auditable no-secret completion record; the helper enforces that ordering (retirement refused before verification and recorded approval). Genuinely unresolved product/security decisions (subscription, legal/compliance retention, owner identities, machine-access architecture, irreversible retirement) are surfaced to the captain, not chosen. A private task report with inspected boundaries, open decisions, exact tests, and skipped real-world evidence was written to the firstmate home's data directory (gitignored, deliberately not part of this diff). No real Bitwarden account, subscription, organization, invite, export, secret, production credential, or access path was exercised by this work. Repo conventions followed: one sentence per line in tracked Markdown, plain dashes, shellcheck-clean bin script via bin/fm-lint.sh, colocated .test.sh behavior tests asserting executable behavior (not source bytes), and bin/fm-doc-audience-check.sh green.
What Changed
bin/fm-bitwarden-ceremony.sh, a validator for per-batch Bitwarden migration ceremony records under$FM_HOME/data/bitwarden/<batch-id>.ceremony. It offersinit,add-item,mark,status, andcheck, enforces the strictpreflight -> approval -> moved -> verified -> retiredstep order on every read and write (refusingretireduntilverifiedplus a recordedapproved-byapproval exists), validates record structure, batch identity, item placement, and calendar dates (rejecting future, backwards, pre-creation, and truncated records), and makes identical replays idempotent while refusing conflicting ones. It never contacts Bitwarden or any credential store, refuses secret-shaped labels and batch ids, and reports corrupt lines by number with content withheld.docs/bitwarden-rollout.md, the operator runbook covering custody boundaries (local secrets stay in Automic Vault; Secrets Manager deliberately not enabled), roles, open captain decisions, a label-only credential class inventory, phase 0-4 gates, the per-batch migration ceremony, encrypted export and recovery drills, and what the runbook never authorizes.tests/fm-bitwarden-ceremony.test.shcovering malicious batch ids and filenames, redaction of secret-shaped input, tampered and truncated record histories, date validation, partial-ceremony recovery, idempotent replay, and retirement refusal; registers the doc asoperator-currentindocs/documentation-audiences.jsonand links it fromdocs/scripts.mdand the README.Risk Assessment
✅ Low: The change is a self-contained, additive, no-secret local text validator plus documentation with no production or credential-store reach; every prior round's fix was independently reproduced as working, the parser refuses all adversarial record shapes I could construct without producing a wrong result, and the added tests are real behavioral regressions rather than source-text assertions.
Testing
Ran the two targeted behavior suites (ceremony validator and documentation-audience inventory) - both green - then drove the ceremony script end-to-end as an operator would from a shell and captured the transcript as the primary evidence: a pilot batch goes from init through per-item owner/collection registration to a completed auditable no-secret record, with retirement refused before verification and captain approval,
checknaming the resume point on a partial record,movedrefused with no registered items, idempotent replay succeeding while a conflicting-approver replay is refused with the record unchanged, and tampered, truncated, future-dated, and misfiled records refused by line number with content withheld. Adversarial checks confirm the safety contract: path-traversal and secret-shaped batch ids are refused with nothing written anywhere under the data root, pasted secret-shaped values are refused with redacted messages and grep-proven absent from every persisted file, and a PATH-shimmed run with curl/wget/bw/op/vault/ssh stubbed to abort produced no forbidden calls, so the tool never contacts Bitwarden or any credential store. Diff-level boundaries hold too (AGENTS.md untouched, pointer-only doc updates, data/ gitignored). No screenshot or rendered-UI artifact: this change ships a CLI helper and a Markdown runbook, and there is no rendered end-user surface to capture, so the CLI transcript is the faithful product-level evidence. Worktree left clean; all evidence is in the dedicated evidence directory.Evidence: Operator CLI walkthrough transcript (full ceremony, gates, refusals, redaction, no-network proof)
Source: Operator CLI walkthrough transcript (full ceremony, gates, refusals, redaction, no-network proof)
Evidence: Reproducible walkthrough script used to produce the transcript
Source: Reproducible walkthrough script used to produce the transcript
Evidence: Targeted behavior test output
Source: Targeted behavior test output
Evidence: Destructive-gate refusal and partial-ceremony recovery (transcript excerpt)
Evidence: Redacted secret refusal with grep proof of non-persistence (transcript excerpt)
Evidence: Final auditable no-secret completion record on disk (transcript excerpt)
Evidence: Tampered/truncated/misfiled record refusals: line number only, content withheld (transcript excerpt)
Evidence: No credential-store or network call under PATH shim (transcript excerpt)
Pipeline
Updates from git push no-mistakes
... (3 earlier update rounds omitted to keep the PR body within GitHub's 65536-char limit; full history is in the run log.)
🔧 Fix: refuse secret-shaped batch ids and fix grep option parsing
2 warnings still open:
bin/fm-bitwarden-ceremony.sh:155- parse_record records step names into PARSED_STEPS but never checks that they appear in the declared order, so the retirement invariant is enforced only on themarkwrite path, never on thecheck/statusvalidation path. Verified by direct invocation: a record containingstep: preflight,step: moved,step: retired(no approval, no verified) passescheckwith exit 0 and printsnext: approval;statusshows[x] preflight [ ] approval [x] moved [ ] verified [x] retired. Replayingmark <b> approval --approved-by captainandmark <b> verifiedthen succeeds,mark <b> retiredreports "already recorded; nothing to do", and the finalstatusprints all five gates[x]andnext: complete- while the record on disk plainly showsstep: retiredwritten beforestep: approvalandstep: verified. So the tool certifies as a compliant completion record an artifact whose own contents prove old-custody retirement preceded captain approval and post-move verification, with no error at any point. This is reachable without corruption: the add-item conflict refusal at line 217 explicitly instructs the operator to "resolve the conflict in the record before continuing", i.e. to hand-edit the file, and docs/bitwarden-rollout.md:93 designates the record as the completion evidence. It contradicts the script header at lines 43-45 and the intent's requirement that "the helper enforces that ordering (retirement refused before verification and recorded approval)". Fix: have parse_record (the shared boundary all four commands already go through) reject a record whose recorded steps are not a prefix-ordered subsequence of $STEPS, reporting the offending line number only, socheckandstatusrefuse an out-of-order record instead of blessing it.tests/fm-bitwarden-ceremony.test.sh:134- The negative tests for the two gates the intent marks required are satisfied by the ordering check, not by the gates they name, so those gates are untested. Verified by running the same commands the test runs:mark batch-a approvalat line 134 (labeled 'approval requires --approved-by') fails with "next required step is 'preflight', not 'approval'" because batch-a has no recorded steps at that point - the[ -n "$approved_by" ]gate at bin/fm-bitwarden-ceremony.sh:249 is never reached. Recording preflight first and re-running does produce the real refusal ("refused: approval requires --approved-by..."), so the gate works but nothing asserts it. Same pattern for lines 131, 142 and 145 ('retirement is refused before verification'): all three fail with "next required step is ...", so the retirement gates at bin/fm-bitwarden-ceremony.sh:258-259 are never exercised - I had to hand-write a record with an emptyapproved-by=value to make line 259 fire. These tests would still pass with lines 249 and 258-259 deleted. Fix: record the prerequisite steps first so each command reaches the gate under test, and assert the gate's specific refusal text (assert_contains "$OUT" 'approval requires --approved-by','before post-move verification','without a recorded captain approval') instead of exit status alone.🔧 Fix: validate ceremony step order on every record read
5 issues (2 warnings, 3 infos) still open:
bin/fm-bitwarden-ceremony.sh:147- parse_record acceptsbatch:andcreated:lines without ever comparing them to the batch being read, so a copied or renamed record is certified as a completed ceremony for the wrong batch with no error. Verified by direct invocation: complete batch b1 (preflight through retired), thencp b1.ceremony b2.ceremony- an ordinary operator copy, no body edit.init b2reports "batch 'b2' already initialized; nothing to do" (exit 0);status b2printsbatch: b2,items: 1, all five gates[x],next: complete;check b2printsnext: complete- while the file body plainly readsbatch: b1. The tool therefore presents b1's approval, verification and retirement evidence as b2's completion record, and prints the batch label from argv rather than from the record, so the reported label is wrong without any failure. Line 147 also accepts repeated and conflicting header lines: a record containingbatch: dup+batch: other+created: 2026-01-01+created: 2030-01-01passescheckwith exit 0. This is the same class the round-2 fix closed for step lines (the fix's own scope was "refuse malformed, duplicate, out-of-order ... and impossible histories before reporting progress"), left open on the one field that names what the evidence is for, and docs/bitwarden-rollout.md:93 designates the record as the completion evidence. Fix at the same shared boundary the step-order check already uses: have parse_record require exactly onebatch:line whose value equals $batch and exactly onecreated:line, reporting the offending line number only, so no reader blesses a record that belongs to a different batch.bin/fm-bitwarden-ceremony.sh:148- parse_record enforces ordering among step lines but never constrains item lines relative to them, so an item registered after the batch moved is counted as a pre-move ownership target that add-item itself refuses to create. Verified by direct invocation: a record withstep: preflight,step: approval date=... approved-by=cap,step: moved, followed byitem: added-after-move owner=o collection=cpassesstatuswith exit 0, reportsitems: 1, andmark <batch> verifiedthen succeeds and appends. Lines 249-251 exist precisely to refuse this on the write path ("items cannot be added after the batch is marked moved; start a new batch"), and the moved gate at lines 282-284 exists so "every moved credential needs a recorded owner/collection target", but neither is checked on read, so the auditable completion record can show an ownership/collection target recorded after the credential had already moved and every reader reports it as valid progress. Fix at the same shared boundary: have parse_record reject anitem:line that appears after anystep:line, reporting the line number only.bin/fm-bitwarden-ceremony.sh:266- The step-name validation in cmd_mark uses a substring match against " $STEPS ", so a multi-word argument that is a contiguous run of step names is accepted as a step name and can exit 0 claiming a nonexistent step is already recorded. Verified by direct invocation on a batch with preflight and approval recorded:mark b1 'preflight approval'passes the line 266 guard (" preflight approval " is a substring of " preflight approval moved verified retired "), then step_recorded at line 272 matches the same run inside PARSED_STEPS, and the command prints "batch 'b1': step 'preflight approval' already recorded; nothing to do" and exits 0. Nothing is written, so no record is corrupted, but any caller that reads exit 0 as "this step is satisfied" gets a true answer for a step that does not exist. Fix: match exactly, e.g.for s in $STEPS; do [ "$s" = "$step" ] && found=1; done, instead of substring-testing the concatenated list.bin/fm-bitwarden-ceremony.sh:287- The[ -n "$PARSED_APPROVED_BY" ]retirement guard is now unreachable. For control to reach line 287,step_recorded verifiedmust be true at line 286; the new prefix-ordering check then guarantees approval is recorded, and lines 178-183 guarantee its approved-by is present and non-empty, so PARSED_APPROVED_BY is always non-empty at that point. The retirement-vs-approval invariant the intent requires is still enforced, just by parse_record rather than here, so this is not a defect. Worth noting only because tests/fm-bitwarden-ceremony.test.sh:252, labelled 'retirement cannot be recorded against an empty approver', now exits non-zero through the corrupt-record path at line 183 rather than through this gate - so nothing reaches line 287 and nothing can. Keep it as defence in depth or drop it, but do not read it as the enforcing gate.bin/fm-bitwarden-ceremony.sh:311- cmd_status:311-315 and cmd_check:322-326 are byte-identical blocks, and each invokes next_step twice (once for the branch test, once for the value). A single helper -report_next() { local n; if n=$(next_step); then printf 'next: %s\n' "$n"; else printf 'next: complete\n'; fi; }- replaces both and halves the traversals. Related mechanical simplification: line 282's[ -z "$(printf '%s' "$PARSED_ITEMS")" ]is equivalent to[ -z "$PARSED_ITEMS" ], since PARSED_ITEMS is a plain string. No behavior change in either case.🔧 Fix: validate record batch identity and item placement
2 warnings still open:
bin/fm-bitwarden-ceremony.sh:211- parse_record never mirrors the write-path gate thatmovedrequires at least one registered item, so a record that records the move with no item lines at all is certified as valid progress and can be driven to the destructive retirement gate. Verified by direct invocation: a record containing only the v1/batch/created headers plusstep: preflight,step: approval date=... approved-by=cap,step: moved date=...and noitem:line makesstatusexit 0 printingitems: 0, all three gates[x],next: verified;mark <batch> verifiedthenmark <batch> retiredboth succeed, andcheckprintsnext: complete(exit 0). The same batch built through the tool is refused:mark z2 movedon an itemless batch dies with "refused to mark moved with no registered items; every moved credential needs a recorded owner/collection target" (line 313). This is the identical read/write asymmetry the round-3 fix closed for post-move item lines at line 176, left open on the gate that guarantees the completion record names what was actually moved - so docs/bitwarden-rollout.md:93 ("the completed batch record ... is the completion evidence") and the ceremony's per-item ownership/collection target requirement are satisfied by a record that names no credential at all. Fix at the same shared boundary: at line 211, where the parser observes the moved step and post-move items are already refused, require PARSED_ITEMS to be non-empty and otherwisecorruptwith the line number only, so no reader blesses a move with no recorded ownership target.bin/fm-bitwarden-ceremony.sh:179- parse_record checks only that an item line contains the substringsowner=andcollection=, never that the label, owner, or collection actually has a value, so two readers of the same record disagree and the moved gate is satisfied by an item that names nothing. Verified by direct invocation: a record whose body isitem: owner=ops collection=prod(two spaces, i.e. an empty label) parses with name="" at line 182, appends a bare newline to PARSED_ITEMS at line 186, and thenstatusprintsitems: 0(line 337'sgrep -c .sees no non-empty line) whilemark <batch> movedsucceeds, because line 313's[ -z "$PARSED_ITEMS" ]sees the lone newline as non-empty - the count says there are no items and the gate says there is one, for the same file. Separately,item: db owner= collection=is accepted and counted asitems: 1, and I drove that batch through moved, verified and retired to completion, so the record certifies retirement of a credential with no recorded owner or collection - the exact target the gate at line 313 exists to require.step: approval date= approved-by=caplikewise passes the line 193 date check with an empty date. add-item and mark refuse all of these on the write path via require_label. Fix in parse_record: after extracting the item fields, require a non-empty label and non-empty owner/collection values (and a non-empty step date), reporting the offending line number only, so PARSED_ITEMS can never hold an entry that the item count and the moved gate read differently.🔧 Fix: validate record field values on every read
3 issues (1 warning, 2 infos) still open:
bin/fm-bitwarden-ceremony.sh:192- parse_record accepts a record whose final line carries no trailing newline (the|| [ -n "$line" ]clause at line 192), and both writers then append directly onto that last line, merging two record lines into one while reporting success. Verified by direct invocation: a record written asfm-bitwarden-ceremony v1\nbatch: nl\ncreated: 2026-01-01\nitem: db owner=ops collection=prodwith no final newline passescheck(exit 0,next: preflight) andstatus(items: 1);mark nl preflightthen prints "batch 'nl': step 'preflight' recorded (2026-08-29)" and exits 0, but the file becomesitem: db owner=ops collection=prodstep: preflight date=2026-08-29- the item line's collection value is destroyed, the step was never recorded as its own line, and a subsequentcheckdies with "corrupt at line 4 (item collection contains characters outside...)".add-itemcorrupts identically (step: preflight date=2026-01-01item: web owner=ops collection=prod). This is a wrong result that does not error: the operator is told the ceremony advanced while the auditable no-secret completion record - the deliverable's whole point per docs/bitwarden-rollout.md:93 - has silently lost its recorded ownership/collection target, and the damage is unrecoverable by the documented remedy because the surviving line is a fusion of two, not a truncated prefix. It is reachable through the recovery path the runbook itself prescribes at docs/bitwarden-rollout.md:95 ("correct it back to its last valid prefix (delete only the trailing lines that are not yet true)"), since an operator hand-editing or rewriting the file with a tool that omits the final newline lands exactly here. Fix at the shared boundary both writers already pass through: in parse_record, detect that the last line was not newline-terminated andcorruptat that line number (a truncated record, reported by number only, consistent with "this tool never repairs a record for you"), so no reader blesses it and neither append site can reach it. Add a test writing a newline-less record and asserting that check/status/mark all refuse it and that the file is left unmodified.bin/fm-bitwarden-ceremony.sh:128- is_date validates only the YYYY-MM-DD character shape, never the field ranges or the ordering between recorded dates, so a record can certify a history that the write path can never produce and every reader reports it as valid progress. Verified by direct invocation:created: 9999-99-99withstep: preflight date=0000-00-00passescheckwith exit 0 printingnext: approval; and a record withcreated: 2030-01-01,step: preflight date=2026-12-31,step: approval date=2020-01-01 approved-by=cap,step: moved date=1999-01-01passesstatuswith exit 0, all three gates[x]andnext: verified- an approval dated six years before the preflight it must follow, and a move dated before the batch was created. cmd_mark always stamps$(today), so tool-written records are necessarily non-decreasing and never predatecreated; this is the same read/write asymmetry the earlier rounds closed for every other field, left open on the dates. The step-order safety invariant still holds (retirement remains behind verification and approval), which is why this is informational rather than a gate failure - but the record is the audit evidence, and its timestamps are the only evidence of when each gate was passed. Fix in parse_record where the dates are already extracted: reject out-of-range month/day values, and require each step date to be no earlier than the created header and no earlier than the previous step's date, reporting the offending line number only.bin/fm-bitwarden-ceremony.sh:384- cmd_status re-derives the item count with$(printf '%s' "$PARSED_ITEMS" | grep -c -- . || true)- two forks plus an exit-status mask - to count something parse_record already enumerated line by line. Thegrep -c .filter and the|| truewere both defences against a PARSED_ITEMS entry that is empty or absent, and the empty-label case that made them necessary is now refused outright at line 216, so the guard has no remaining input to guard against. Maintaining a plainPARSED_ITEM_COUNTcounter alongside PARSED_ITEMS in parse_record (incremented at line 227) and printing it directly removes both subprocesses and the masked status, with no behavior change: the count is already exactly the number of accumulated entries.🔧 Fix: refuse truncated records and validate recorded dates
2 issues (1 warning, 1 info) still open:
bin/fm-bitwarden-ceremony.sh:380- cmd_mark's idempotency early-return at line 380 runs before every argument gate (384-389) and before any conflict check, so a replayedmarksilently discards the arguments the operator actually passed and reports success. Verified by direct invocation against a record already carryingstep: approval date=2026-08-29 approved-by=alice:mark b approval --approved-by malloryexits 0 printing "batch 'b': step 'approval' already recorded; nothing to do", and the record still readsapproved-by=alice. The operator is told the approval they asserted is recorded while the auditable evidence names a different captain - a wrong result with no error, on the one field the destructive retirement gate at line 395 depends on. Same root cause, two more symptoms:mark b approval --approved-by ghp_AAAA...on an already-approved batch exits 0 with no refusal, because require_label at line 386 is never reached (the intent requires the tool to refuse secret-shaped input with a redacted message; nothing is echoed or persisted here, but nothing is refused either), andmark b preflight --approved-by captainon a recorded preflight exits 0, bypassing the "--approved-by is only valid for the approval step" guard at line 388. cmd_add_item already implements the correct shape for exactly this case: identical re-add is a no-op (353-356), conflicting re-add is refused (357-359), and require_label runs at 347-349 before parse_record. Fix at the same boundary in cmd_mark: run the --approved-by validation block (384-389) before the step_recorded early return, and when the step is already recorded and it isapprovalwith a supplied --approved-by that differs from PARSED_APPROVED_BY, refuse with the add-item-style conflict message instead of reporting a no-op. Identical replay stays a no-op, so the documented idempotency contract (header lines 65-67) is preserved. Add a test replaying approval with a different approver and asserting exit 1 plus an unchanged record.bin/fm-bitwarden-ceremony.sh:145- is_date validates month 01-12 and day 01-31 independently, so it accepts days that do not exist in the given month. Verified by direct invocation: a record withcreated: 2026-02-30andstep: preflight date=2026-02-31passesstatuswith exit 0, printingitems: 1,[x] preflight,next: approval. 2026-04-31 and 2025-02-29 pass the same way. The script header at line 40 states "every date must be a real YYYY-MM-DD calendar date" and both refusal messages (lines 231 and 266) say "is not a YYYY-MM-DD calendar date", so the published record-format contract - which per the intent is owned solely by this header - overclaims what is enforced. This is informational rather than a gate failure: the step-ordering and non-decreasing-date invariants still hold, so retirement stays behind verification and approval; only the timestamp evidence can name a day that never occurred. Fix in is_date where the fields are already sliced: bound the day by the month (30 for 04/06/09/11, and 28/29 for February with the usual leap rule), or, if calendar-exact validation is deliberately out of scope, change the header sentence and the two refusal messages to say "in-range" rather than "real calendar" date. Either way, add a case asserting the chosen behavior for 2026-02-30.🔧 Fix: validate mark arguments before idempotent replay
2 issues (1 warning, 1 info) still open:
bin/fm-bitwarden-ceremony.sh:408- cmd_mark stamps the appended step line with $(today) but never checks that line against the date invariants parse_record enforces on read (lines 272-277), so on any record whose created header or last step date is in the future, mark reports success and permanently bricks the record. Verified by direct invocation, twice. (1) Recordcreated: 2026-01-01withstep: preflight date=2026-12-31and one item:checkexits 0 printingnext: approval;mark fut approval --approved-by captainexits 0 printing "step 'approval' recorded (2026-08-29)"; the very nextcheckandstatusboth exit 1 with "corrupt at line 6 (step date is earlier than the previous step, so the recorded history runs backwards)". (2) Recordcreated: 2030-01-01:mark fc preflightexits 0, thencheckexits 1 with "step date is earlier than the created header, so the step predates the batch". In both cases the operator is told the gate was recorded while the auditable no-secret completion record - the deliverable's whole point per docs/bitwarden-rollout.md:93 - is destroyed, and the documented remedy (docs/bitwarden-rollout.md:97, and RECOVERY_HINT at line 91: "this tool never repairs a record for you") requires deleting the line the tool itself just wrote. Root cause is a read/write asymmetry introduced by the round-6 date fix: parse_record accepts dates the write path can never produce. A record dated entirely in the future is accepted as finished evidence -created: 2099-12-31with all five steps at 2099-12-31 passesstatuswith exit 0,items: 1, all five gates[x],next: complete- certifying a completed retirement on a day that has not happened. Fix at the shared boundary that already owns the date rules: in parse_record, also refuse a created header or step date later thanbin/fm-bitwarden-ceremony.sh:362- cmd_add_item detects a conflicting re-add withprintf '%s\n' "$PARSED_ITEMS" | grep -Fqx -- "$label"- a fork plus a pipe - to answer exactly the question parse_record already answers in-shell at line 251 withcase $'\n'"$PARSED_ITEMS" in *$'\n'"$name"$'\n'*). The two are equivalent here: PARSED_ITEMS is newline-terminated, and $label has already passed require_label at line 352, so it holds no glob metacharacter (the charset at line 128 is A-Za-z0-9 . _ @ / -). Replacing the pipeline with the same case glob removes both subprocesses, drops the -F/-x/-- option-safety dance that the dash-leading-label test at tests/fm-bitwarden-ceremony.test.sh:119-127 exists to guard, and leaves one definition of 'this label is already registered' instead of two. No behavior change.🔧 Fix: refuse future-dated ceremony records
1 info still open:
bin/fm-bitwarden-ceremony.sh:240- The round-8 fix makes parse_record refuse any created or step date later than today(), which correctly closes the future-dated-evidence hole, but it also means a record the tool itself wrote while the host clock was ahead becomes permanently unreadable once the clock corrects. Verified by direct invocation: a record whose only content is the v1 magic, a batch header, andcreated: <today+1>(exactly whatinitwrites on a fast clock) makes bothinitandcheckexit 1 with "corrupt at line 3 (created header is dated in the future...)" plus the RECOVERY_HINT at line 93 saying "this tool never repairs a record for you". Because the offending line is the created header, there is no "last valid prefix" to trim back to, so the only remedy is the quarantine-and-restart branch already documented at docs/bitwarden-rollout.md:97. This is a fail-closed refusal rather than a wrong result, it needs an incorrect system clock to reach, and the alternative (checking only in cmd_mark) would leave a fully future-dated record readable asnext: completeevidence - so the tradeoff looks like the right one. Recording it as informational only: no change requested, and the documented quarantine path already covers the recovery.✅ **Test** - passed
✅ No issues found.
bash tests/fm-bitwarden-ceremony.test.sh- colocated behavior suite (malicious batch ids, redaction, no-secret logging, step ordering, idempotent retries, partial recovery, retirement refusal, calendar/future date validation, truncated and misfiled records)bash tests/fm-documentation-audiences.test.sh- validates the new docs/bitwarden-rollout.md operator-current inventory entry through the real audience consumerManual end-to-end operator walkthrough ofbin/fm-bitwarden-ceremony.sh(init -> add-item -> preflight -> approval -> moved -> verified -> retired) against an isolated FM_DATA_OVERRIDE data root, captured as a CLI transcriptfm-bitwarden-ceremony init ../../etc/evil/init 'a b'/init ghp_.../init deadbeef...thenfindover the data root - malicious and secret-shaped batch ids refused, nothing created or escapedfm-bitwarden-ceremony add-item ... --collection ghp_ZZZZ...and--collection a1b2c3d4...thengrep -R -Fover the whole data root - refusal is redacted and the value is neither logged nor persistedfm-bitwarden-ceremony mark <batch> retiredbefore verification, andmark <empty-batch> movedwith no items - both destructive/ordering gates refusedfm-bitwarden-ceremony mark <batch> approval --approved-by malloryreplayed over a recorded approval - refused, recorded approver unchangedfm-bitwarden-ceremony check/statusagainst a future-dated step, a deleted approval line, a newline-truncated record, and a record copied under another batch id - each refused by line number with content withheldPATH-shimmed run (curl/wget/bw/op/nc/ssh/security/aws/gcloud/vault/openssl replaced with abort stubs) acrossstatus,init, andadd-item- no credential-store or network binary invokedgit diff --name-only <base>..HEAD -- AGENTS.md,grep -nE 'curl|wget|http|bw |vault|token=' bin/fm-bitwarden-ceremony.sh,git check-ignore -v data/bitwarden- hard boundaries verifiedgit status --porcelain- worktree clean, no transient artifacts left behind✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.