From e86cb66ab34f7aec90e0d1412cd7960c24a06b9d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?=
Date: Tue, 18 Aug 2026 15:02:30 +0000
Subject: [PATCH 1/2] docs(tbtc-signer): spec doc updates for Decisions 5, 6,
7, 8, 9
Updates FROST shadow-mode phase-freeze docs, opaque DKG session
handle spec, ROAST adaptive timeout / terminal error class, and the
per-(sender, key_group) interactive rate-limit spec to match the
Decision 7 implementation in src/engine/policy.rs (env var names,
defaults, and enforcement points corrected against the actual
config.rs constants).
---
.../docs/permissioned-signer-hardening-rfc.md | 79 ++++++++++++++
...phase-7-interactive-session-spec-freeze.md | 102 ++++++++++++++----
.../docs/roast-coordinator-seed-derivation.md | 48 +++++++++
.../signer/docs/roast-phase-0-spec-freeze.md | 26 +++++
.../roast-phase-5-security-rollout-gates.md | 91 ++++++++++++++++
5 files changed, 327 insertions(+), 19 deletions(-)
diff --git a/pkg/tbtc/signer/docs/permissioned-signer-hardening-rfc.md b/pkg/tbtc/signer/docs/permissioned-signer-hardening-rfc.md
index c5de3ffd97..b1eb2330c8 100644
--- a/pkg/tbtc/signer/docs/permissioned-signer-hardening-rfc.md
+++ b/pkg/tbtc/signer/docs/permissioned-signer-hardening-rfc.md
@@ -56,6 +56,85 @@ TEEs as prerequisites, while remaining compatible with either in future.
| `P2-M2` Implementation diversity + differential fuzzing | Independent verification path or secondary implementation checks, differential/fuzz harnesses, divergence triage workflow | Security + Protocol | Differential CI runs continuously with no unresolved critical divergence |
| `P2-M3` Canary rollout + instant rollback controls | 10%-50%-100% rollout policy, signer cohort canaries, one-command rollback and config pinning | Platform + Ops | Canary progression is automated by SLO gates; rollback is validated under incident drill |
+### P0-M3 Rate-Limit Specification (Decision 7 extension)
+
+The P0-M3 milestone introduced per-call value/script-class rate
+controls for the signing path (`BuildTaprootTx` and its peers),
+mounted via the signing policy firewall. Decision 7 of the PR #4005
+review extends the same rate-limit discipline to the interactive
+session entry points — `InteractiveSessionOpen` and
+`InteractiveRound1` — because both are the canonical per-operator
+amplifiers: a hostile or misconfigured operator can otherwise
+inflate attempt throughput on a single key group without bound.
+The shape mirrors the existing `BuildTaprootTx` rate-limit
+configuration (token-bucket refill, env-var tunable, fail-closed
+rejection) but uses two buckets per operation rather than one, so
+that the per-caller and per-key-group budgets are independently
+observable.
+
+#### `InteractiveSessionOpen` rate-limit buckets
+
+| Bucket | Scope | Trigger | Reason code on exhaustion | Env-var knob | Default |
+| --- | --- | --- | --- | --- | --- |
+| Primary | per-`(sender, key_group)`, includes the attempt-context fingerprint so each fresh attempt has independent budget (replay protection) | `InteractiveSessionOpen` for a given `(sender, key_group)` exceeds the budget | `interactive_rate_limit_exceeded` | `TBTC_SIGNER_INTERACTIVE_OPEN_RATE_LIMIT_PER_MINUTE` | 60/min |
+| Cross-operator | per-`(member, key_group)`, aggregates across attempts to bound a member's effective work rate on a given wallet | Sum of Open calls for a `(member, key_group)` exceeds the cross-operator cap | `interactive_cross_operator_cap_exceeded` | `TBTC_SIGNER_INTERACTIVE_OPEN_CROSS_OPERATOR_CAP_PER_MINUTE` | 5/min |
+
+Both buckets are enforced at `InteractiveSessionOpen` (charged in order:
+primary bucket first, then cross-operator cap), implemented by
+`enforce_interactive_open_rate_limit` and
+`enforce_interactive_open_cross_operator_cap` in `src/engine/policy.rs`.
+The primary bucket is the per-caller throttle; the cross-operator cap is
+the per-`(member, key_group)` cap that prevents a single operator from
+inflating the effective budget by rotating `sender` identifiers or
+attempt contexts.
+
+#### `InteractiveRound1` rate-limit bucket
+
+`InteractiveRound1` has its own independent primary bucket — it does
+NOT reuse the Open cross-operator bucket. Implemented by
+`enforce_interactive_round1_rate_limit` in `src/engine/policy.rs`.
+
+| Bucket | Scope | Trigger | Reason code on exhaustion | Env-var knob | Default |
+| --- | --- | --- | --- | --- | --- |
+| Primary | per-`(sender, key_group)`, includes the attempt-context fingerprint | `InteractiveRound1` for a given `(sender, key_group)` exceeds the budget | `interactive_round1_rate_limit_exceeded` | `TBTC_SIGNER_INTERACTIVE_ROUND1_RATE_LIMIT_PER_MINUTE` | 60/min |
+
+There is no separate cross-operator cap on `InteractiveRound1` today —
+only the per-`(sender, key_group)` primary bucket. A cross-operator cap
+on Round1 (mirroring Open's) is a candidate future hardening, not
+implemented in Decision 7.
+
+#### Operator knobs and defaults
+
+All three knobs (`TBTC_SIGNER_INTERACTIVE_OPEN_RATE_LIMIT_PER_MINUTE`,
+`TBTC_SIGNER_INTERACTIVE_OPEN_CROSS_OPERATOR_CAP_PER_MINUTE`,
+`TBTC_SIGNER_INTERACTIVE_ROUND1_RATE_LIMIT_PER_MINUTE`) are env-var
+tunable, follow the existing `TBTC_SIGNER_*_ENV` pattern from
+`src/engine/config.rs`, and are parsed with the same bounded-parse
+rule as the rest of the policy surface. Defaults: 60/min for both
+primary buckets, 5/min for the cross-operator cap — deliberately much
+tighter than the primary bucket, since a compromised or misbehaving
+operator rotating `sender`/attempt identities is the threat this cap
+specifically targets. Each rejection emits a structured policy event
+with the `(sender, key_group)` / `(member, key_group)` tuple and the
+active bucket state at the time of rejection; the rejection is
+fail-closed (no exception carve-out for the host). All rate-limit
+state is process-local (in-memory token buckets) and resets on signer
+restart — it is never persisted or durable.
+
+#### Cumulative-rejection budget (Decision 8)
+
+The P0-M3 rate-limit discipline is independent of the
+`wallet_deadline_exceeded` terminal error class from Decision 8.
+Rate-limit rejections consume the per-(sender, key_group) and
+cross-operator buckets but do NOT consume the wallet-level attempt
+budget — a flood of rejects from a hostile operator therefore does
+not exhaust the wallet's deadline. The wallet-level deadline is
+only consumed by attempts that were admitted by all rate-limit
+buckets and produced a signing-flow outcome. This separation is
+load-bearing: keeping the rate-limit and the wallet-level deadline
+independent means a rate-limited operator cannot DoS the wallet's
+attempts by spending the deadline on its own rejections.
+
## Acceptance Test Catalog
### P0 acceptance tests
diff --git a/pkg/tbtc/signer/docs/phase-7-interactive-session-spec-freeze.md b/pkg/tbtc/signer/docs/phase-7-interactive-session-spec-freeze.md
index f392841b18..8efbce8d56 100644
--- a/pkg/tbtc/signer/docs/phase-7-interactive-session-spec-freeze.md
+++ b/pkg/tbtc/signer/docs/phase-7-interactive-session-spec-freeze.md
@@ -21,11 +21,12 @@ included member can veto an attempt. Redemption signings adopt the
path first (slashing-backed deadlines; gates-doc decision 5).
Non-goals of this spec: bounded `n-t+1` concurrent attempts
-(fast-follow — section 8 reserves the room it needs), DKG redesign
-(the interactive DKG primitives ship as-is for now), and the wallet
-recovery-leaf question (explicitly open; nothing here may bake in a
-key-path-only assumption — the session layer takes the Taproot
-merkle root as an input, as today).
+(fast-follow — section 8 reserves the room it needs), DKG redesign:
+DKG custody now follows the same opaque-handle design as the signing
+path (per the section-4 extension below); the wallet recovery-leaf
+question (explicitly open; nothing here may bake in a key-path-only
+assumption — the session layer takes the Taproot merkle root as an
+input, as today).
## 2. Inherited decisions (settled; cite, do not relitigate)
@@ -122,20 +123,83 @@ the FFI and never persist.**
atomically.
This is also the audit story for the FFI boundary — scoped
-precisely: after Phase 7, no secret material of the **signing
-path** (key shares already env/command-only; now nonces too)
-transits the Go/Rust interface in either direction. The interactive
-**DKG** primitives are explicitly out of this spec's scope and
-still hand secret round packages to the host (`dkg_part1` returns
-`secret_package_hex`; `dkg_part2` accepts it back). DKG custody is
-a named follow-up with the same design shape as section 4; until it
-lands, the audit scope statement must describe the DKG boundary
-as-is rather than inheriting this section's claim.
-
-> **FFI boundary (explicit):** the **DKG path is OUTSIDE this secret-boundary**.
-> `dkg_part1` still produces `secret_package_hex` that crosses the FFI;
-> subsequent DKG parts operate on hex deserialisation back to in-memory structs.
-> The non-persistence guarantee is enforced only for the interactive signing path.
+precisely: after Phase 7, **no secret material of the signing path
+transits the Go/Rust interface in either direction**, and the
+interactive **DKG** path now follows the same design shape (Decision
+5): the secret round package for DKG is held inside the engine under
+an opaque handle, and only the handle (a `u64`) crosses the FFI.
+There is no longer an exception carved out for DKG in the audit
+scope.
+
+### DKG custody, opaque handle (Decision 5)
+
+The distributed-DKG primitives (`dkg_part1` / `dkg_part2` / `dkg_part3`)
+now keep the secret round package inside the engine process. The wire
+shape is:
+
+* `DkgPart1Result` adds `session_handle: u64` alongside the public
+ `package: DkgRound1Package`. The handle is generated by an
+ incrementing counter at the moment `dkg_part1` succeeds; the
+ `secret_package_hex` is moved into the in-process table
+ `DKG_SESSION_STORAGE` in `src/engine/frost_ops.rs`, keyed by the
+ handle, and is **not** returned to the host.
+* `DkgPart2Request` and `DkgPart3Request` take `session_handle: u64`
+ instead of `secret_package_hex`. The engine resolves the held
+ secret round package from `DKG_SESSION_STORAGE` by the handle and
+ uses it in-process; the host never re-serializes the round-1
+ secret back into hex.
+* `DkgPart2Result` adds `session_handle: u64` alongside the public
+ round-2 `packages: Vec`. The handle for the
+ carried secret round-2 package is the same counter type as
+ `DkgPart1Result`; the handle is returned to the host only so the
+ subsequent `DkgPart3Request` can carry it back.
+* `dkg_part3` consumes the handle: on success, the entry in
+ `DKG_SESSION_STORAGE` for the consumed handle is removed
+ (consumption-marker pattern, see below). The function never
+ returns a handle, and the resulting `DkgPart3Result` carries only
+ the public `key_package` / `public_key_package`.
+
+The handle is a monotonic counter, scoped to the engine process. It
+is not durable: a signer restart loses any in-flight DKG handles by
+construction (mirroring the nonce-custody rule above), so the
+operator restart pattern is the same as for a crashed interactive
+attempt — the in-flight DKG must be re-initiated from `dkg_part1`
+after the restart. The handle table is single-process: two engines
+in the same process should not see the same handle counter value in
+practice, and the table is not coordinated across processes.
+
+### Consumption-marker pattern (replay protection)
+
+Once `dkg_part3` is called with a handle, that handle is removed
+from `DKG_SESSION_STORAGE` and cannot be reused. A second call to
+`dkg_part3` with the same handle — by the same host or by a hostile
+host that captured the request bytes — fails closed with a
+structured `DKG_SESSION_HANDLE_NOT_FOUND` / the equivalent
+structured rejection in `src/engine/frost_ops.rs`. The same
+consumption rule applies to the round-1 and round-2 handles
+indirectly: each `dkg_part*` consumes its own handle input on
+success, so a replayed request that succeeds in any part is followed
+by a failed successor rather than a second successful emission.
+This is the DKG analogue of the `consumed_nonce_replay` error in
+the interactive signing path and is the load-bearing piece of the
+no-replay story for the DKG custody boundary.
+
+The marker is process-local and is not written to durable state
+(mirroring the "markers-only" rule from §10.4 for the signing
+path). A restart re-creates the empty table on engine init; the
+audit story is the same: two clones cannot both complete a DKG
+under the same round-1 package, because only the holder of the
+process-local handle can call `dkg_part2` and `dkg_part3` against
+that secret, and the consumption rule prevents a valid retry once
+the handle has been removed.
+
+> **FFI boundary (updated):** the DKG path is now INSIDE the
+> secret-boundary. The `session_handle: u64` is the only DKG-side
+> field that crosses the FFI for the secret package; the secret
+> package itself is held in `DKG_SESSION_STORAGE` and never
+> serialized back to the host. The non-persistence guarantee is
+> enforced for both the interactive signing path and the interactive
+> DKG path with the same consumption-marker pattern.
## 5. Session model and API contract
diff --git a/pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md b/pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md
index 844ff46d1d..8547659ca5 100644
--- a/pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md
+++ b/pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md
@@ -1,5 +1,53 @@
# Coordinator-shuffle seed derivation (RFC-21 Annex A mirror)
+## Status: HKDF+CSPRNG migration reverted (Decision 2)
+
+The HKDF-SHA256 + CSPRNG coordinator-shuffle migration proposed in the
+PR #4005 review (originally decided as the new normative derivation)
+was attempted and reverted during implementation. The reverted attempt
+is documented here for the audit trail; the rest of this file describes
+the **current** derivation, which is unchanged from the prior
+unification-PR state.
+
+**What happened.** The migration's HKDF pull-in surfaced a
+`digest` crate version conflict between `sha2` (pinned at `0.10` in
+`Cargo.toml`) and the `hkdf` crate that the migration would have
+required, with the resolver landing on a configuration that broke
+downstream `frost-secp256k1-tr` compilation. Decision 2 was reverted
+rather than repinned, because the cost of reshuffling the ciphersuite
+dependency chain for a derivation change that was not security-binding
+on `frost-core` 3.x was disproportionate to the benefit.
+
+**What the current derivation actually is.** The current derivation
+remains the Go-`math/rand` port: `GoMathRandShuffle` in
+`src/go_math_rand.rs`, the 607-element `RNG_COOKED` table, and the
+four-line `roast_attempt_shuffle_seed` derivation in `src/engine/roast.rs`
+described in the "Derivation" and "Conformance vectors" sections
+below. No behavior changed.
+
+**What was kept from the partially-applied migration.** A single
+`COORDINATOR_SHUFFLE_VERSION: u8 = 1` byte (the prior unified state)
+is fed into the attempt-context hash as a version-pin. The version
+byte is intentionally inert at version `1` — it does not alter the
+computed seed or the selected coordinator — but it establishes the
+versioning slot so a future real migration can bump to `2` (or
+higher) without confusing the conformance corpus. Bumping the version
+byte deliberately drops compatibility with the existing test vectors
+and forces a documented re-pinning, which is the whole point of the
+pin.
+
+**Open follow-up.** The HKDF-SHA256 + CSPRNG migration itself is
+tracked as a follow-up to PR #4005. Until that follow-up lands and
+bumps `COORDINATOR_SHUFFLE_VERSION`, the normative derivation is the
+Go-port described below; the "Status" note at the top of this file
+is the load-bearing caveat for any reader cross-referencing the
+PR #4005 review's original Decision 2.
+
+This status note is appended to the top of the file (rather than
+replacing the body) so that pre-revert readers and the
+`coordinator_seed_derivation_matches_cross_language_vectors`
+conformance pinning remain diff-stable.
+
The normative definition of the ROAST coordinator-shuffle seed lives in
keep-core's RFC-21, *Annex A (normative): coordinator-shuffle seed
derivation*
diff --git a/pkg/tbtc/signer/docs/roast-phase-0-spec-freeze.md b/pkg/tbtc/signer/docs/roast-phase-0-spec-freeze.md
index ab082e88b0..1ba0265b24 100644
--- a/pkg/tbtc/signer/docs/roast-phase-0-spec-freeze.md
+++ b/pkg/tbtc/signer/docs/roast-phase-0-spec-freeze.md
@@ -140,6 +140,32 @@ Mapping guidance:
`attempt_id_mismatch`, and `attempt_transition_unauthorized` as non-retriable
for that attempt payload.
+### Addendum: Phase 7 + PR #4005 decisions (append-only)
+
+The codes below are appended to the Phase 0 error taxonomy by
+Phase 7 and by the PR #4005 review decisions. They are listed
+separately from the frozen Phase 0 table above so that the Phase 0
+contract remains diff-stable for any reader still pinning the
+original table. Mapping guidance and the non-retriable guidance
+from the original §8 apply unchanged to the addendum entries.
+
+| Code | Meaning | Decision / phase |
+| --- | --- | --- |
+| `consumed_nonce_replay` | A second `InteractiveRound2` call against a `(session_id, attempt_id, member_identifier)` tuple whose engine-held nonces have already been consumed (signature share released, or consumption marker durably committed). Caller must mint a fresh attempt; the engine will never release a second share under one nonce pair. Stable code in `src/errors.rs`; produced by `EngineError::ConsumedNonceReplay`. | Phase 7 §4 (frozen) |
+| `interactive_attempt_already_aggregated` | `InteractiveAggregate` invoked again for an attempt that already produced an aggregate signature in this session. The per-attempt "aggregated" marker is durable; re-aggregation is rejected rather than recomputed (a lost signature is recovered with a fresh attempt, not by replay). Stable code in `src/errors.rs`; produced by `EngineError::InteractiveAttemptAlreadyAggregated`. Distinct from `consumed_nonce_replay` because the marker is "aggregated", not "nonce consumed". | Phase 7 §5 (frozen) |
+| `wallet_deadline_exceeded` | New terminal error class. The cumulative ROAST attempt budget for the wallet/session has been exceeded across the entire retry chain — the *wallet-level* deadline, not the per-attempt `attempt_exhausted` recoverable cap. Distinct from `attempt_exhausted`, which is recoverable (the caller can mint a new attempt within the cap); `wallet_deadline_exceeded` is terminal for the signing request and the wallet must be re-armed (e.g., via the operator's `persist_distributed_dkg_key_package` reset pathway or an explicit wallet re-arming procedure) before any further attempt is accepted. Surfaced as a structured rejection with the wallet-identifying context. | Decision 8 (PR #4005) |
+| `interactive_rate_limit_exceeded` | Policy-rejection code returned by `InteractiveSessionOpen` when the per-`(sender, key_group)` primary bucket is exhausted. Distinct from the existing `rate_limit_per_minute_exceeded` reason used for `BuildTaprootTx`; this addendum code carries the `(sender, key_group)` tuple in the structured reject payload so the Go host can surface the per-key-group attribution. The cross-operator `(member, key_group)` cap is enforced at the same entry point and surfaces as `interactive_cross_operator_cap_exceeded` (see below). | Decision 7 (PR #4005) |
+| `interactive_round1_rate_limit_exceeded` | Policy-rejection code returned by `InteractiveRound1` when its OWN independent per-`(sender, key_group)` primary bucket is exhausted. This is NOT the cross-operator cap — the cross-operator `(member, key_group)` cap is enforced at `InteractiveSessionOpen` (charged in order: primary bucket, then cross-operator cap) and surfaces as `interactive_cross_operator_cap_exceeded`, not as this code. `InteractiveRound1` has no cross-operator cap of its own. Both buckets are fail-closed and consume the rate-limit decrement before the reject is returned. | Decision 7 (PR #4005) |
+| `interactive_cross_operator_cap_exceeded` | Policy-rejection code returned by `InteractiveSessionOpen` when the per-`(member, key_group)` cross-operator cap is exceeded. The cross-operator cap aggregates across attempts to bound a member's effective work rate on a given wallet even when the member rotates `sender` identifiers or attempt contexts, so the primary per-`(sender, key_group)` bucket alone cannot police it. Charged at `InteractiveSessionOpen` only, never at `InteractiveRound1`. | Decision 7 (PR #4005) |
+| `frost_shadow_mode_advisory` | Audit signal (not an error code emitted as a rejection) emitted when a FROST signing output is gated to advisory-only under the FROST shadow mode (Decision 1). The signal is emitted on every gated output regardless of the final success/failure of the surrounding handshake; downstream observers consume the signal to confirm the shadow mode is active and to attribute the gated output to the caller. Pairs with the `TBTC_SIGNER_FROST_SHADOW_MODE` env var and the three-condition disjunction documented in `roast-phase-5-security-rollout-gates.md`. | Decision 1 (PR #4005) |
+
+`wallet_deadline_exceeded` is **terminal**; the other four Phase 7
+and Decision 7 codes are **recoverable** in the same sense as the
+existing `attempt_exhausted` (the caller may mint a new attempt
+subject to the budget). `frost_shadow_mode_advisory` is an audit
+signal, not a rejection — it is never returned in the response
+status, only emitted on the audit channel.
+
## 9. Replay, Restart, And Concurrency Invariants
1. Attempt id is single-use for a given `(session_id, message, cohort)` flow.
diff --git a/pkg/tbtc/signer/docs/roast-phase-5-security-rollout-gates.md b/pkg/tbtc/signer/docs/roast-phase-5-security-rollout-gates.md
index 451b18853e..adced3cd2f 100644
--- a/pkg/tbtc/signer/docs/roast-phase-5-security-rollout-gates.md
+++ b/pkg/tbtc/signer/docs/roast-phase-5-security-rollout-gates.md
@@ -43,8 +43,20 @@ Required before stage 1 canary:
Recommended stages:
1. Stage 1: 10% signer fleet / limited wallet cohort, hold for 24h.
+ The Stage 1 cohort ships under the `advisory_only` FROST shadow mode
+ (see the FROST Shadow Mode section below) — FROST-derived signatures
+ are gated to advisory output and the legacy path remains the
+ release-able channel. Operators do not change the shadow mode for
+ Stage 1.
2. Stage 2: 50% signer fleet / broader cohort, hold for 24h.
3. Stage 3: 100% rollout after Phase 5 acceptance criteria remain green.
+ Flipping Stage 3 to the `production` FROST shadow mode is gated by
+ the FROST Shadow Mode section's three-condition disjunction (audit
+ report / enforced TEE attestation / keep-core PR #4044); the
+ cohort-percent promotion and the shadow-mode promotion are
+ independent decisions and Stage 3 may roll forward at 100% with
+ `advisory_only` for the cohort-percent leg if the shadow-mode
+ disjunction is not yet satisfied.
The executable promotion gate requires, for each stage, at least 100 successful
samples from Interactive Round1, Interactive Round2, and Interactive Aggregate,
@@ -55,6 +67,85 @@ stage rebuilds its window. Fast failures and idempotent replays are excluded.
Operators can tune the bounded minimum/window and the three per-operation p95
thresholds with the `TBTC_SIGNER_CANARY_*` knobs documented in `README.md`.
+## FROST Shadow Mode
+
+Decision 1 introduced a software-side rollout gate for the FROST signing
+output that is conceptually distinct from the cohort-based canary of
+Gate 3: `FROST_SHADOW_MODE` controls how the engine treats a signing
+output that is FROST-derived rather than the legacy ECDSA-derived
+one. The mode is selected by the operator at process start; it is
+**not** hot-reloaded (env vars are parsed at init only), and the
+existing provenance gate already enforces an operator-supplied
+attestation set on every load.
+
+### Mode values
+
+| Mode | Default? | Behavior | When appropriate |
+| --- | --- | --- | --- |
+| `advisory_only` | yes | FROST signing outputs are produced under their normal handshake, but the resulting signature is **not** released to the host. The engine emits a `frost_shadow_mode_advisory` audit signal (Decision 1) for every gated output and the host continues with the legacy ECDSA path. This is the **fail-closed** default. | All canary cohorts of Gate 3 Stage 1 and Stage 2 (testnet and pre-ECDSA-retirement production rehearsal). |
+| `internal_canary` | no | FROST signing outputs are produced and released but tagged with the `internal_canary` shadow marker for downstream observers; the legacy path is retained as a backstop for the canary window. Capacity-controlled; the operator is expected to keep this mode on a bounded cohort. | Internal testnet and post-audit staging, when a downstream observer exists to consume the `internal_canary` marker. |
+| `production` | no | FROST signing outputs are produced and released without the shadow marker; the legacy path is dropped from the output chain. | Production, only after ALL THREE gate conditions below are met. |
+
+### Gate conditions for `production`
+
+The `production` mode is **only** appropriate when AT LEAST ONE of the
+following holds. (This is a disjunction-of-hard-gates, not a graded
+score; "we have one of the three" is sufficient to flip the mode, but
+operators should still prefer the strongest available justification.)
+
+1. **External audit report.** A completed external audit covering
+ `frost-core` 3.x and the `frost-secp256k1-tr` ciphersuite, as
+ required by the Decision Log entry 1 "external audit = hard gate
+ for ECDSA retirement" clause. The audit report must be attached to
+ the release decision (the Evidence Checklist, item 5).
+2. **Enforced TEE attestation.** TEE enforcement is active, the
+ per-call measurement check passes, and the operator's trust roots
+ ratify the active attestation set (P0-M2 and the future
+ `pkg/tbtc/signer/docs/tee-whitelisted-signer-enforcement-plan.md`).
+ Without TEE enforcement actually running on the cohort, an
+ attestation set alone is not enough.
+3. **keep-core PR #4044 proof-carrying blame.** The proof-carrying
+ blame stack from PR #4044 (the `EquivocationEvidence`
+ instrumentation + retention condition; see Decision Log entry 4)
+ is merged and shipping in the active keep-core build, so an
+ equivocating coordinator is detected and attributable rather than
+ merely observed.
+
+Until at least one of these three holds, `production` MUST NOT be
+selected — the engine will accept the env var but the resulting
+signature carries no FROST-side blame attribution, which is the whole
+point of the migration. The `advisory_only` default is the safe
+starting state for every new operator install.
+
+### Operator procedure for transitioning modes
+
+The mode is selected by the `TBTC_SIGNER_FROST_SHADOW_MODE` env var.
+The recognized values are `advisory_only`, `internal_canary`, and
+`production`; any other value is treated as `advisory_only` (fail
+closed). The env var is read once at signer init; **env vars are not
+hot-reloaded**. To change the mode, an operator:
+
+1. Edits the operator-supplied unit/env file to set
+ `TBTC_SIGNER_FROST_SHADOW_MODE` to the desired mode.
+2. Restarts the signer process (the env var is consumed during init,
+ then frozen for the lifetime of the process).
+3. Verifies the new mode in the engine's startup log line
+ (`frost_shadow_mode_active=…`) — the line is emitted on every
+ protected-operation entry, so an operator can confirm the active
+ mode without dumping the full env. The log line is sanitized via
+ `sanitize_policy_log_field` (no identifier cross-leakage).
+
+### Testnet canary currently ships under `advisory_only`
+
+The testnet canary cohort of Gate 3 Stage 1 currently ships under
+the default `advisory_only` mode. Operators should not change the
+mode for the testnet canary cohort until the audit/TEE blame
+conditions in the table above are met for the testnet cohort
+specifically. Bumping the mode for a canary cohort that does not
+yet have blame attribution defeats the purpose of the canary and
+reintroduces the F1-class bug surface (round-nonce-v3) that the
+shadow mode is designed to guard against.
+
## Cryptographic Dependency Audit Status (Gate 1 Input)
The signer pins `frost-secp256k1-tr = "=3.0.0"` (`Cargo.toml`), the Zcash
From 09f6ff3ecaa3fa3ca9c22bd1a9d6b7d5aac71398 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?=
Date: Wed, 19 Aug 2026 06:39:49 +0000
Subject: [PATCH 2/2] docs(tbtc-signer): correct forward-looking tense and
off-by-one in spec addendum
- roast-coordinator-seed-derivation.md: COORDINATOR_SHUFFLE_VERSION
paragraph was written in present tense implying it's already fed
into the attempt-context hash. It doesn't exist in the current
derivation (0 matches in src/, roast_attempt_shuffle_seed hashes
only key_group||session_id||rfc21_message_digest). Rewritten as an
explicit design note for the eventual migration.
- roast-phase-0-spec-freeze.md: addendum postscript said 'the other
four' recoverable codes; the table lists five (consumed_nonce_replay,
interactive_attempt_already_aggregated, interactive_rate_limit_exceeded,
interactive_cross_operator_cap_exceeded, interactive_round1_rate_limit_exceeded).
---
.../docs/roast-coordinator-seed-derivation.md | 25 +++++++++++--------
.../signer/docs/roast-phase-0-spec-freeze.md | 2 +-
2 files changed, 16 insertions(+), 11 deletions(-)
diff --git a/pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md b/pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md
index 8547659ca5..5db87abf60 100644
--- a/pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md
+++ b/pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md
@@ -25,16 +25,21 @@ four-line `roast_attempt_shuffle_seed` derivation in `src/engine/roast.rs`
described in the "Derivation" and "Conformance vectors" sections
below. No behavior changed.
-**What was kept from the partially-applied migration.** A single
-`COORDINATOR_SHUFFLE_VERSION: u8 = 1` byte (the prior unified state)
-is fed into the attempt-context hash as a version-pin. The version
-byte is intentionally inert at version `1` — it does not alter the
-computed seed or the selected coordinator — but it establishes the
-versioning slot so a future real migration can bump to `2` (or
-higher) without confusing the conformance corpus. Bumping the version
-byte deliberately drops compatibility with the existing test vectors
-and forces a documented re-pinning, which is the whole point of the
-pin.
+**What the reverted migration would have kept.** The design called for
+a single `COORDINATOR_SHUFFLE_VERSION: u8 = 1` byte (the prior unified
+state) to be fed into the attempt-context hash as a version-pin. This
+byte does not exist in the current derivation — `roast_attempt_shuffle_seed`
+in `src/engine/roast.rs` hashes only `key_group || session_id ||
+rfc21_message_digest`, with no version byte. The design intent is that
+the byte would be intentionally inert at version `1` — it would not
+alter the computed seed or the selected coordinator — but would
+establish the versioning slot so a future real migration could bump to
+`2` (or higher) without confusing the conformance corpus. Bumping the
+version byte would deliberately drop compatibility with the existing
+test vectors and force a documented re-pinning, which is the whole
+point of the pin. **None of this is implemented today; it is a design
+note for the eventual HKDF-SHA256 + CSPRNG migration, not a description
+of current behavior.**
**Open follow-up.** The HKDF-SHA256 + CSPRNG migration itself is
tracked as a follow-up to PR #4005. Until that follow-up lands and
diff --git a/pkg/tbtc/signer/docs/roast-phase-0-spec-freeze.md b/pkg/tbtc/signer/docs/roast-phase-0-spec-freeze.md
index 1ba0265b24..748b46c8fd 100644
--- a/pkg/tbtc/signer/docs/roast-phase-0-spec-freeze.md
+++ b/pkg/tbtc/signer/docs/roast-phase-0-spec-freeze.md
@@ -159,7 +159,7 @@ from the original §8 apply unchanged to the addendum entries.
| `interactive_cross_operator_cap_exceeded` | Policy-rejection code returned by `InteractiveSessionOpen` when the per-`(member, key_group)` cross-operator cap is exceeded. The cross-operator cap aggregates across attempts to bound a member's effective work rate on a given wallet even when the member rotates `sender` identifiers or attempt contexts, so the primary per-`(sender, key_group)` bucket alone cannot police it. Charged at `InteractiveSessionOpen` only, never at `InteractiveRound1`. | Decision 7 (PR #4005) |
| `frost_shadow_mode_advisory` | Audit signal (not an error code emitted as a rejection) emitted when a FROST signing output is gated to advisory-only under the FROST shadow mode (Decision 1). The signal is emitted on every gated output regardless of the final success/failure of the surrounding handshake; downstream observers consume the signal to confirm the shadow mode is active and to attribute the gated output to the caller. Pairs with the `TBTC_SIGNER_FROST_SHADOW_MODE` env var and the three-condition disjunction documented in `roast-phase-5-security-rollout-gates.md`. | Decision 1 (PR #4005) |
-`wallet_deadline_exceeded` is **terminal**; the other four Phase 7
+`wallet_deadline_exceeded` is **terminal**; the other five Phase 7
and Decision 7 codes are **recoverable** in the same sense as the
existing `attempt_exhausted` (the caller may mint a new attempt
subject to the budget). `frost_shadow_mode_advisory` is an audit