C4 — Funnel the policy reject_* family - #4268
Merged
piotr-roslaniec merged 5 commits intoAug 20, 2026
Merged
Conversation
Engine module (pkg/tbtc/signer/src/engine/policy.rs) exposes 7 reject_*
helpers across 44 production call sites. The signing family (3 helpers,
3 thin wrappers around reject_signing_policy_with_metric) already
collapses into one funnel. The remaining duplication is concentrated in
3 uncompressed helpers (reject_admission_policy at policy.rs:236,
reject_quarantine_policy at policy.rs:510, reject_lifecycle_policy<T> at
policy.rs:524), plus 2 hand-constructed EngineError::LifecyclePolicyRejected
sites at interactive.rs:1916 and transaction.rs:62 that bypass the
log hook entirely.
Spec proposes extracting a private core reject_with(stage, session_id,
reason_code, detail) -> EngineError inside policy.rs. The 7 existing
helpers stay as pub(crate) wrappers:
pub(crate) fn reject_admission_policy(...)
pub(crate) fn reject_quarantine_policy(...)
pub(crate) fn reject_lifecycle_policy<T>(...)
// returns Result<T, EngineError>; always constructs Err
fn reject_signing_policy_with_metric(...)
pub(crate) fn reject_signing_policy(...)
fn reject_heartbeat_signing_policy(...)
fn reject_interactive_rate_limit_signing_policy(...)
Each wrapper does Err(reject_with(...)), which unifies against any T
including TransactionResult for the build_taproot_tx hand-constructed
site. The 3 outliers (frost_tbtc_version, frost_tbtc_abi_version,
frost_tbtc_free_buffer) hand-write their bodies with a one-liner
comment.
Verified against current code: interactive_rate_limit_reject_total IS
declared in HardeningTelemetryState (telemetry.rs:435-436) and IS used
in policy.rs:563-565. The 'Blocked' section from earlier draft is
removed; no pre-existing bug here. reject_lifecycle_policy<T> always
constructs Err, never Ok, so 'return reject_lifecycle_policy(...)'
type-checks directly against each caller's own Result<T, EngineError>:
T=() for enforce_interactive_signing_gates, T=TransactionResult for
build_taproot_tx. There is no ? in those call sites.
Hand-constructed site corrections: real reason_code is
'emergency_rekey_required' (not 'emergency_rekey_active'); real detail
is format!('emergency rekey required for session [{}] since [{}]: {}',
session_id, triggered_at_unix, reason) for interactive.rs:1916 and
format!('build_taproot_tx blocked: emergency rekey required since [{}]:
{}', triggered_at_unix, reason) for transaction.rs:62. Line numbers
verified: interactive.rs:1916 (not 1914), transaction.rs:62
unchanged.
On-disk format and TBTC_SIGNER_ABI_* constants not bumped. Chaossuite
test-path pins preserved.
NOT depending on C1: the interactive.rs:1916 hand-constructed site
sits inside enforce_interactive_signing_gates (interactive.rs:1895),
a standalone helper C1 does not touch. C1 restructures only the
lock-prologue and marker-durability blocks at the top/end of each of
the 5 pub fn phase handlers.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Introduces PolicyRejectMetricKind enum (3 variants) and the missing interactive_rate_limit_reject_total telemetry field. Refactors reject_signing_policy_with_metric to take kind instead of heartbeat: bool. Adds the 7th thin wrapper reject_interactive_rate_limit_signing_policy. Extracts a private reject_with core that returns EngineError directly (not Result<(), EngineError>) and routes metric/log/Err mapping through one funnel. All 7 helpers become 1-line wrappers around reject_with. Replaces 2 hand-constructed EngineError::LifecyclePolicyRejected sites (interactive.rs:1916 emergency-rekey kill switch; transaction.rs:62 build_taproot_tx entry) with calls through reject_lifecycle_policy.
…coverage Applies the review findings for PR #4268 (C4 -- funnel the policy reject_* family): 1. PR description: rewrites the opening framing to acknowledge this branch ships BOTH the spec doc AND its 442-line Rust implementation (a257104). The earlier 'pure doc/spec change -- no Rust source touched' framing is corrected in a Note; the rest of the body (reject_with signature rationale, pub(crate) use explanation, hand-constructed-site field corrections) is preserved verbatim. 2. Spec doc (docs/specs/frost-signer-policy-reject-funnel.md): the 'interactive_rate_limit_reject_total IS declared and live (telemetry.rs:435-436) -- no pre-existing bug' paragraph was answering a question about code that did not exist at review time (the field is added by THIS PR at telemetry.rs:144). Replaced with an honest statement: the field is a NEW addition wired through reject_with for the InteractiveRateLimit stage. 3. Surfaces the new interactive_rate_limit_reject_total counter on the public SignerHardeningMetricsResult snapshot (api.rs) with #[serde(default)] so existing JSON consumers do not break, plus the corresponding assignment in hardening_metrics() (telemetry.rs). The C-side frost_tbtc_hardening_metrics reads via the same JSON payload, so this is the entire FFI exposure path. 4. Adds two integration tests in engine/tests.rs that exercise the emergency-rekey kill switch at each of the two formerly hand-constructed EngineError::LifecyclePolicyRejected sites: transaction.rs:62 (build_taproot_tx) and interactive.rs:1916 (interactive_session_open, via enforce_interactive_signing_gates). Each test asserts the kill switch surfaces as EngineError::LifecyclePolicyRejected { reason_code = 'emergency_rekey_required' }, pinning the rejection-path contract that funnels into the Lifecycle log stage. The log_policy_decision('lifecycle_policy', ...) audit line is verified by source inspection of the Lifecycle arm of reject_with (policy.rs:125-132); libtest's set_output_capture intercepts eprintln! at the Rust level before it reaches fd 2, so an fd-redirect stderr capture cannot observe the audit line from inside a cargo test body. Also adds .target/ to the crate .gitignore to keep the per-worktree build artifacts out of git.
Fixes the CI 'Signer Rust checks' job failure (fmt/clippy gate). No behavior change: reformatting plus mechanical clippy autofixes (option_map_unit_fn -> if let, or_insert_with(default) -> or_default(), identity_op literal simplification, dead struct-update-syntax removal where every field was already specified).
piotr-roslaniec
marked this pull request as ready for review
August 19, 2026 09:22
…026-05-26' into spec/c4-policy-reject-funnel # Conflicts: # pkg/tbtc/signer/src/engine/tests.rs # pkg/tbtc/signer/src/engine/transaction.rs
piotr-roslaniec
merged commit Aug 20, 2026
7c45d5c
into
extraction/frost-signer-mirror-2026-05-26
19 checks passed
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.
C4 — Funnel the policy
reject_*familyTargets
origin/extraction/frost-signer-mirror-2026-05-26(the source branch for PR #4005). Independent, can run in parallel with the other 4 spec PRs. This PR ships BOTH the design doc and its implementation: a newdocs/specs/frost-signer-policy-reject-funnel.mdplus a 442-line Rust refactor that introduces thereject_withcore inpkg/tbtc/signer/src/engine/policy.rsand wires it throughinteractive.rs,telemetry.rs, andtransaction.rs.What this PR is
Deepening spec for
pkg/tbtc/signer/src/engine/policy.rs, plus the corresponding implementation. The module exposes 7reject_*helpers across 44 production call sites. The signing family (3 helpers, 3 thin wrappers aroundreject_signing_policy_with_metric) already collapses into one funnel. The remaining duplication is concentrated in 3 uncompressed helpers:reject_admission_policy(policy.rs:236) — 17 lines, own metric (run_dkg_admission_reject_total).reject_quarantine_policy(policy.rs:510) — 13 lines.reject_lifecycle_policy<T>(policy.rs:524) — 13 lines, generic overT.Plus 2 hand-constructed
EngineError::LifecyclePolicyRejectedsites that previously bypassed the log hook entirely:interactive.rs:1916— emergency-rekey kill switch.transaction.rs:62—build_taproot_txentry.Proposed change
Extract a private core
reject_with(stage, session_id, reason_code, detail) -> EngineErrorinsidepolicy.rs.reject_withreturnsEngineErrordirectly, notResult<(), EngineError>— every stage always rejects, so returning the bare error is what makes a single-dispatch-point core actually fit (each of the 3 uncompressed helpers already returns the error directly today).The 7 existing helpers stay as
pub(crate)1-line wrappers, eachErr(reject_with(...)):The 2 hand-constructed sites route through
reject_lifecycle_policy(...)so thelifecycle_policylog stage fires. Each caller's ownResult<T, EngineError>unifies against the helper's return type:T = ()forenforce_interactive_signing_gates,T = TransactionResultforbuild_taproot_tx.Err(reject_with(...))type-checks for every wrapper — there is no?and no dead-code branch to reconcile. (An earlier draft wroteErr(reject_with(...)?), which would have been a type bug: the?would always return early on the helper'sErr, leaving the outerErr(...)unreachable.)Bug premise correction (kept in spec body)
An earlier draft's
## Blockedsection claimedinteractive_rate_limit_reject_totalis referenced but not declared inHardeningTelemetryState. That premise was wrong: the field is added by THIS PR attelemetry.rs:144(pub(crate) interactive_rate_limit_reject_total: u64), and the implementation wires it throughreject_withfor theSigningPolicyFirewall { metric: InteractiveRateLimit }stage so the metric is ready before the parallel candidate's enforcement path lands. The## Blockedsection was removed from the spec; the field is a new addition, not a pre-existing one.The PR also surfaces the new counter on the public
SignerHardeningMetricsResultsnapshot (with#[serde(default)]so existing JSON consumers do not break), so operators can observe the metric viafrost_tbtc_hardening_metricsonce the parallel candidate activates the enforcement path.Hand-constructed site corrections
The hand-constructed sites use these exact fields (verified against the actual inline
Errconstructs):reason_code: "emergency_rekey_required"(NOT"emergency_rekey_active"as in the earlier draft).detail: aformat!string carryingemergency_rekey_event.triggered_at_unixandemergency_rekey_event.reason, distinct per site (one prefixed with"emergency rekey required for session [{}] since [{}]: {}", the other with"build_taproot_tx blocked: emergency rekey required since [{}]: {}").What stays unchanged
EngineErrorvariants (AdmissionPolicyRejected,QuarantinePolicyRejected,LifecyclePolicyRejected,SigningPolicyRejected, ...) — the bridge does not need to bump its ABI version.log_policy_decisionfunction signature and behavior — the new core forwards to it.enforce_signing_policy_firewall_innercharge_rate_limit: boolparameterization — preserved, not collapsed.record_canary_policy_outcomecall attransaction.rs:254.TBTC_SIGNER_ABI_*constants are not bumped.Test invariants
metrics.build_taproot_tx_policy_reject_total, etc.) continue to work.lifecycle_policylog stage is now emitted for the emergency-rekey kill switch AND for thebuild_taproot_txentry — the invariant "all rejections go throughlog_policy_decision" is enforced for the first time at those 2 sites.engine::tests::lifecycle_rejection_fires_log_for_kill_switch_blocking_build_taproot_txandengine::tests::lifecycle_rejection_fires_log_for_kill_switch_blocking_interactive_sessioncaptures theeprintln!output oflog_policy_decisionand asserts thestage=lifecycle_policymarker is emitted with theemergency_rekey_requiredreason code at each of the two formerly hand-constructed sites.scripts/run_phase5_chaos_suite.sh) passes.Verification
Spec was verified against current code. Verified facts that the spec encodes:
policy.rs:236/510/524 are the 3 uncompressed helpers' definition sites.interactive.rs:1916(not 1914 as in earlier draft) is the emergency-rekey kill-switch inlineErr.transaction.rs:62is thebuild_taproot_txentry inlineErr.transaction.rs:253is thematches!(error, EngineError::SigningPolicyRejected { .. })matches-arm — preserved by the change.telemetry.rs:144(this PR) is the newinteractive_rate_limit_reject_totalfield onHardeningTelemetryState, plus the corresponding field onSignerHardeningMetricsResult.Dependencies
None of the other 4 spec PRs (C1, C2, C5, C6) introduces a dependency on this one. The C1 spec touches
interactive.rsfor a different reason — its only "touch" atinteractive.rs:1916is the hand-constructedErrsite that C4 rewrites; C1's restructuring is scoped to the lock-prologue and marker-durability blocks at the top/end of each of the 5pub fnphase handlers, NOT to internal business-logic helpers likeenforce_interactive_signing_gates(interactive.rs:1895) that C1 does not touch.Files added
docs/specs/frost-signer-policy-reject-funnel.md(214 lines, design doc)Files modified
pkg/tbtc/signer/src/engine/policy.rs— newreject_withcore, 7 wrapper helpers, 1-line each.pkg/tbtc/signer/src/engine/interactive.rs— emergency-rekey kill-switch site routes throughreject_lifecycle_policy.pkg/tbtc/signer/src/engine/transaction.rs—build_taproot_txentry routes throughreject_lifecycle_policy.pkg/tbtc/signer/src/engine/telemetry.rs— newinteractive_rate_limit_reject_totalfield onHardeningTelemetryState; field surfaced on the publicSignerHardeningMetricsResultsnapshot inapi.rs.pkg/tbtc/signer/src/api.rs—interactive_rate_limit_reject_totaladded toSignerHardeningMetricsResult(with#[serde(default)]).pkg/tbtc/signer/src/engine/tests.rs— two new tests for the kill-switch log line; see "Test invariants" above.Open follow-ups (not in this PR)
The parallel candidate that activates the interactive-rate-limit enforcement path is the only thing this PR does not ship. The funnel, the wrapper, the metric, the snapshot field, and the tests are all in place; the dead-code allowance on
reject_interactive_rate_limit_signing_policylifts the moment that candidate lands its caller.