tz6-key-rotation.md

tz6 Key Rotation#

A baker runbook for XMSS consensus keys on the pqpark demo network: how to size one, how to read what is left of it, when to rotate, and how to rotate without destroying the key.

A tz6 key can only ever produce a finite number of signatures. Everything below follows from that.


Validated live on 2026-08-19. The rotation procedure below was executed twice end-to-end on an external baker joined to the shownet (delegate tz5RjfCC…, attesting_power 120/7000): tz5 → tz6 [0,131071] at cycle 244, then tz6 → tz6 [0,16383] at cycle 247. Both activated at n+3 as predicted, both left the delegate active with sufficient_dal_participation. A further A-B-A rotation (16 383 → 131 071 → 16 383, cycles 255–262) settled the key-size question. Figures below marked measured come from those runs.

Why you are being asked to run a tz6#

Standard Tezos aggregates consensus signatures. A post-quantum Tezos that could not would be a downgrade, so the demo network aggregates too — and that is what forces tz6.

Only Bls and Xmss attestations are aggregation-eligible. ML-DSA (tz5) is post-quantum but not aggregatable, and with a consensus committee of 7 000 slots against a validation pass capped at 4 MiB / 2 048 operations, individual post-quantum attestations simply do not fit. So the network runs tz5 manager key + tz6 consensus key, which is what the in-cluster bakers already do.

The cost of aggregation is the mutable state described below. It is real, and the UX around it is not good yet — that is on the roadmap. For now, running a tz6 means running this runbook.

Where the join tutorial stops

tutorial.pqpark.dal.nomadic-labs.com joins the network with a tz5 manager key and a tz6 consensus key sized at slot_end = 131071, announced in the same operation that registers the delegate. Its §7 carries the rotation commands alone — six of them, for the one key size it generates. Everything behind those commands is this document: sizing, where the margins come from, monitoring, and what breaks a key.


The network you are joining#

Every number in this document is computed from the demo network's parameters (internal/genesis/mainnet-parameters.json). They are not mainnet numbers, and the difference matters more than you would expect.

ParameterValueConsequence
minimal_block_delay6 s
blocks_per_cycle100a cycle is 10 minutes
consensus_rights_delay2a key announced at cycle n activates at n + 3 — ~30 minutes
tolerated_inactivity_period2an exhausted key gets you deactivated in ~20–30 min
consensus_committee_size7 000why aggregation is mandatory

Ten-minute cycles are the headline. On a mainnet-like chain the activation window is measured in days and dominates every sizing decision; here it is half an hour, and it is the deactivation deadline that bites first.

The network is a shownet: it is relaunched every couple of weeks. A key does not need to outlive the network.


tz5 and tz6 do not need the same care#

Both are post-quantum. That is not what separates them.

tz5tz6
SchemeML-DSA-44 (FIPS 204)XMSS (hash-based)
StateStatelessStateful
Signature budgetUnlimitedslot_end - slot_start + 1
AggregatableNoYes (recursive zkVM proof)
RotationNot requiredRequired

Each signature of a tz6 key consumes one slot, and every slot is a one-time signing key. The key is only valid over the range [slot_start, slot_end] fixed at creation time.

The rule that governs everything else

Signing twice with the same slot permanently compromises the key — a third party can then forge your signatures, and therefore your double-signatures. The slot counter is mutable state living in the wallet: it needs protecting just as much as the secret key does.


The counter: where it lives, what it means#

The counter is client-side, in <base_dir>/xmss_slots, keyed by the secret key URI. On every signature Octez reads the current slot, writes slot + 1, and then signs — wasting a slot is safe, reusing one is not.

The join tutorial works inside ~/pqnet-tezos, with the wallet in client and the binaries in bin; those are the paths used throughout:

bash
cat client/xmss_slots
json
[ { "uri":
      "unencrypted:xmsk2cLjZfvwyhZxrq4XXuhYCBZ8sWwdpduUVjUm8N2bqF9RWb75NGZDn1UhVjHrKGpxCzvk9ExuL32krZKGmptzhAc",
    "slot": 0 } ]

slot is the next slot to be used, so for a key of range [start, end]:

text
remaining slots = slot_end - slot + 1

Direct consequence

A signature that fails (node unreachable, application error) has already burnt its slot. A burst of failures consumes real budget.


What a baker actually consumes#

An attester signs twice per level: preattestation + attestation. The DAL attestation is embedded in the attestation and costs nothing extra. Add one signature per block proposed, and one extra signature per additional round during round changes.

