Rotate the key without breaking CI
A key rotation touches two things at once. Every encrypted value in the repository has to be written again, and every job that reads one has to learn the new key. Do them in the wrong order and the metadata gate goes red across the whole docset, on pages nobody edited.
This page is the runbook. It assumes the key already lives somewhere, which Put the key where every tool finds it settles.
Every transcript below is real output from the built tool.
Before you start
Section titled “Before you start”- Run it on a clean working tree.
manni key rotaterewrites pages, manifests and the config. A clean tree is what lets you read the diff. - Run it where the sources are reachable. An encrypted citation’s pin is
keyed over the cited lines, so rotation reads them. Point
--rootat the code checkout when the pages and the code live apart. - Know which key you are replacing. A key in
manni.config.yamlis replaced for you. A key inMANNI_ENCRYPTION_KEYis replaced by you, and the environment variant below is the path for it. - Do it on a branch. The rotation is a content change to every page that holds an encrypted value, and it wants the same review any of them would get.
The runbook
Section titled “The runbook”-
Check that the repository is green under the current key. A rotation cannot fix a value that was already wrong, and it will not start beside one it cannot decrypt.
Terminal window $ manni meta validateUsing manni.config.yaml (.)✓ docs/auth.md✓ docs/limits.md2 files checked, 2 passed, 0 failed, 0 errors# exit 0$ manni cite checkUsing manni.config.yaml (.)✓ docs/auth.md✓ docs/limits.md✓ fetch-timeout :17 current ~Afnt…:2 current2 files checked, 2 passed, 0 failed, 0 findings# exit 0 -
Preview the whole rotation.
--dry-runre-encrypts everything in memory and writes nothing at all, not a page, not a manifest and not the config:Terminal window $ manni key rotate --dry-rundocs/auth.md: /owner ~AZVg… -> ~AT6O…docs/limits.md: fetch-timeout ~Afnt…:2 -> ~AdMQ…:22 values re-encrypted in 2 files, 0 skippedDry run: nothing written.# exit 0Read the skip count. A single skipped value means nothing would be written, and the run would exit
1. The new key is never written beside a value it cannot decrypt. -
Rotate the whole family. With no paths and no
--collection, one run covers every collection, its pages and its local manifests.rotategenerates the new key when you do not pass--to, and never prints it:Terminal window $ manni key rotatedocs/auth.md: /owner ~AZVg… -> ~AeLw…docs/limits.md: fetch-timeout ~Afnt…:2 -> ~Abq5…:22 values re-encrypted in 2 files, 0 skippedEncryption key written to manni.config.yaml.The citation baseline fingerprints id-less encrypted citations by their pin; re-record it with `manni cite check --write-baseline`.# exit 0The metadata values are named by their JSON pointer,
/owner. The citation is named by its id, and keeps its source lines after the ciphertext. -
Update the CI secret, if the job reads one. Nothing to do when the key lives in
manni.config.yamland CI reads the committed file. When the job readsMANNI_ENCRYPTION_KEY, follow the environment variant instead of this step, because the whole run above would have refused. -
Re-record the citation baseline. The last line of step 3 asks for this, and it only appears when a baseline exists. A baseline fingerprints a citation that carries no id by its pin, and a new key changes every keyed pin:
Terminal window $ manni cite check --write-baselineUsing manni.config.yaml (.)✓ docs/auth.md✓ docs/limits.md✓ fetch-timeout :17 current ~Abq5…:2 current2 files checked, 2 passed, 0 failed, 0 findingsBaseline written to .manni-cite-baseline.json# exit 0The file is whatever
cite.baselinenames, else.manni-cite-baseline.jsonbeside the config. Commit it with the rotated pages. -
Verify under the new key. Both gates should read exactly as they did in step 1, over different ciphertexts:
Terminal window $ manni meta validateUsing manni.config.yaml (.)✓ docs/auth.md✓ docs/limits.md2 files checked, 2 passed, 0 failed, 0 errors# exit 0$ manni cite checkUsing manni.config.yaml (.)✓ docs/auth.md✓ docs/limits.md✓ fetch-timeout :17 current ~Abq5…:2 current2 files checked, 2 passed, 0 failed, 0 findings# exit 0
Commit the pages, the manifests, the config and the baseline together. They are one change, and a pull request that carries half of it fails on the other half.
When the key lives in the environment
Section titled “When the key lives in the environment”The tool cannot reach into your secret store, so it will not pretend the key
was replaced. A run with no --to refuses and says what to do:
$ manni key rotatemanni: The key comes from MANNI_ENCRYPTION_KEY; pass --to <value>, re-encrypt with it, then update the secret. Nothing is written to config.# exit 2Generate the new value, pass it as --to, and let the run rewrite the pages.
The config is never touched:
$ manni key rotate --to "$NEW_KEY"docs/auth.md: /owner ~AeLw… -> ~Aa0O…docs/limits.md: fetch-timeout ~Abq5…:2 -> ~AY9Z…:22 values re-encrypted in 2 files, 0 skippedKey not written: it comes from MANNI_ENCRYPTION_KEY. Update the secret to the value you passed.The citation baseline fingerprints id-less encrypted citations by their pin; re-record it with `manni cite check --write-baseline`.# exit 0With the secret updated, the same check is green again:
$ manni meta validateUsing manni.config.yaml (.)✓ docs/auth.md✓ docs/limits.md
2 files checked, 2 passed, 0 failed, 0 errors# exit 0Rotate one area at a time
Section titled “Rotate one area at a time”A repository too large to rewrite in one commit can be rotated area by area.
Positional paths and --collection both narrow a run, and a narrowed run never
writes the key. Writing it would leave everything outside the run encrypted
under a key the config no longer holds.
That is why a narrowed run needs --to:
$ manni key rotate docs/auth.mdmanni: A run over part of the family needs --to, and never writes the key.# exit 2Pass the same --to to every narrowed run, then finish with a whole run under
that value:
$ manni key rotate docs/auth.md --to "$NEW_KEY"docs/auth.md: /owner ~AeLw… -> ~Aa0O…1 value re-encrypted in 1 file, 0 skippedKey not written: this run covered part of the family. Finish with a whole run under the same key: `manni key rotate --to <the same value>`.The citation baseline fingerprints id-less encrypted citations by their pin; re-record it with `manni cite check --write-baseline`.# exit 0
$ manni key rotate --to "$NEW_KEY"docs/limits.md: fetch-timeout ~Abq5…:2 -> ~AY9Z…:21 value re-encrypted in 1 file, 0 skippedEncryption key written to manni.config.yaml.The citation baseline fingerprints id-less encrypted citations by their pin; re-record it with `manni cite check --write-baseline`.# exit 0The whole run re-encrypted only what the first run had not reached. A value already under the new key counts as done, which is the one rule that makes narrowed runs compose.
Between the two runs the gate is red, because half the pages are under each key:
$ manni meta validateUsing manni.config.yaml (.)✗ docs/auth.md /owner /owner does not decrypt under the current key: encrypted under another key, or edited by hand. (line 3) [encrypted:unreadable]✓ docs/limits.md
2 files checked, 1 passed, 1 failed, 1 error# exit 1Keep the area-by-area runs on one branch, and merge when the whole run has finished. The CLI reference has the rules for what a narrowed run selects.
Resume an interrupted rotation
Section titled “Resume an interrupted rotation”A run stopped between its first write and its last leaves the config holding both keys:
encryptionKey: <the new key>encryptionKeyPrevious: <the old key>Nothing is lost. The new key reached the config before the first page, and the old one stays beside it until the last page is written. Every value still decrypts under one of the two.
Meanwhile the pages the run did not reach report the same
encrypted:unreadable finding, so the gate tells you the rotation is unfinished
before anything else does:
$ manni meta validateUsing manni.config.yaml (.)✗ docs/auth.md /owner /owner does not decrypt under the current key: encrypted under another key, or edited by hand. (line 3) [encrypted:unreadable]✓ docs/limits.md
2 files checked, 1 passed, 1 failed, 1 error# exit 1Until it finishes, the two commands that would replace the key again are refused with one line each:
$ manni key setmanni: A rotation is unfinished in manni.config.yaml. Run `manni key rotate` with no --to to finish it.# exit 2
$ manni key rotate --to "$SOME_OTHER_KEY"manni: A rotation is unfinished in manni.config.yaml. Run `manni key rotate` with no --to to finish it.# exit 2Run manni key rotate with no --to, and it finishes the job it started. The
report says so above the counts, and encryptionKeyPrevious: is removed:
$ manni key rotatedocs/auth.md: /owner ~AeLw… -> ~Aa0O…docs/limits.md: fetch-timeout ~Abq5…:2 -> ~AY9Z…:2Finished the interrupted rotation in manni.config.yaml.2 values re-encrypted in 2 files, 0 skippedThe citation baseline fingerprints id-less encrypted citations by their pin; re-record it with `manni cite check --write-baseline`.# exit 0Then pick the runbook back up at step 5.
What stops a rotation before it starts
Section titled “What stops a rotation before it starts”Each of these is one line on stderr, and nothing is written.
$ manni key rotate --to tooshortmanni: --to must be at least 32 hex or base64url characters.# exit 2
$ manni key rotate - --to "$NEW_KEY"manni: key rotate reads and writes files, so it takes no stdin (`-`). Name the files instead.# exit 2
$ manni key rotate -f sarifmanni: Unknown --format "sarif". Use pretty or json.# exit 2
$ manni key rotatemanni: No encryption key is available, so nothing can be re-encrypted. Run `manni key set` first.# exit 2A value that will not re-encrypt is the one failure that exits 1 rather than
2, and it stops the whole write. The
CLI reference lists every refusal.