From 21bbd486f3d4597ef78d9481498a18df9477662e Mon Sep 17 00:00:00 2001 From: Jon Bailey <297513015+Pitchfork-and-Torch@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:10:01 -0400 Subject: [PATCH] Fail closed when gizmoduck viewer-age lookup errors Gizmoduck viewer RPC Err/timeout was mapped to ViewerAge::Unknown. SensitiveViewerUnderageDropRule and NoStatedAgeDropRule both miss Unknown, so NSFW/gore/card-image served as a normal card during an outage. Stamp LookupFailed and hard-drop sensitive media globally. Confirmed Unknown (no such user) and NotStated stay unchanged. --- .../hydration/viewer_hydrator.rs | 20 +++++++++++------- visibility-filtering/models/viewer.rs | 10 +++++++++ visibility-filtering/rules/context.rs | 5 +++++ visibility-filtering/rules/golden_corpus.rs | 8 +++++++ visibility-filtering/rules/tweet_rules.rs | 21 ++++++++++++++++++- 5 files changed, 55 insertions(+), 9 deletions(-) diff --git a/visibility-filtering/hydration/viewer_hydrator.rs b/visibility-filtering/hydration/viewer_hydrator.rs index 51b9b3bd..313ebccf 100644 --- a/visibility-filtering/hydration/viewer_hydrator.rs +++ b/visibility-filtering/hydration/viewer_hydrator.rs @@ -61,12 +61,12 @@ impl ViewerHydrator { ) } Ok(Err(e)) => { - warn!(error = %e, "Gizmoduck viewer lookup failed; failing open"); - (false, ViewerAge::Unknown, None) + warn!(error = %e, "Gizmoduck viewer lookup failed; failing closed on age"); + (false, ViewerAge::LookupFailed, None) } Err(_) => { - warn!("Gizmoduck viewer lookup timed out; failing open"); - (false, ViewerAge::Unknown, None) + warn!("Gizmoduck viewer lookup timed out; failing closed on age"); + (false, ViewerAge::LookupFailed, None) } } } @@ -175,23 +175,27 @@ mod tests { } #[tokio::test] - async fn rpc_error_fails_open_to_logged_in_defaults() { + async fn rpc_error_fails_closed_on_age() { let viewer = hydrate_with_broken_client(ViewerLookup::Fails).await; assert_eq!(viewer.viewer, Viewer::LoggedIn(123)); assert!(!viewer.allows_sensitive_media); - assert_eq!(viewer.viewer_age, ViewerAge::Unknown); + assert_eq!(viewer.viewer_age, ViewerAge::LookupFailed); + assert!(viewer.viewer_age_lookup_failed()); + assert!(!viewer.viewer_is_underage()); + assert!(!viewer.viewer_has_no_stated_age()); assert_eq!(viewer.account_country_code, None); assert_eq!(viewer.country_code.as_deref(), Some("us")); } #[tokio::test] - async fn rpc_timeout_fails_open_to_logged_in_defaults() { + async fn rpc_timeout_fails_closed_on_age() { let viewer = hydrate_with_broken_client(ViewerLookup::Hangs).await; assert_eq!(viewer.viewer, Viewer::LoggedIn(123)); assert!(!viewer.allows_sensitive_media); - assert_eq!(viewer.viewer_age, ViewerAge::Unknown); + assert_eq!(viewer.viewer_age, ViewerAge::LookupFailed); + assert!(viewer.viewer_age_lookup_failed()); assert_eq!(viewer.account_country_code, None); assert_eq!(viewer.country_code.as_deref(), Some("us")); } diff --git a/visibility-filtering/models/viewer.rs b/visibility-filtering/models/viewer.rs index 46a9b6b0..114f9d66 100644 --- a/visibility-filtering/models/viewer.rs +++ b/visibility-filtering/models/viewer.rs @@ -20,6 +20,9 @@ pub const ADULT_AGE_YEARS: i32 = 18; pub enum ViewerAge { Known(i32), NotStated, + /// Gizmoduck viewer RPC errored or timed out. Distinct from `Unknown` + /// (no such user) so age gates can fail closed instead of serving NSFW. + LookupFailed, #[default] Unknown, } @@ -41,6 +44,13 @@ impl ViewerFeatures { pub fn viewer_has_no_stated_age(&self) -> bool { matches!(self.viewer, Viewer::LoggedIn(_)) && self.viewer_age == ViewerAge::NotStated } + + /// Viewer age could not be confirmed because gizmoduck failed. Sensitive + /// media must hard-drop globally (same sink as underage), not fail open + /// as `Unknown` and not jurisdiction-scope as `NotStated`. + pub fn viewer_age_lookup_failed(&self) -> bool { + matches!(self.viewer, Viewer::LoggedIn(_)) && self.viewer_age == ViewerAge::LookupFailed + } } impl ViewerFeatures { diff --git a/visibility-filtering/rules/context.rs b/visibility-filtering/rules/context.rs index 5b9b939a..c371dee9 100644 --- a/visibility-filtering/rules/context.rs +++ b/visibility-filtering/rules/context.rs @@ -69,6 +69,11 @@ impl ViewerPredicates<'_> { self.ctx.viewer.viewer_has_no_stated_age() } + #[inline] + pub fn age_lookup_failed(&self) -> bool { + self.ctx.viewer.viewer_age_lookup_failed() + } + #[inline] pub fn allows_sensitive_media(&self) -> bool { self.ctx.viewer.allows_sensitive_media diff --git a/visibility-filtering/rules/golden_corpus.rs b/visibility-filtering/rules/golden_corpus.rs index cc4da723..8a1598e6 100644 --- a/visibility-filtering/rules/golden_corpus.rs +++ b/visibility-filtering/rules/golden_corpus.rs @@ -666,6 +666,14 @@ fn age_gating_cases() -> Vec { expected_action: Allow, expected_decided_by: None, }, + Case { + name: "gizmoduck_age_lookup_failed_drops_sensitive_media", + level: TimelineHome, + viewer: viewer_with_age(ViewerAge::LookupFailed), + candidate: labeled_media(SafetyLabelType::NSFW_HIGH_RECALL), + expected_action: Drop(FilteredReason::ContainNsfwMedia), + expected_decided_by: Some("SensitiveViewerUnderageDropRule"), + }, ] } diff --git a/visibility-filtering/rules/tweet_rules.rs b/visibility-filtering/rules/tweet_rules.rs index 15db052e..ba214158 100644 --- a/visibility-filtering/rules/tweet_rules.rs +++ b/visibility-filtering/rules/tweet_rules.rs @@ -228,7 +228,9 @@ fn sensitive_viewer_logged_out(context: &RuleContext<'_>) -> VfAction { } fn sensitive_viewer_underage(context: &RuleContext<'_>) -> VfAction { - if context.viewer().is_underage() && sensitive_base_condition(context) { + if (context.viewer().is_underage() || context.viewer().age_lookup_failed()) + && sensitive_base_condition(context) + { VfAction::Drop(FilteredReason::ContainNsfwMedia) } else { VfAction::Allow @@ -680,6 +682,23 @@ mod tests { assert_allows(underage, &gating_viewer(ViewerAge::Unknown), &text); assert_allows(no_age, &gating_viewer(ViewerAge::Unknown), &text); + for firing in sensitive_firing_candidates() { + assert_drops( + underage, + &gating_viewer(ViewerAge::LookupFailed), + &firing, + &reason, + ); + } + assert_allows(no_age, &gating_viewer(ViewerAge::LookupFailed), &hp); + let us_failed = ViewerFeatures { + country_code: Some("us".into()), + ..gating_viewer(ViewerAge::LookupFailed) + }; + assert_drops(underage, &us_failed, &text, &reason); + let clean = candidate().build(); + assert_allows(underage, &gating_viewer(ViewerAge::LookupFailed), &clean); + let opted_in = ViewerFeatures { allows_sensitive_media: true, ..gating_viewer(ViewerAge::Known(15))