Two measurements, and they differ by design:

Consumption therefore scales with your baking power, and 2.0 is a floor, not a budget. For sizing, this document and the deployment code both use 3 (pqComputeBaseMargin in internal/cmd/tezosk8s.go) — headroom for proposals, round changes, and bursts of failed signatures. Use 2.0 only to interpret a counter you are watching, never to decide how big a key to generate.

At 3 sig/level, 6 s blocks
per level3 slots
per cycle (100 levels)300 slots
per hour (6 cycles)1 800 slots
per day43 200 slots

The octez default is not viable here

gen keys --sig xmss with no options produces the range [0, 1023] — 34 minutes of attesting. That is shorter than the rotation margin below, meaning such a key cannot even survive one activation window. The deploy path rejects this configuration outright (feasibility guard, internal/cmd/tezosk8s.go:174). A production key must be sized explicitly.


Sizing the range#

The Merkle tree is not stored on disk — it is rebuilt on every load of the key. That cost grows linearly with the range, and it is paid at generation time as well as at baker startup (the baker warms the key up off the consensus critical path).

Generation times measured on the branch binary bin/octez-client (46c784de), 16-core i7-13620H, net of the ~1.4 s client startup. Autonomy at 43 200 slots/day; "usable" subtracts the 1 200-slot rotation margin derived in the next section.

slot_endSignaturesGeneration / loadAutonomyUsable before rotating
1 023 (octez default)1 0240.04 s34 mininfeasible
8 1918 1920.5 s4.6 h3.9 h
16 383 (in-cluster default)16 3841.2 s9.1 h8.4 h
65 53565 5364.4 s1.5 d1.5 d
131 071131 0728.8 s3.0 d3.0 d
524 287524 28838 s12.1 d12.1 d

Memory is ~345 MB per key and does not grow with the range — but it is additive per key the baker holds: a baker measured mid-rotation, holding one 131 071 tree and one 16 383 tree, sat at 690 MB RSS. Warm-up is additive the same way: 9.2 s from daemon start to first signature with one tree, ~11.8 s with two. Budget for both during the rotation window, when you deliberately hold two keys.

Recommended for a baker rotating by hand: --xmss-slot-end 131071 — about three days between rotations, for ~9 s of warm-up at baker startup, and (measured, see below) no penalty at all on per-signature latency.

Why this differs from the in-cluster default of 16 383

internal/genesis/pqkeys.go sets 16 383 for the bakers pqpark deploys, on the grounds that XMSS signing latency grows with tree height (cf. the tezos xmss_large_slot_range_no_signing_stall regression test) — "keep keys this size and ROTATE rather than use giant, slow-to-sign keys".

That is the right call there, because those bakers rotate automatically: rpc-baker.sh runs a background loop that regenerates and rotates without a human. At 16 383 they rotate roughly every 8 hours and nobody notices. A small tree also keeps their startup warm-up near a second, which matters when a rotation ends in a pod restart.

You are doing it by hand, and the latency argument does not transfer: measured on this network, a 131 071 tree signs no slower than a 16 383 one (A-B-A experiment below). So the trade is simply three manual rotations a day at 16 383 versus one every three days at 131 071, paid for with ~8 s more warm-up and ~345 MB of RAM. For a human-operated baker that is not a close call.

Tree height does not cost you signing speed. This was settled by an A-B-A experiment on a live external baker (2026-08-19): the same baker process, never restarted, holding the same three keys throughout, rotated 16 383 → 131 071 → 16 383 across cycles 255–262. Latencies were referenced to an external clock — a poller on the node's monitor/heads/main stream, outside the baker process, so it cannot be skewed by the baker's single-threaded event loop (which XMSS signing loads, cf. docs/public-p2p-exposure.md). Levels are then matched on baker lag < 80 ms so every phase is compared under equivalent conditions.

phasekeynsign median95% CI
AXMSS @16 383104435 ms[317, 555]
BXMSS @131 071128368 ms[303, 416]
A′XMSS @16 38381371 ms[282, 480]

The return leg is the whole point: A′ came back to 371 ms, matching B (368 ms) rather than A (435 ms) — with the same key as A. A difference that does not reproduce when you restore the original condition is drift, not a treatment effect. Formally: 131 071 − 16 383 = −41 ms, 95% CI [−135, +56], while the same-key A→A′ drift is +53 ms, CI [−94, +205] — the noise is as large as the signal (Mann-Whitney p = 0.12 / 0.49 / 0.44 across the three pairings).

