Skip to content

C4 — Funnel the policy reject_* family - #4268

Merged
piotr-roslaniec merged 5 commits into
extraction/frost-signer-mirror-2026-05-26from
spec/c4-policy-reject-funnel
Aug 20, 2026
Merged

C4 — Funnel the policy reject_* family#4268
piotr-roslaniec merged 5 commits into
extraction/frost-signer-mirror-2026-05-26from
spec/c4-policy-reject-funnel

Conversation

@piotr-roslaniec

@piotr-roslaniec piotr-roslaniec commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

C4 — Funnel the policy reject_* family

Targets 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 new docs/specs/frost-signer-policy-reject-funnel.md plus a 442-line Rust refactor that introduces the reject_with core in pkg/tbtc/signer/src/engine/policy.rs and wires it through interactive.rs, telemetry.rs, and transaction.rs.

Note: an earlier draft of the PR description labeled this a "pure doc/spec change — no Rust source touched". That was incorrect: this branch contains two commits, the second of which (a25710457 feat(frost-signer): funnel the policy reject_* family) is a real implementation change. The framing has been corrected; the rest of the substance below is unchanged.

What this PR is

Deepening spec for pkg/tbtc/signer/src/engine/policy.rs, plus the corresponding implementation. The module 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 (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 over T.

Plus 2 hand-constructed EngineError::LifecyclePolicyRejected sites that previously bypassed the log hook entirely:

  • interactive.rs:1916 — emergency-rekey kill switch.
  • transaction.rs:62build_taproot_tx entry.

Proposed change

Extract a private core reject_with(stage, session_id, reason_code, detail) -> EngineError inside policy.rs. reject_with returns EngineError directly, not Result<(), 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, each Err(reject_with(...)):

pub(crate) fn reject_admission_policy(...)
pub(crate) fn reject_quarantine_policy(...)
pub(crate) fn reject_lifecycle_policy<T>(...)        // unifies against any T
fn reject_signing_policy_with_metric(...)
pub(crate) fn reject_signing_policy(...)
fn reject_heartbeat_signing_policy(...)
fn reject_interactive_rate_limit_signing_policy(...)

The 2 hand-constructed sites route through reject_lifecycle_policy(...) so the lifecycle_policy log stage fires. Each caller's own Result<T, EngineError> unifies against the helper's return type: T = () for enforce_interactive_signing_gates, T = TransactionResult for build_taproot_tx.

Err(reject_with(...)) type-checks for every wrapper — there is no ? and no dead-code branch to reconcile. (An earlier draft wrote Err(reject_with(...)?), which would have been a type bug: the ? would always return early on the helper's Err, leaving the outer Err(...) unreachable.)

Bug premise correction (kept in spec body)

An earlier draft's ## Blocked section claimed interactive_rate_limit_reject_total is referenced but not declared in HardeningTelemetryState. That premise was wrong: the field is added by THIS PR at telemetry.rs:144 (pub(crate) interactive_rate_limit_reject_total: u64), and the implementation wires it through reject_with for the SigningPolicyFirewall { metric: InteractiveRateLimit } stage so the metric is ready before the parallel candidate's enforcement path lands. The ## Blocked section 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 SignerHardeningMetricsResult snapshot (with #[serde(default)] so existing JSON consumers do not break), so operators can observe the metric via frost_tbtc_hardening_metrics once the parallel candidate activates the enforcement path.

Hand-constructed site corrections

The hand-constructed sites use these exact fields (verified against the actual inline Err constructs):

  • reason_code: "emergency_rekey_required" (NOT "emergency_rekey_active" as in the earlier draft).
  • detail: a format! string carrying emergency_rekey_event.triggered_at_unix and emergency_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

  • The public EngineError variants (AdmissionPolicyRejected, QuarantinePolicyRejected, LifecyclePolicyRejected, SigningPolicyRejected, ...) — the bridge does not need to bump its ABI version.
  • The log_policy_decision function signature and behavior — the new core forwards to it.
  • The enforce_signing_policy_firewall_inner charge_rate_limit: bool parameterization — preserved, not collapsed.
  • The record_canary_policy_outcome call at transaction.rs:254.
  • The TBTC_SIGNER_ABI_* constants are not bumped.

Test invariants

  • The 7 helper names are preserved, so the existing test assertions (metrics.build_taproot_tx_policy_reject_total, etc.) continue to work.
  • The lifecycle_policy log stage is now emitted for the emergency-rekey kill switch AND for the build_taproot_tx entry — the invariant "all rejections go through log_policy_decision" is enforced for the first time at those 2 sites.
  • New coverage in engine::tests::lifecycle_rejection_fires_log_for_kill_switch_blocking_build_taproot_tx and engine::tests::lifecycle_rejection_fires_log_for_kill_switch_blocking_interactive_session captures the eprintln! output of log_policy_decision and asserts the stage=lifecycle_policy marker is emitted with the emergency_rekey_required reason code at each of the two formerly hand-constructed sites.
  • The chaos suite (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 inline Err.
  • transaction.rs:62 is the build_taproot_tx entry inline Err.
  • transaction.rs:253 is the matches!(error, EngineError::SigningPolicyRejected { .. }) matches-arm — preserved by the change.
  • telemetry.rs:144 (this PR) is the new interactive_rate_limit_reject_total field on HardeningTelemetryState, plus the corresponding field on SignerHardeningMetricsResult.

Dependencies

None of the other 4 spec PRs (C1, C2, C5, C6) introduces a dependency on this one. The C1 spec touches interactive.rs for a different reason — its only "touch" at interactive.rs:1916 is the hand-constructed Err site that C4 rewrites; C1's restructuring is scoped to the lock-prologue and marker-durability blocks at the top/end of each of the 5 pub fn phase handlers, NOT to internal business-logic helpers like enforce_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 — new reject_with core, 7 wrapper helpers, 1-line each.
  • pkg/tbtc/signer/src/engine/interactive.rs — emergency-rekey kill-switch site routes through reject_lifecycle_policy.
  • pkg/tbtc/signer/src/engine/transaction.rsbuild_taproot_tx entry routes through reject_lifecycle_policy.
  • pkg/tbtc/signer/src/engine/telemetry.rs — new interactive_rate_limit_reject_total field on HardeningTelemetryState; field surfaced on the public SignerHardeningMetricsResult snapshot in api.rs.
  • pkg/tbtc/signer/src/api.rsinteractive_rate_limit_reject_total added to SignerHardeningMetricsResult (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_policy lifts the moment that candidate lands its caller.

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.
@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0a663d8d-393f-46ed-a769-7b2a8c9f1aca

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

piotr-roslaniec and others added 3 commits August 18, 2026 16:12
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
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
piotr-roslaniec merged commit 7c45d5c into extraction/frost-signer-mirror-2026-05-26 Aug 20, 2026
19 checks passed
@piotr-roslaniec
piotr-roslaniec deleted the spec/c4-policy-reject-funnel branch August 20, 2026 09:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant