Skip to content

Commit c003f65

Browse files
committed
fix(review): report unsigned gossip admissions as their own outcome and metric (#325)
An unsigned event that survives the rolling-upgrade window was returned as IngestOutcome::Accepted, so `gitlawb_gossip_ingest_events_total{outcome="accepted"}` mixed signature-verified admissions with unauthenticated legacy traffic. An operator could not tell whether the fleet still relied on the compatibility allowance or authenticated gossip was healthy. Add IngestOutcome::UnsignedAdmitted with its own `unsigned_admitted` metric label, returned when an unsigned event passes every gate and the writes land. `Accepted` is now reserved for signature-verified events. Propagate through the swarm-loop logging and the tests that asserted `Accepted` for unsigned admissions. Both guards proven load-bearing by execution: reverting the return-site change goes RED on flag_off_unsigned_known_peer_event_is_accepted ("got Accepted"), and giving UnsignedAdmitted the "accepted" label goes RED on every_ingest_outcome_carries_a_distinct_metric_label.
1 parent c571953 commit c003f65

2 files changed

Lines changed: 38 additions & 12 deletions

File tree

‎crates/gitlawb-node/src/metrics.rs‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,8 +289,14 @@ pub fn record_webhook_delivery(result: &str) {
289289
}
290290

291291
/// Record what the gossip ingest path decided about one inbound ref-update.
292-
/// `outcome` ∈ {accepted, write_failed, rejected, source_rate_limited,
293-
/// author_rate_limited, unsigned_source_rate_limited}.
292+
/// `outcome` ∈ {accepted, unsigned_admitted, write_failed, rejected,
293+
/// source_rate_limited, author_rate_limited, unsigned_source_rate_limited}.
294+
///
295+
/// `accepted` is reserved for signature-verified events. An unsigned event that
296+
/// survives the rolling-upgrade window is `unsigned_admitted`, so the
297+
/// authenticated-admission rate is not silently padded by legacy compatibility
298+
/// traffic; an operator can tell whether the fleet still relies on the
299+
/// compatibility allowance.
294300
///
295301
/// The three shed reasons stay separate labels rather than one `rate_limited`
296302
/// because they answer different operator questions: `source_rate_limited` is a

‎crates/gitlawb-node/src/p2p/mod.rs‎

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -427,10 +427,19 @@ fn verify_ref_update(event: &RefUpdateEvent) -> Result<(), String> {
427427
pub(crate) enum IngestOutcome {
428428
/// The event was authenticated AND every write it implies landed.
429429
Accepted,
430+
/// The event was admitted WITHOUT authentication, through the rolling-upgrade
431+
/// window, and every write it implies landed.
432+
///
433+
/// Deliberately a distinct outcome from `Accepted`: a valid signature proves
434+
/// who the sender is, an unsigned event proves nothing, and counting the two
435+
/// under one label would make the fleet's authenticated-admission rate
436+
/// indistinguishable from its reliance on the legacy compatibility path.
437+
UnsignedAdmitted,
430438
/// The event passed every guard, but a durable write failed. The decision
431439
/// was still "admit it", so this is not a refusal; it exists because
432-
/// returning `Accepted` for an event whose row never landed would make the
433-
/// outcome an observability lie.
440+
/// returning an admission outcome (`Accepted` or `UnsignedAdmitted`) for an
441+
/// event whose row never landed would make the outcome an observability
442+
/// lie.
434443
WriteFailed(String),
435444
/// The event was dropped. Nothing is stored, so the reason exists only to
436445
/// be logged and counted.
@@ -468,6 +477,7 @@ impl IngestOutcome {
468477
fn metric_label(&self) -> &'static str {
469478
match self {
470479
IngestOutcome::Accepted => "accepted",
480+
IngestOutcome::UnsignedAdmitted => "unsigned_admitted",
471481
IngestOutcome::WriteFailed(_) => "write_failed",
472482
IngestOutcome::Rejected(_) => "rejected",
473483
IngestOutcome::SourceRateLimited => "source_rate_limited",
@@ -747,6 +757,7 @@ pub(crate) async fn ingest_ref_update(
747757
}
748758
match write_error {
749759
Some(reason) => IngestOutcome::WriteFailed(reason),
760+
None if unsigned => IngestOutcome::UnsignedAdmitted,
750761
None => IngestOutcome::Accepted,
751762
}
752763
}
@@ -1030,6 +1041,7 @@ pub async fn start(
10301041
crate::metrics::record_gossip_ingest(outcome.metric_label());
10311042
match outcome {
10321043
IngestOutcome::Accepted => {}
1044+
IngestOutcome::UnsignedAdmitted => {}
10331045
IngestOutcome::WriteFailed(reason) => warn!(
10341046
from = %propagation_source,
10351047
reason = %reason,
@@ -1803,6 +1815,9 @@ mod tests {
18031815
match outcome {
18041816
IngestOutcome::Rejected(reason) => reason,
18051817
IngestOutcome::Accepted => panic!("{context}: the event must be rejected"),
1818+
IngestOutcome::UnsignedAdmitted => {
1819+
panic!("{context}: the event must be rejected, not admitted unsigned")
1820+
}
18061821
IngestOutcome::WriteFailed(reason) => {
18071822
panic!("{context}: the event must be rejected by a guard, not admitted and then failed to write: {reason}")
18081823
}
@@ -2094,10 +2109,13 @@ mod tests {
20942109
}
20952110

20962111
/// R7, the rolling-upgrade window: with enforcement off, an unsigned event
2097-
/// from a KNOWN peer is still accepted. Turning the flag on is the
2112+
/// from a KNOWN peer is still admitted. Turning the flag on is the
20982113
/// operator's step, not a code change, so this path has to keep working
20992114
/// until they take it.
21002115
///
2116+
/// It is `UnsignedAdmitted`, not `Accepted`: the event wrote rows without
2117+
/// authenticating its sender, and the two must not be observably the same.
2118+
///
21012119
/// The ingest path also emits a `warn!` pointing at the flag on this
21022120
/// branch. Nothing here asserts that, so the log line is uncovered:
21032121
/// deleting it leaves this test green. Say so rather than implying the
@@ -2113,8 +2131,8 @@ mod tests {
21132131
let outcome =
21142132
ingest_with_fresh_limiter(&db, false, true, &bytes_of(&event), &PeerId::random()).await;
21152133
assert!(
2116-
matches!(outcome, IngestOutcome::Accepted),
2117-
"an unsigned known-peer event must survive the rolling-upgrade window, got {outcome:?}"
2134+
matches!(outcome, IngestOutcome::UnsignedAdmitted),
2135+
"an unsigned known-peer event must be admitted (distinct from Accepted) through the rolling-upgrade window, got {outcome:?}"
21182136
);
21192137
assert_eq!(count(&pool, "received_ref_updates").await, 1);
21202138
assert_eq!(count(&pool, "sync_queue").await, 1);
@@ -2393,6 +2411,7 @@ mod tests {
23932411
fn every_ingest_outcome_carries_a_distinct_metric_label() {
23942412
let labels = [
23952413
IngestOutcome::Accepted.metric_label(),
2414+
IngestOutcome::UnsignedAdmitted.metric_label(),
23962415
IngestOutcome::WriteFailed("db down".into()).metric_label(),
23972416
IngestOutcome::Rejected("malformed".into()).metric_label(),
23982417
IngestOutcome::SourceRateLimited.metric_label(),
@@ -2403,6 +2422,7 @@ mod tests {
24032422
labels,
24042423
[
24052424
"accepted",
2425+
"unsigned_admitted",
24062426
"write_failed",
24072427
"rejected",
24082428
"source_rate_limited",
@@ -2629,7 +2649,7 @@ mod tests {
26292649
let outcome =
26302650
ingest_ref_update(&db, &limiters, false, true, &claim, &attacker_edge).await;
26312651
assert!(
2632-
matches!(outcome, IngestOutcome::Accepted),
2652+
matches!(outcome, IngestOutcome::UnsignedAdmitted),
26332653
"unsigned event {i} is inside every budget and is admitted in the rolling-upgrade window, got {outcome:?}"
26342654
);
26352655
}
@@ -2671,7 +2691,7 @@ mod tests {
26712691
for i in 0..GOSSIP_UNSIGNED_SOURCE_MAX_EVENTS {
26722692
let outcome = ingest_ref_update(db, limiters, false, true, claim, &source).await;
26732693
assert!(
2674-
matches!(outcome, IngestOutcome::Accepted),
2694+
matches!(outcome, IngestOutcome::UnsignedAdmitted),
26752695
"unsigned event {i} is inside the unsigned budget and must be admitted, got {outcome:?}"
26762696
);
26772697
}
@@ -2766,7 +2786,7 @@ mod tests {
27662786
let fresh_source = PeerId::random();
27672787
let outcome = ingest_ref_update(&db, &limiters, false, true, &claim, &fresh_source).await;
27682788
assert!(
2769-
matches!(outcome, IngestOutcome::Accepted),
2789+
matches!(outcome, IngestOutcome::UnsignedAdmitted),
27702790
"the claimed DID must not carry a spent budget between forwarders, got {outcome:?}"
27712791
);
27722792
assert_eq!(
@@ -2899,7 +2919,7 @@ mod tests {
28992919
let outcome =
29002920
ingest_ref_update(&db, &limiters, false, true, &valid_unsigned, &unsigned_edge).await;
29012921
assert!(
2902-
matches!(outcome, IngestOutcome::Accepted),
2922+
matches!(outcome, IngestOutcome::UnsignedAdmitted),
29032923
"the forwarder's unsigned budget must be untouched by malformed events, got {outcome:?}"
29042924
);
29052925
assert_eq!(
@@ -3035,7 +3055,7 @@ mod tests {
30353055
let outcome =
30363056
ingest_with_fresh_limiter(&db, false, true, &claim, &PeerId::random()).await;
30373057
assert!(
3038-
matches!(outcome, IngestOutcome::Accepted),
3058+
matches!(outcome, IngestOutcome::UnsignedAdmitted),
30393059
"B outcome {outcome:?}"
30403060
);
30413061
assert!(logs.saw_warn(), "B must warn, got: {}", logs.text());

0 commit comments

Comments
 (0)