So: no per-signature latency difference between a 16 k and a 131 k tree, and the experiment can exclude anything larger than about ±135 ms. The earlier impression that 16 383 was slower was entirely an artefact of an unstable link and a control that ran through the baker's own event loop.

What a bigger tree does still cost is generation and warm-up time (0.04 s → 38 s across the table above) and RAM, not signing. That is what the in-cluster default of 16 383 is really buying, and it matters much less to a baker who rotates by hand than the rotation interval does.

XMSS is nevertheless far slower than ML-DSA, and that gap is real: under the same matching, a tz5 signs in 72 ms with a p90 of 80 ms — near-constant-time — against ~370–435 ms median and a p90 near one second for any XMSS key. On a 6 s block the median is comfortable; the tail is what will eventually cost you an attestation. Monitor the tail, not the average.


When to rotate#

A new consensus key only becomes active at cycle n + consensus_rights_delay + 1 = n + 3: between 2 and 3 cycles, so 20–30 minutes, and the old key has to cover that whole window.

The margin used by the deployment code, and the one this runbook adopts:

text
margin = (consensus_rights_delay + 2) × blocks_per_cycle × 3
       = (2 + 2) × 100 × 3
       = 1200 slots  ≈ 40 minutes

That is the activation window plus one full cycle of buffer. Rotate when remaining < 1200.

With the recommended slot_end = 131071 that threshold is reached after ~3 days of baking, and it leaves ~40 minutes of headroom — comfortable for a process that needs 30 minutes to take effect, but not comfortable enough to discover it by accident. Check the counter at least daily; see the monitoring section.

The deadline is deactivation, not missed rights

With tolerated_inactivity_period = 2 on 10-minute cycles, a baker that stops attesting is deactivated in ~20–30 minutes. Exhausting a key does not just cost you rights until you fix it: it drops your delegate to inactive, and you then have to re-register and wait for rights all over again. Missing the rotation window is expensive here in a way it is not on mainnet.


Rotating#

Rotation goes through the consensus key: the manager key is untouched and delegators do not move. The old key keeps signing until the new one activates.

Commands assume the tutorial's alias, its delegate mykey, and its current consensus key consensus-1:

bash
alias tzc='bin/octez-client --base-dir client --endpoint http://127.0.0.1:8732'
PKH=$(tzc show address mykey | awk '/^Hash:/ {print $2}')

1. Generate the new key, sized

bash
tzc gen keys consensus-2 \
  --sig xmss --xmss-slot-start 0 --xmss-slot-end 131071

The flag is --sig (not --sig-alg). The counter is initialised to slot_start at generation.

On --encrypted

An encrypted key (xmesk…) cannot be decoded, so xmss_status.py can no longer read its range — the monitoring below goes blind on exactly the key it most needs to watch, and it will tell you so (RANGE UNKNOWN).

On a shownet that is relaunched every couple of weeks, with a key that is deliberately short-lived, encryption buys little. Leave it unencrypted. If you do encrypt it, write the range down and pass --warn-slots against a range you track yourself.

2. Check the key before announcing it

bash
tzc show address consensus-2
# Hash:       tz6ENM6…
# Public Key: xmpk…

python3 ~/pqpark/docs/xmss_status.py client

The counter must equal slot_start, and the range must be the one you asked for.

3. Announce the new consensus key

bash
tzc set consensus key for mykey to consensus-2

4. Confirm the announcement is recorded

bash
tzc rpc get /chains/main/blocks/head/context/delegates/$PKH/consensus_key

The response carries active (the key signing today) and pendings (the announcements, with their activation cycle). The new key must appear under pendings.

5. Restart the baker with both keys — without removing the old one

The baker fixes its key set at startup, and must know both keys for the whole window: it signs with the old key until the activation cycle, and with the new one afterwards. That restart is also what pays the new key's warm-up.

bash
bin/octez-baker --base-dir client --endpoint http://127.0.0.1:8732 \
  run with local node l1 mykey consensus-1 consensus-2 \
  --dal-node http://127.0.0.1:10732 \
  --liquidity-baking-toggle-vote pass

The baker confirms its key set on startup — check this line before walking away:

text
Baker will run with the following keys:
       'consensus-1' (tz6A3iihbRym3fZfM3ALPVfkN1fbhDMHapFy)
       'consensus-2' (tz6ENM6…)
       'mykey' (tz5RjfCCXq51Udp5ZegArR9Vpm2XT7gmmKs2)

