diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs
index 217d635..c27eec7 100644
--- a/crates/app/src/source.rs
+++ b/crates/app/src/source.rs
@@ -511,6 +511,11 @@ pub fn spawn_registry_bridge(
let mut attached: HashSet = HashSet::new();
let mut ticker = tokio::time::interval(REGISTRY_POLL_INTERVAL);
loop {
+ // Prune ids that left the registry: their per-event tasks exit on their own (they
+ // watch for the event's disappearance), and dropping them here keeps this set from
+ // growing forever across create/delete cycles.
+ let live: HashSet = registry.list().into_iter().map(|m| m.id).collect();
+ attached.retain(|id| live.contains(id));
for meta in registry.list() {
if attached.contains(&meta.id) {
continue;
@@ -601,6 +606,12 @@ pub(crate) async fn run_bridge(
loop {
ticker.tick().await;
+ // A DELETED event ends its bridge: the AppState Arc outlives the registry entry, so
+ // without this check the orphaned task would poll a dead log forever.
+ if registry.resolve(&event_id).is_none() {
+ return;
+ }
+
// Reap a finished/cancelled source task so a heat that ran to the end clears the
// slot (without it, a re-Start of the same heat would be ignored). A heat with an armed
// RotorHazard connection is NOT reaped on the Mock tasks finishing — the live connection
@@ -699,6 +710,8 @@ pub fn spawn_presence_reconciler(registry: EventRegistry) -> JoinHandle<()> {
let mut attached: HashSet = HashSet::new();
let mut ticker = tokio::time::interval(REGISTRY_POLL_INTERVAL);
loop {
+ let live: HashSet = registry.list().into_iter().map(|m| m.id).collect();
+ attached.retain(|id| live.contains(id));
for meta in registry.list() {
if attached.contains(&meta.id) {
continue;
@@ -734,6 +747,10 @@ pub(crate) async fn run_presence_reconciler(
let mut ticker = tokio::time::interval(POLL_INTERVAL);
loop {
ticker.tick().await;
+ // A DELETED event ends its reconciler (the log Arc alone would keep it alive forever).
+ if registry.resolve(&event_id).is_none() {
+ return;
+ }
let new_events = match read_tail(&state, cursor) {
Ok(batch) => batch,
// A poisoned lock (or a dropped log at shutdown) ends the reconciler cleanly.
@@ -748,6 +765,10 @@ pub(crate) async fn run_presence_reconciler(
Ok(events) => registrations(events.iter()),
Err(_) => continue,
};
+ // Dedup WITHIN the batch: `bindings` was folded once before the loop, so two
+ // CompetitorSeen for the same key in one poll batch both read "unbound" and appended
+ // twice (benign — same key/value, last-wins fold — but noise in the log).
+ let mut seen_this_batch: HashSet<(AdapterId, CompetitorRef)> = HashSet::new();
for (offset, event) in new_events {
cursor = offset + 1;
if let Event::CompetitorSeen {
@@ -755,6 +776,9 @@ pub(crate) async fn run_presence_reconciler(
competitor,
} = event
{
+ if !seen_this_batch.insert((adapter.clone(), competitor.clone())) {
+ continue;
+ }
reconcile_seen(
&state, ®istry, &pilots, &event_id, &bindings, adapter, competitor,
);
diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs
index 8f25454..b2ab139 100644
--- a/crates/server/src/app.rs
+++ b/crates/server/src/app.rs
@@ -1710,9 +1710,13 @@ pub(crate) fn class_window_offsets(events: &[Event], class: &ClassId) -> Vec<(u6
}
}
// A tagged pass belongs to its stamped heat — in or out by class membership,
- // independent of the positional cursor (same rule as `heat_window_offsets`).
+ // independent of the positional cursor (same rule as `heat_window_offsets`),
+ // and frozen out once that heat's run is official (the Final freeze).
Event::Pass(p) if p.heat.is_some() => {
- if p.heat.as_ref().is_some_and(|h| class_heats.contains(h)) {
+ if p.heat.as_ref().is_some_and(|h| {
+ class_heats.contains(h)
+ && offset < crate::live_state::current_run_pass_ceiling(events, h) as u64
+ }) {
window.push((offset, event.clone()));
}
}
@@ -1921,6 +1925,7 @@ fn now_micros() -> i64 {
/// they carry the lineup and the FSM lineage the folds need.
pub(crate) fn heat_window_offsets(events: &[Event], heat: &HeatId) -> Vec<(u64, Event)> {
let run_start = crate::live_state::current_run_start(events, heat) as u64;
+ let pass_ceiling = crate::live_state::current_run_pass_ceiling(events, heat) as u64;
let mut window = Vec::new();
// The offsets already claimed by this window — a target-carrying ruling joins iff its
// target is one of them (targets always point backwards, so one forward scan suffices).
@@ -1952,11 +1957,17 @@ pub(crate) fn heat_window_offsets(events: &[Event], heat: &HeatId) -> Vec<(u64,
}
// Passes: by their bridge-stamped heat TAG when present (robust against a
// heat-span event closing the positional span mid-race — the scheduling-eats-laps
- // bug); an untagged (legacy) pass keeps the positional rule.
- Event::Pass(p) => match &p.heat {
- Some(h) => h == heat && offset >= run_start,
- None => active && offset >= run_start,
- },
+ // bug); an untagged (legacy) pass keeps the positional rule. Either way, a pass
+ // landing at/after the run's `Finalized` is FROZEN OUT: once a result is official
+ // it cannot shift under a delayed catch-up pass with no command and no audit
+ // entry (rulings are unaffected — a Revert re-opens marshaling, not the record).
+ Event::Pass(p) => {
+ let before_official = offset < pass_ceiling;
+ match &p.heat {
+ Some(h) => h == heat && offset >= run_start && before_official,
+ None => active && offset >= run_start && before_official,
+ }
+ }
// Untagged (legacy insertions, registrations, …): positional.
_ => active && offset >= run_start,
};
@@ -2096,6 +2107,58 @@ mod tests {
]
}
+ /// The FINAL FREEZE: a pass landing AFTER a heat's run went official never joins its
+ /// window — a delayed RotorHazard catch-up pass (tagged or untagged) used to silently
+ /// change a Final result with no command, no ruling, and no audit entry.
+ #[test]
+ fn a_pass_landing_after_finalized_never_joins_the_official_record() {
+ let heat = HeatId("q-1".into());
+ let mut events = recorded_heat();
+ let window_before = heat_window_offsets(&events, &heat);
+ let passes_before = window_before
+ .iter()
+ .filter(|(_, e)| matches!(e, Event::Pass(_)))
+ .count();
+ assert_eq!(passes_before, 5, "the run's real passes all count");
+
+ // A late catch-up pass TAGGED with the (now Final) heat…
+ let mut late = Pass {
+ adapter: AdapterId("vd".into()),
+ competitor: CompetitorRef("A".into()),
+ at: SourceTime::from_micros(9_000_000),
+ sequence: Some(9),
+ gate: GateIndex::LAP,
+ signal: None,
+ heat: Some(heat.clone()),
+ };
+ events.push(Event::Pass(late.clone()));
+ // …and an UNTAGGED one (legacy positional attribution would claim it too).
+ late.heat = None;
+ late.sequence = Some(10);
+ events.push(Event::Pass(late));
+
+ let window_after = heat_window_offsets(&events, &heat);
+ let passes_after = window_after
+ .iter()
+ .filter(|(_, e)| matches!(e, Event::Pass(_)))
+ .count();
+ assert_eq!(
+ passes_after, passes_before,
+ "the official record is frozen — late passes stay out"
+ );
+
+ // Rulings are NOT frozen (Revert re-opens marshaling): a void targeting a real run
+ // pass still joins the window.
+ events.push(Event::DetectionVoided { target: LogRef(6) });
+ let with_ruling = heat_window_offsets(&events, &heat);
+ assert!(
+ with_ruling
+ .iter()
+ .any(|(_, e)| matches!(e, Event::DetectionVoided { .. })),
+ "rulings keep flowing into the window"
+ );
+ }
+
/// A registry whose Practice event carries a round with `win_condition` and a heat `q-1`
/// tagged with that round, driven Scheduled → Final over the given lap-gate `passes`. Used to
/// prove the result projection scores under the heat's round win condition (#45).
diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs
index c1759f2..a4263c7 100644
--- a/crates/server/src/live_state.rs
+++ b/crates/server/src/live_state.rs
@@ -265,6 +265,7 @@ fn live_state_core(events: &[Event], window: &[(u64, &Event)]) -> LiveRaceState
// marshaling adjudication still addresses the right pass. A heat that never reset has no
// boundary, so everything counts (a normally-finalized heat is unaffected).
let run_start = current_run_start(events, ¤t_heat);
+ let pass_ceiling = current_run_pass_ceiling(events, ¤t_heat);
let laps = lap_list_marshaled(
window
.iter()
@@ -276,9 +277,12 @@ fn live_state_core(events: &[Event], window: &[(u64, &Event)]) -> LiveRaceState
// Tag-aware pass attribution: a pass stamped for ANOTHER heat never counts
// toward this one — selecting an older heat as current used to absorb every
// later heat's passes (they all sit after its run_start). An untagged
- // (legacy) pass keeps the positional rule.
+ // (legacy) pass keeps the positional rule. Either way a pass landing AFTER
+ // the run went official is frozen out (the Final freeze).
match e {
- Event::Pass(p) => p.heat.as_ref().is_none_or(|h| h == ¤t_heat),
+ Event::Pass(p) => {
+ *i < pass_ceiling && p.heat.as_ref().is_none_or(|h| h == ¤t_heat)
+ }
_ => true,
}
})
@@ -604,6 +608,29 @@ pub(crate) fn current_run_start(events: &[Event], heat: &HeatId) -> usize {
start
}
+/// The log index where the current run's **official record closes**: the first `Finalized`
+/// transition for `heat` at/after [`current_run_start`], or `usize::MAX` while the run is not
+/// (yet) official. Passes at/after this offset NEVER join the heat's window — once a result is
+/// official it is frozen against late arrivals (a delayed RotorHazard catch-up pass tagged with
+/// the heat used to silently change a Final result with no command, no ruling, and no audit
+/// entry). A `Revert` re-opens marshaling (rulings are not passes and stay unaffected), but the
+/// late passes stay out: the race's observation record ended with its run.
+pub(crate) fn current_run_pass_ceiling(events: &[Event], heat: &HeatId) -> usize {
+ let run_start = current_run_start(events, heat);
+ events
+ .iter()
+ .enumerate()
+ .skip(run_start)
+ .find_map(|(i, e)| match e {
+ Event::HeatStateChanged {
+ heat: h,
+ transition: HeatTransition::Finalized,
+ } if h == heat => Some(i),
+ _ => None,
+ })
+ .unwrap_or(usize::MAX)
+}
+
/// The lineup of a heat: the competitors from its most recent `HeatScheduled`.
fn lineup_of(events: &[Event], heat: &HeatId) -> Vec {
let mut lineup = Vec::new();
diff --git a/docs/release-audit-2026-07.html b/docs/release-audit-2026-07.html
index de2da9d..9a44400 100644
--- a/docs/release-audit-2026-07.html
+++ b/docs/release-audit-2026-07.html
@@ -213,6 +213,14 @@ 4.4 Fuzz verdict
5. Deferred — recorded, deliberately not built
+
+ Closed by the release-polish batch (2026-07-04, late): the Final freeze
+ (a delayed catch-up pass can no longer shift an official result),
+ version-skew rendering (unknown timer kinds/metrics render labeled instead of a crash /
+ a DNF-reading dash), the start-tone round-trip (editing any round silently erased
+ it), orphan bridge/presence tasks (they exit when their event is deleted), and the
+ presence-batch duplicate bindings. What follows is what genuinely remains.
+
- Failover completeness (dual-timer): crossings that land between the
primary's real death and the gate flip are dropped (a standby's passes are discarded, never
@@ -220,17 +228,10 @@
5. Deferred — recorded, deliberately not built
Real but rare (multi-timer setups), and buffering needs a design of its own — replay
semantics, dedup against the primary's already-appended passes. The cheap status-stomp fix
(§2) did land.
- - Orphan bridge pruning: a deleted event's bridge task holds a strong log
- handle for the process lifetime — a resource leak, no correctness impact.
- - Presence-reconciler duplicate bindings within one poll batch — benign
- (same key/value, last-wins fold).
- - Version-skew rendering: an additive server enum variant renders as a
- blank metric cell / mislabeled timer kind on a stale console build. Mitigated by the
- both-halves deploy rule and the new contract pins; a graceful-unknown rendering pass is
- future polish.
- - StartProcedure form round-trip: the round form always writes
-
randomized-delay — lossless today (one mode exists), lossy the day a second
- mode ships. The wire shape is now contract-pinned so that day is loud, not silent.
+
+
+
+