Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 71 additions & 32 deletions docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -307,44 +307,83 @@ seed bytes must produce the same legacy `int64` on every honest
signer. The bridge is named, isolated, and exhaustively tested so
later edits cannot accidentally desynchronise it.

The exclusion policy is:

. Senders with `OverflowCount >= overflowExclusionThreshold` during the
attempt window are moved to `ExcludedSet` (transport blamable).
. Senders with at least one confirmed reject event for non-transport
reasons are moved to `ExcludedSet` (validation blamable).
The exclusion policy (verifiable-blame revision) starts from one
observation: every evidence entry in the bundle is an observer-signed
*claim*, not a self-incriminating proof. Nothing in an overflow,
reject, or conflict counter lets a third party re-check that the
accused actually misbehaved. A policy that permanently excludes on
unverifiable counters inverts ROAST's robustness guarantee -- a single
byzantine observer could fabricate evidence against honest members
and grind the `IncludedSet` toward `ErrAttemptInfeasible`. Permanent
exclusion therefore requires the accusation to be *established*:
corroborated by a quorum of distinct accusers large enough that, under
the protocol's own t-of-n honesty assumption, at least one accuser is
honest.

. Accusation tallying: each bundle snapshot is one observer's claim
set. Only observers in the previous attempt's `IncludedSet` are
credible; each observer counts at most once per accused member per
category regardless of the claimed count magnitude; accusations
against members outside the original signer set are ignored. An
accusation is established when at least
`ExclusionAccuserQuorum(originalGroupSize, threshold)
= originalGroupSize - threshold + 1` (that is, `f+1`) distinct
observers make it. A real fault is observed by every honest member
processing the broadcast, so established faults reach the quorum
naturally; fabricated ones cannot, because at most `f` members are
byzantine.
. Established reject or conflict accusations (validation /
equivocation blamable) move the accused to `ExcludedSet`
permanently. The categories are tallied independently and never
summed with each other.
. Established overflow accusations (transport blamable) move the
accused to the *parked* set for one attempt -- never to
`ExcludedSet`. Transport pressure is observable only at the
transport layer and can never be made self-incriminating, so
overflow may cost an attempt of liveness but not permanence.
. Senders with deadline-expiry only -- silent peers -- are moved to a
*parked* set that the next attempt skips but the attempt after that
retries (to tolerate transient outages). Silence parking is
*strictly transient*: a single attempt's worth of skip, no escalation.
A peer falsely labelled silent because their contribution arrived
late (or because a malicious coordinator censored it) is not
permanently penalised -- they are reinstated by the very next
attempt. Permanent exclusion only follows from overflow or non-
transport reject events, neither of which can fire on a slow-but-
honest peer.
retries (to tolerate transient outages). Parking is *strictly
transient*: a single attempt's worth of skip, no escalation. A peer
falsely labelled silent because their contribution arrived late (or
because a malicious coordinator censored it) is not permanently
penalised -- they are reinstated by the very next attempt.
Silence is detected as *bundle absence* (the member submitted no
evidence snapshot for the transition). A member that submits its
snapshot while withholding its signing contribution is therefore
not parked by this policy: signing-silence is invisible to the
transition layer, costs the attempt, and is bounded only by the
retry budget (Annex B) until t-of-included finalize lands.
. If `IncludedSet` minus exclusions drops below the threshold `t`, the
coordinator returns `ErrAttemptInfeasible` and the session is
declared failed for this signer set.

The thresholds are *fixed constants* in the initial design, picked to
be evidently small relative to the per-attempt deadline and the
`expectedMessagesCount*4+1` channel capacity:

[source,go]
----
const (
overflowExclusionThreshold = 4 // overflow events per attempt window
rejectExclusionThreshold = 1 // any confirmed non-transport reject
silenceParkingThreshold = 1 // any deadline expiry parks for 1 attempt
)
----

Making them constants up-front means honest signers do not need to
negotiate them. If production telemetry indicates a constant is wrong
for the attempt's wall-clock bound, the change is a routine code
update that ships through Phase 7's manifest gate -- not a runtime
parameter that drift can desynchronise.
The quorum is a *derived constant* of the key-group shape -- for the
production 51-of-100 group it is 50 -- so honest signers do not need
to negotiate it and drift cannot desynchronise it. Sub-quorum claims
are deliberately ignored rather than parked: acting on a single
unverifiable claim would let one byzantine observer cost any honest
member an attempt of liveness at will.

*Verifiability roadmap.* Permanent exclusion on a *single* piece of
evidence becomes sound once the wire format carries
self-incriminating proof: for conflicts, the accused's own two
operator-signed payloads with identical (attempt, sender) and
different bytes; for rejects, the accused's operator-signed
contribution plus a deterministic validation failure any member can
re-run. When those land, the per-category quorum gate can be relaxed
to proof-verified entries. The cost of the interim quorum policy is
bounded: a fault observed by fewer than `f+1` honest members (for
example a targeted, per-recipient equivocation) is not permanently
excluded and instead burns retry attempts, which the serial-attempt
latency analysis already budgets for. Near the assumption boundary
the gate is intentionally demanding -- at worst-case `f` only `t`
honest observers exist, so establishment needs all but `2t-n-1` of
them (50 of 51 at the production shape) to have observed the fault
and landed snapshots in the bundle. In that regime the quorum acts
as a fabrication firewall rather than a working exclusion mechanism;
restoring per-category exclusion under heavy attack is exactly what
proof-carrying blame is for.

=== Layer C: Retry orchestration (M7)

Expand Down
18 changes: 13 additions & 5 deletions pkg/frost/roast/multi_coordinator_soak_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,13 +265,15 @@ func TestSoak_CleanAttemptPreservesIncludedSet(t *testing.T) {
}
}

func TestSoak_OverflowEvidenceExcludesPermanently(t *testing.T) {
func TestSoak_EstablishedOverflowParksTransiently(t *testing.T) {
members := []group.MemberIndex{1, 2, 3, 4, 5}
nodes := newSoakHarness(t, members)
prev := soakStartingContext(t, members)

// Four observers report 1 overflow each against member 3.
// Total 4 = OverflowExclusionThreshold.
// Four distinct observers report overflow against member 3:
// 4 >= ExclusionAccuserQuorum(5, 3) = 3, so the accusation is
// established -- but transport blame is unverifiable in
// principle, so it parks transiently instead of excluding.
overflow := map[group.MemberIndex][]group.MemberIndex{
1: {3},
2: {3},
Expand All @@ -280,8 +282,14 @@ func TestSoak_OverflowEvidenceExcludesPermanently(t *testing.T) {
}
next, _ := soakAttempt(t, nodes, prev, nil, overflow, 3)

if !containsMember(next.ExcludedSet, 3) {
t.Fatalf("member 3 must be excluded; got %v", next.ExcludedSet)
if !containsMember(next.TransientlyParked, 3) {
t.Fatalf("member 3 must be parked; got %v", next.TransientlyParked)
}
if containsMember(next.ExcludedSet, 3) {
t.Fatalf(
"overflow must never permanently exclude; got %v",
next.ExcludedSet,
)
}
if containsMember(next.IncludedSet, 3) {
t.Fatal("member 3 must not be in next IncludedSet")
Expand Down
Loading
Loading