Until the new key activates, the baker reports it as having no rights (The following delegates have no attesting rights at level …, naming the tz6). That is expected, not a misconfiguration.

You have ~30 minutes before activation, so the restart is not urgent — but do not postpone it past the activation cycle, or the baker will not hold the key it is supposed to be signing with. The restart itself costs attestations: measured at 8 levels (~55 s) between the last signature of the old process and the first of the new one, warm-up included. Do it early in the window, not at the boundary.

6. Wait for activation, then verify

At cycle n + 3 (~30 minutes), the same RPC must show the new key as active and pendings empty. Then check that the new key's counter is advancing and that the old key's counter has stopped:

bash
python3 ~/pqpark/docs/xmss_status.py client
text
alias             next   slot_end  remaining   autonomy  status
consensus-1     130204     131071        868     29 min  ROTATE NOW  <- frozen
consensus-2        130     131071     130942      3.0 d  ok          <- advancing

The retired key still reads ROTATE NOW — expected: the status column knows nothing about which key the baker signs with. What matters is that its next has stopped moving, and the new key's has not.

Once the new key is active the attestation log gains a with consensus key clause, which is the ground-truth confirmation that the tz6 is doing the signing:

text
injected attestation (attesting DAL slots at published level(s): 24418 -> [0])
  for level 24421, round 0 for delegate
  'mykey' (tz5RjfCC…) with consensus key
  'consensus-2' (tz6ENM6…)

The new key burns nothing before it activates

Verified on a live rotation: the incoming key's counter stayed at 0 for the entire ~30-minute window while the outgoing key kept signing, and took its first slot at the first level of the activation cycle. You do not need to size in any budget for the waiting period.

Your DAL node needs no change

The DAL attester profile follows the delegate, not the consensus key. A node running --attester-profiles <tz5-manager> kept attesting DAL slots across a consensus-key rotation without a restart. Do not rotate it.

7. Retire the old key — without deleting its counter

Take consensus-1 out of the baker command line at your next restart. Keep its xmss_slots entry and the key in the wallet: if you delete them and one day re-import the same key, you restart from a zeroed counter, i.e. straight into slot reuse.


Monitoring#

There is no exhaustion warning from Octez: no baker event tells you the budget is running low. The first signal is the hard failure, mid-baking:

text
Error:
  XMSS signing slot 64 is out of the key's valid range [0, 63]: the key is exhausted.

From that point the baker signs nothing, misses every right, and is deactivated within ~30 minutes — while a rotation needs ~30 minutes to activate. There is no recovering from this gracefully; you have to not reach it.

docs/xmss_status.py is built for a cron. It exits 0 when every key is fine, 1 when one is below the margin or unreadable, 2 when one is exhausted, and with --quiet it prints nothing in the healthy case:

bash
python3 ~/pqpark/docs/xmss_status.py client
text
alias             next   slot_end  remaining   autonomy  status
consensus-1     129998     131071       1074     36 min  ROTATE NOW
consensus-2      41203     131071      89869      2.1 d  ok

An hourly cron is enough at slot_end = 131071 (three days of budget):

cron
0 * * * * python3 ~/pqpark/docs/xmss_status.py ~/pqnet-tezos/client --quiet || \
            echo "tz6 rotation needed" | mail -s "pqpark baker" you@example.org

Defaults are this network's: --sigs-per-day 43200, --warn-slots 1200. Override both if you deploy against different parameters.

Not every missed attestation is the key's fault

An external baker holds a single peer over an off-cluster link, and that link is the more common culprit. Observed on the reference run: an 18-level gap (~108 s) with no signing involved at all, announced by

text
WARN │ No data received from monitor_operations RPC for 9. seconds.
     │ Assuming the stream is stalled and refreshing it.

followed by several levels delivered in one burst once the stream recovered. Key exhaustion looks nothing like this — it is the explicit slot … is out of the key's valid range error, and it does not recover on its own. Check xmss_status.py before blaming the tz6: if remaining is healthy, the problem is upstream of the key.

Also

Every one-shot octez-client invocation that signs rebuilds the key (≈ 9 s at slot_end = 131071). For a production key, go through the octez-baker daemon, which warms it once at startup and keeps it cached.


What breaks a tz6 key#

Every situation below produces the same fault: two signatures on one slot.

The good news

The error is asymmetric and Octez plays the safe side: the increment is written before the signature, so a crash loses a slot instead of replaying one.


Appendix · xmss_status.py#

Decodes the range of every xmsk key in the wallet and cross-references the counter. No dependencies. Kept in sync with docs/xmss_status.py — run that file, this listing is for reading.

python
#!/usr/bin/env python3
"""État des clés XMSS (tz6) d'un wallet octez-client.

Reads <BASE_DIR>/xmss_slots (the counter) and decodes the slot range stored in
each xmsk secret key (the capacity), then reports how much signing budget is
left and whether it is time to rotate.

Exit codes, so this can be crontab'd directly:
    0  every key has more than --warn-slots remaining
    1  at least one key is below the margin (rotate now) or cannot be read
    2  at least one key is exhausted (the baker is already failing)

Defaults are those of the pqpark demo network: 6 s blocks, blocks_per_cycle=100,
consensus_rights_delay=2, ~3 signatures per level. Override for another network.
"""
import argparse
import json
import os
import sys

ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
XMSK_PREFIX = bytes([32, 113, 56, 113])  # "xmsk", unencrypted XMSS secret key
SEED_LEN = 20

# 3 signatures per level x 14400 levels/day at minimal_block_delay=6. Measured
# rates: ~2.0 for a small external baker, ~2.6 in-cluster; 3 is sizing headroom.
DEFAULT_SIGS_PER_DAY = 43200
# (consensus_rights_delay + 2) * blocks_per_cycle * 3 = (2+2) * 100 * 3.
# Mirrors pqComputeBaseMargin() in internal/cmd/tezosk8s.go.
DEFAULT_WARN_SLOTS = 1200

OK, ROTATE, EXHAUSTED = 0, 1, 2


def b58decode(s):
    n = 0
    for c in s:
        n = n * 58 + ALPHABET.index(c)
    raw = n.to_bytes((n.bit_length() + 7) // 8, "big")
    return b"\x00" * (len(s) - len(s.lstrip("1"))) + raw


def strip_uri(uri):
    """Drop the scheme prefix and any ?xmss_slot=N query."""
    return uri.split(":")[-1].split("?")[0]


def slot_range(sk_uri):
    """(slot_start, slot_end), or None if the key cannot be decoded.

    Encrypted keys (xmesk...) are opaque: their range is only recoverable from
    whatever you recorded at generation time.
    """
    key = strip_uri(sk_uri)
    if not key.startswith("xmsk"):
        return None
    try:
        raw = b58decode(key)
    except ValueError:
        return None
    if raw[:4] != XMSK_PREFIX:
        return None
    body = raw[4:-4]  # without prefix or checksum
    if len(body) < SEED_LEN + 8:
        return None
    return (
        int.from_bytes(body[SEED_LEN:SEED_LEN + 4], "little"),
        int.from_bytes(body[SEED_LEN + 4:SEED_LEN + 8], "little"),
    )


def load_aliases(base_dir):
    """{normalised secret-key URI: alias} from <base_dir>/secret_keys."""
    path = os.path.join(base_dir, "secret_keys")
    if not os.path.exists(path):
        return {}
    with open(path) as fh:
        return {strip_uri(e["value"]): e["name"] for e in json.load(fh)}


def collect(base_dir, sigs_per_day, warn_slots):
    """[(alias, next_slot, slot_end, remaining, days, state)], worst state first."""
    path = os.path.join(base_dir, "xmss_slots")
    if not os.path.exists(path):
        sys.exit(f"no {path}: no tz6 key in this wallet")
    with open(path) as fh:
        entries = json.load(fh)
    aliases = load_aliases(base_dir)

    rows = []
    for e in entries:
        alias = aliases.get(strip_uri(e["uri"]), "?")
        nxt = e["slot"]
        rng = slot_range(e["uri"])
        if rng is None:
            # Range unknown => monitoring is blind on this key. That is itself
            # worth alerting on, so it counts as ROTATE rather than OK.
            rows.append((alias, nxt, None, None, None, ROTATE))
            continue
        _, slot_end = rng
        remaining = slot_end - nxt + 1
        state = (EXHAUSTED if remaining <= 0
                 else ROTATE if remaining < warn_slots
                 else OK)
        rows.append((alias, nxt, slot_end, remaining, remaining / sigs_per_day,
                     state))
    rows.sort(key=lambda r: (-r[5], r[0]))
    return rows


def human(days):
    """Days as the largest sensible unit — cycles here are 10 minutes long."""
    if days >= 1:
        return f"{days:.1f} d"
    if days * 24 >= 1:
        return f"{days * 24:.1f} h"
    return f"{days * 1440:.0f} min"


def main():
    p = argparse.ArgumentParser(
        description="Report the remaining signing budget of tz6 (XMSS) keys.")
    p.add_argument("base_dir", nargs="?",
                   default=os.environ.get("TEZOS_CLIENT_DIR",
                                          os.path.expanduser("~/.tezos-client")),
                   help="octez-client base directory "
                        "(default: $TEZOS_CLIENT_DIR or ~/.tezos-client)")
    p.add_argument("--sigs-per-day", type=int, default=DEFAULT_SIGS_PER_DAY,
                   help=f"assumed consumption, for the autonomy column "
                        f"(default: {DEFAULT_SIGS_PER_DAY})")
    p.add_argument("--warn-slots", type=int, default=DEFAULT_WARN_SLOTS,
                   help=f"rotate-now threshold, in slots "
                        f"(default: {DEFAULT_WARN_SLOTS})")
    p.add_argument("--quiet", action="store_true",
                   help="print nothing when every key is fine (for cron)")
    args = p.parse_args()

    rows = collect(args.base_dir, args.sigs_per_day, args.warn_slots)
    worst = max((r[5] for r in rows), default=OK)

    if not (args.quiet and worst == OK):
        width = max([len("alias")] + [len(r[0]) for r in rows])
        print(f"{'alias':<{width}} {'next':>10} {'slot_end':>10} "
              f"{'remaining':>10} {'autonomy':>10}  status")
        for alias, nxt, slot_end, remaining, days, state in rows:
            if slot_end is None:
                print(f"{alias:<{width}} {nxt:>10} {'?':>10} {'?':>10} "
                      f"{'?':>10}  RANGE UNKNOWN (encrypted key: "
                      f"check the range you recorded at generation)")
                continue
            status = {OK: "ok",
                      ROTATE: "ROTATE NOW",
                      EXHAUSTED: "EXHAUSTED"}[state]
            print(f"{alias:<{width}} {nxt:>10} {slot_end:>10} "
                  f"{remaining:>10} {human(days):>10}  {status}")

    return worst


if __name__ == "__main__":
    sys.exit(main())

Why tz6 at all, when tz5 is already post-quantum#

Because tz5 signatures cannot be aggregated. Individually the two schemes cost the same (XMSS 2 329 bytes measured, ML-DSA-44 2 420 bytes), but N XMSS signatures fold into a single recursive zkVM proof, and ML-DSA has no such path — only Bls and Xmss attestations are eligible for aggregation.

Measured aggregate proof sizes: ~97 KB of fixed base plus roughly 0.4–1 KB per signer (96 967 bytes at 1 signer, 112 928 at 32), independent of the key's slot range. Break-even against individual ML-DSA signatures sits around 50 signers, and the gap widens from there. With a consensus committee of 7 000 slots and a consensus validation pass capped at 4 MiB / 2 048 operations, individual post-quantum attestations simply do not fit.

So: tz5 for accounts, transactions and the manager key — stateless, unlimited, no rotation. tz6 only where aggregation is mandatory, which is the consensus key. The mutable state is the price paid for that.


References#

Octez tree:

PathWhat it pins down
src/lib_crypto/xmss.mliSigning contract, one-time-slot warning, aggregation API
src/lib_client_base/client_keys.mlSlot counter, range validation, exhaustion message
src/lib_client_commands/client_keys_commands.mlGeneration and import options
src/proto_alpha/lib_delegate/baking_commands.mlKey warm-up at baker startup
src/proto_alpha/lib_delegate/block_forge.mlWhich signatures are aggregation-eligible
src/proto_alpha/lib_protocol/constants_repr.mlConsensus key activation delay
src/proto_alpha/lib_protocol/main.mlConsensus validation pass quotas

This repository:

PathWhat it pins down
internal/genesis/mainnet-parameters.jsonThe network parameters every number here derives from
internal/genesis/pqkeys.goIn-cluster slot_end = 16383 and its rationale
internal/cmd/tezosk8s.gopqComputeBaseMargin, the 1 200-slot margin, the feasibility guard
helm/tezos-k8s/scripts/rpc-baker.shThe automatic rotation loop in-cluster bakers run
docs/pq-default-bootstrap-plan.mdtz5 manager + tz6 consensus from genesis
docs/join-network-tutorial.mdJoining the network: tz5 manager + tz6 consensus, and the bare rotation commands (§7)