1use std::collections::HashMap;
7use serde::Serialize;
8
9pub const OUTCOME_ACCEPTED: u8 = 1;
10pub const OUTCOME_REJECTED_MIME: u8 = 2;
11pub const OUTCOME_SIZE_ONLY: u8 = 3;
14
15pub const STALE_AFTER_SECS: i64 = 4 * 24 * 3600;
18
19#[derive(Clone, Debug, Serialize)]
20pub struct CapabilityEntry {
21 pub mime_type: String,
22 pub is_encrypted: bool,
26 pub outcome: u8, pub max_accepted_size: u64,
28 pub min_rejected_size: Option<u64>,
30 pub updated_at: i64,
31}
32
33fn now_secs() -> i64 {
34 web_time::SystemTime::now()
35 .duration_since(web_time::UNIX_EPOCH)
36 .map(|d| d.as_secs() as i64).unwrap_or(0)
37}
38
39fn norm_url(url: &str) -> String {
40 url.trim().trim_end_matches('/').to_lowercase()
41}
42
43pub fn record_accepted(
52 server_url: &str,
53 mime_type: &str,
54 is_encrypted: bool,
55 size_bytes: u64,
56) -> Result<(), String> {
57 let conn = crate::db::get_write_connection_guard_static()?;
58 let server = norm_url(server_url);
59 let mime = mime_type.to_lowercase();
60 let enc = if is_encrypted { 1i64 } else { 0i64 };
61 let now = now_secs();
62 conn.execute(
63 "INSERT INTO blossom_server_capabilities
64 (server_url, mime_type, is_encrypted, outcome, max_accepted_size, updated_at)
65 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
66 ON CONFLICT(server_url, mime_type, is_encrypted) DO UPDATE SET
67 outcome = ?4,
68 max_accepted_size = MAX(max_accepted_size, ?5),
69 min_rejected_size = CASE
70 WHEN min_rejected_size IS NOT NULL AND min_rejected_size <= ?5
71 THEN NULL
72 ELSE min_rejected_size
73 END,
74 updated_at = ?6",
75 rusqlite::params![server, mime, enc, OUTCOME_ACCEPTED as i64, size_bytes as i64, now],
76 ).map_err(|e| format!("Failed to record accepted capability: {}", e))?;
77 Ok(())
78}
79
80pub fn record_rejected_mime(
83 server_url: &str,
84 mime_type: &str,
85 is_encrypted: bool,
86) -> Result<(), String> {
87 let conn = crate::db::get_write_connection_guard_static()?;
88 let server = norm_url(server_url);
89 let mime = mime_type.to_lowercase();
90 let enc = if is_encrypted { 1i64 } else { 0i64 };
91 let now = now_secs();
92 conn.execute(
93 "INSERT INTO blossom_server_capabilities
94 (server_url, mime_type, is_encrypted, outcome, max_accepted_size, updated_at)
95 VALUES (?1, ?2, ?3, ?4, 0, ?5)
96 ON CONFLICT(server_url, mime_type, is_encrypted) DO UPDATE SET
97 outcome = ?4,
98 updated_at = ?5",
99 rusqlite::params![server, mime, enc, OUTCOME_REJECTED_MIME as i64, now],
100 ).map_err(|e| format!("Failed to record rejected capability: {}", e))?;
101 Ok(())
102}
103
104pub fn record_rejected_size(
109 server_url: &str,
110 mime_type: &str,
111 is_encrypted: bool,
112 size_bytes: u64,
113) -> Result<(), String> {
114 let conn = crate::db::get_write_connection_guard_static()?;
115 let server = norm_url(server_url);
116 let mime = mime_type.to_lowercase();
117 let enc = if is_encrypted { 1i64 } else { 0i64 };
118 let now = now_secs();
119 conn.execute(
122 "INSERT INTO blossom_server_capabilities
123 (server_url, mime_type, is_encrypted, outcome, max_accepted_size, min_rejected_size, updated_at)
124 VALUES (?1, ?2, ?3, ?4, 0, ?5, ?6)
125 ON CONFLICT(server_url, mime_type, is_encrypted) DO UPDATE SET
126 min_rejected_size = MIN(COALESCE(min_rejected_size, ?5), ?5),
127 updated_at = ?6",
128 rusqlite::params![server, mime, enc, OUTCOME_SIZE_ONLY as i64, size_bytes as i64, now],
129 ).map_err(|e| format!("Failed to record size rejection: {}", e))?;
130 Ok(())
131}
132
133pub fn has_fresh_capability_for(server_url: &str, mime_type: &str, is_encrypted: bool) -> bool {
137 let conn = match crate::db::get_db_connection_guard_static() {
138 Ok(c) => c,
139 Err(_) => return false,
140 };
141 let server = norm_url(server_url);
142 let mime = mime_type.to_lowercase();
143 let enc = if is_encrypted { 1i64 } else { 0i64 };
144 let cutoff = now_secs().saturating_sub(STALE_AFTER_SECS);
145 conn.query_row(
146 "SELECT 1 FROM blossom_server_capabilities
147 WHERE server_url = ?1 AND mime_type = ?2 AND is_encrypted = ?3 AND updated_at >= ?4",
148 rusqlite::params![server, mime, enc, cutoff],
149 |_| Ok(()),
150 ).is_ok()
151}
152
153pub fn preflight_redundant(server_url: &str, mime: &str, is_encrypted: bool, size_bytes: u64) -> bool {
157 let info = crate::blossom_info::cached(server_url).and_then(|d| d.accepts_size(size_bytes));
158 let servers = [server_url.to_string()];
159 let Ok(cache) = load_cache_for(&servers, mime, is_encrypted) else { return false };
160 known_to_take(cache.get(&norm_url(server_url)), info, size_bytes)
161}
162
163fn known_to_take(state: Option<&CapabilityState>, info_verdict: Option<bool>, size_bytes: u64) -> bool {
165 info_verdict != Some(false)
166 && matches!(
167 state,
168 Some(st) if st.outcome == OUTCOME_ACCEPTED
169 && size_bytes <= st.max_accepted_size
170 && st.min_rejected_size.is_none_or(|m| size_bytes < m)
171 )
172}
173
174pub fn purge_server(server_url: &str) -> Result<usize, String> {
177 let conn = crate::db::get_write_connection_guard_static()?;
178 let server = norm_url(server_url);
179 let n = conn.execute(
180 "DELETE FROM blossom_server_capabilities WHERE server_url = ?1",
181 rusqlite::params![server],
182 ).map_err(|e| format!("Failed to purge capabilities for {}: {}", server, e))?;
183 Ok(n)
184}
185
186pub fn list_for_server(server_url: &str) -> Result<Vec<CapabilityEntry>, String> {
188 let conn = crate::db::get_db_connection_guard_static()?;
189 let server = norm_url(server_url);
190 let mut stmt = conn.prepare(
191 "SELECT mime_type, is_encrypted, outcome, max_accepted_size, min_rejected_size, updated_at
192 FROM blossom_server_capabilities
193 WHERE server_url = ?1
194 ORDER BY updated_at DESC"
195 ).map_err(|e| format!("Prepare failed: {}", e))?;
196 let rows = stmt.query_map(rusqlite::params![server], |row| {
197 let enc: i64 = row.get(1)?;
198 let min_rej: Option<i64> = row.get(4)?;
199 Ok(CapabilityEntry {
200 mime_type: row.get(0)?,
201 is_encrypted: enc != 0,
202 outcome: row.get::<_, i64>(2)? as u8,
203 max_accepted_size: row.get::<_, i64>(3)? as u64,
204 min_rejected_size: min_rej.map(|v| v as u64),
205 updated_at: row.get(5)?,
206 })
207 }).map_err(|e| format!("Query failed: {}", e))?;
208 let mut out = Vec::new();
209 for entry in rows.flatten() { out.push(entry); }
210 Ok(out)
211}
212
213#[derive(Clone, Copy, Debug, PartialEq)]
219pub struct CapabilityState {
220 pub outcome: u8,
221 pub max_accepted_size: u64,
222 pub min_rejected_size: Option<u64>,
223}
224
225pub fn classify(
234 cache: &HashMap<String, CapabilityState>,
235 verdicts: &HashMap<String, bool>,
236 servers: Vec<String>,
237 size_bytes: u64,
238) -> Vec<String> {
239 let mut informed_yes = Vec::new();
240 let mut known_good = Vec::new();
241 let mut unknown = Vec::new();
242 let mut too_large = Vec::new();
243 let mut mime_rejected = Vec::new();
244 let mut informed_no = Vec::new();
245 for s in servers {
246 let key = norm_url(&s);
247 match verdicts.get(&key) {
248 Some(true) => { informed_yes.push(s); continue; }
249 Some(false) => { informed_no.push(s); continue; }
250 None => {}
251 }
252 match cache.get(&key) {
253 Some(st) if st.outcome == OUTCOME_REJECTED_MIME => mime_rejected.push(s),
254 Some(st) => {
255 if let Some(min_rej) = st.min_rejected_size {
257 if size_bytes >= min_rej {
258 too_large.push(s);
259 continue;
260 }
261 }
262 if size_bytes <= st.max_accepted_size {
263 known_good.push(s);
264 } else {
265 unknown.push(s);
266 }
267 }
268 None => unknown.push(s),
269 }
270 }
271 informed_yes.extend(known_good);
272 informed_yes.extend(unknown);
273 informed_yes.extend(too_large);
274 informed_yes.extend(mime_rejected);
275 informed_yes.extend(informed_no);
276 informed_yes
277}
278
279fn info_verdicts(servers: &[String], size_bytes: u64) -> HashMap<String, bool> {
282 servers
283 .iter()
284 .filter_map(|s| {
285 let verdict = crate::blossom_info::cached(s)?.accepts_size(size_bytes)?;
286 Some((norm_url(s), verdict))
287 })
288 .collect()
289}
290
291pub fn rank_servers(servers: Vec<String>, mime: &str, is_encrypted: bool, size_bytes: u64) -> Vec<String> {
293 if servers.is_empty() { return servers; }
294 let cache = load_cache_for(&servers, mime, is_encrypted).unwrap_or_default();
295 let verdicts = info_verdicts(&servers, size_bytes);
296 classify(&cache, &verdicts, servers, size_bytes)
297}
298
299#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
302pub struct UploadVerdict {
303 pub likely: bool,
304 pub reasons: Vec<String>,
305}
306
307pub fn upload_verdict(servers: &[String], mime: &str, is_encrypted: bool, size_bytes: u64) -> UploadVerdict {
312 if servers.is_empty() {
313 return UploadVerdict {
314 likely: false,
315 reasons: vec!["No media server is configured.".to_string()],
316 };
317 }
318 let cache = load_cache_for(servers, mime, is_encrypted).unwrap_or_default();
319 let mut reasons = Vec::new();
320 for s in servers {
321 let host = crate::blossom_error::host_of(s);
322 if let Some(info) = crate::blossom_info::cached(s) {
323 match info.accepts_size(size_bytes) {
324 Some(true) => return UploadVerdict { likely: true, reasons: Vec::new() },
325 Some(false) => {
326 if let Some(why) = info.refusal_reason(&host, size_bytes) {
327 reasons.push(why);
328 }
329 continue;
330 }
331 None => {}
332 }
333 }
334 match cache.get(&norm_url(s)) {
335 Some(st) if st.outcome == OUTCOME_REJECTED_MIME => {
336 reasons.push(format!("{} doesn't accept {} files.", host, mime));
337 }
338 Some(st) if st.min_rejected_size.is_some_and(|min| size_bytes >= min) => {
339 reasons.push(format!(
340 "{} has refused files of {} before.",
341 host, crate::crypto::format_bytes(st.min_rejected_size.unwrap_or(size_bytes)),
342 ));
343 }
344 _ => return UploadVerdict { likely: true, reasons: Vec::new() },
345 }
346 }
347 UploadVerdict { likely: false, reasons }
348}
349
350pub fn any_server_likely_accepts(servers: &[String], mime: &str, is_encrypted: bool, size_bytes: u64) -> bool {
352 upload_verdict(servers, mime, is_encrypted, size_bytes).likely
353}
354
355fn load_cache_for(servers: &[String], mime: &str, is_encrypted: bool) -> Result<HashMap<String, CapabilityState>, String> {
356 if servers.is_empty() { return Ok(HashMap::new()); }
357 let conn = crate::db::get_db_connection_guard_static()?;
358 let mime_lower = mime.to_lowercase();
359 let enc: i64 = if is_encrypted { 1 } else { 0 };
360 let cutoff = now_secs().saturating_sub(STALE_AFTER_SECS);
362 let placeholders = servers.iter().enumerate()
364 .map(|(i, _)| format!("?{}", i + 4)).collect::<Vec<_>>().join(",");
365 let sql = format!(
366 "SELECT server_url, outcome, max_accepted_size, min_rejected_size
367 FROM blossom_server_capabilities
368 WHERE mime_type = ?1 AND is_encrypted = ?2 AND updated_at >= ?3 AND server_url IN ({})",
369 placeholders,
370 );
371 let mut stmt = conn.prepare(&sql).map_err(|e| format!("Prepare failed: {}", e))?;
372 let cutoff_param: i64 = cutoff;
373 let mut params: Vec<&dyn rusqlite::ToSql> = vec![&mime_lower, &enc, &cutoff_param];
374 let normalized: Vec<String> = servers.iter().map(|s| norm_url(s)).collect();
375 for n in normalized.iter() { params.push(n); }
376 let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |row| {
377 let url: String = row.get(0)?;
378 let outcome: i64 = row.get(1)?;
379 let max: i64 = row.get(2)?;
380 let min_rej: Option<i64> = row.get(3)?;
381 Ok((url, CapabilityState {
382 outcome: outcome as u8,
383 max_accepted_size: max as u64,
384 min_rejected_size: min_rej.map(|v| v as u64),
385 }))
386 }).map_err(|e| format!("Query failed: {}", e))?;
387 let mut out = HashMap::new();
388 for (url, state) in rows.flatten() {
389 out.insert(url, state);
390 }
391 Ok(out)
392}
393
394pub fn is_size_rejection(http_status: Option<u16>) -> bool {
400 matches!(http_status, Some(413))
401}
402
403pub fn is_mime_rejection(http_status: Option<u16>, error_msg: &str) -> bool {
413 if matches!(http_status, Some(415)) { return true; }
414 if matches!(http_status, Some(401)) { return true; }
415 if matches!(http_status, Some(402)) { return true; }
416 if matches!(http_status, Some(413) | Some(409)) { return false; }
417 if matches!(http_status, Some(408) | Some(429)) { return false; }
418 if let Some(s) = http_status {
419 if (500..=504).contains(&s) {
420 let lower = error_msg.to_ascii_lowercase();
423 return lower.contains("could not be processed")
424 || lower.contains("cannot be processed");
425 }
426 }
427 let lower = error_msg.to_ascii_lowercase();
429 let hints = [
430 "could not be processed",
431 "cannot be processed",
432 "unsupported",
433 "file type",
434 "mime",
435 "invalid file",
436 "not allowed",
437 "does not match",
438 "doesn't match",
439 "content-type",
440 ];
441 hints.iter().any(|h| lower.contains(h))
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447
448 fn cache_of(entries: &[(&str, u8, u64)]) -> HashMap<String, CapabilityState> {
449 entries.iter()
450 .map(|(url, o, max)| (norm_url(url), CapabilityState {
451 outcome: *o,
452 max_accepted_size: *max,
453 min_rejected_size: None,
454 }))
455 .collect()
456 }
457
458 fn cache_with_size_cap(entries: &[(&str, u8, u64, u64)]) -> HashMap<String, CapabilityState> {
459 entries.iter()
460 .map(|(url, o, max, min_rej)| (norm_url(url), CapabilityState {
461 outcome: *o,
462 max_accepted_size: *max,
463 min_rejected_size: Some(*min_rej),
464 }))
465 .collect()
466 }
467
468 #[test]
469 fn norm_url_handles_case_and_trailing_slash() {
470 assert_eq!(norm_url("HTTPS://Example.COM/"), "https://example.com");
471 assert_eq!(norm_url(" https://example.com "), "https://example.com");
472 assert_eq!(norm_url("https://example.com"), "https://example.com");
473 }
474
475 #[test]
476 fn classify_empty_input_returns_empty() {
477 let cache = HashMap::new();
478 assert!(classify(&cache, &HashMap::new(), vec![], 1024).is_empty());
479 }
480
481 #[test]
482 fn classify_all_unknown_preserves_order() {
483 let cache = HashMap::new();
484 let servers = vec!["https://a".to_string(), "https://b".to_string(), "https://c".to_string()];
485 assert_eq!(classify(&cache, &HashMap::new(), servers.clone(), 1024), servers);
486 }
487
488 #[test]
489 fn classify_known_good_floats_to_top() {
490 let cache = cache_of(&[("https://b", OUTCOME_ACCEPTED, 10_000)]);
491 let servers = vec!["https://a".to_string(), "https://b".to_string(), "https://c".to_string()];
492 assert_eq!(classify(&cache, &HashMap::new(), servers, 5_000), vec!["https://b", "https://a", "https://c"]);
493 }
494
495 #[test]
496 fn classify_known_good_falls_to_unknown_when_over_size_ceiling() {
497 let cache = cache_of(&[("https://b", OUTCOME_ACCEPTED, 10_000)]);
498 let servers = vec!["https://a".to_string(), "https://b".to_string(), "https://c".to_string()];
499 let out = classify(&cache, &HashMap::new(), servers, 20_000);
500 assert_eq!(out, vec!["https://a", "https://b", "https://c"]);
501 }
502
503 #[test]
504 fn classify_mime_rejected_sinks_to_bottom() {
505 let cache = cache_of(&[("https://b", OUTCOME_REJECTED_MIME, 0)]);
506 let servers = vec!["https://a".to_string(), "https://b".to_string(), "https://c".to_string()];
507 let out = classify(&cache, &HashMap::new(), servers, 1024);
508 assert_eq!(out, vec!["https://a", "https://c", "https://b"]);
509 }
510
511 #[test]
512 fn classify_mixed_tiers_full_ordering() {
513 let cache = cache_of(&[
514 ("https://b", OUTCOME_ACCEPTED, 10_000),
515 ("https://c", OUTCOME_REJECTED_MIME, 0),
516 ("https://d", OUTCOME_ACCEPTED, 1_000),
517 ]);
518 let servers = vec![
519 "https://a".to_string(), "https://b".to_string(),
520 "https://c".to_string(), "https://d".to_string(),
521 ];
522 assert_eq!(
523 classify(&cache, &HashMap::new(), servers, 5_000),
524 vec!["https://b", "https://a", "https://d", "https://c"],
525 );
526 }
527
528 #[test]
529 fn classify_demotes_servers_above_known_size_ceiling() {
530 let cache = cache_with_size_cap(&[("https://b", OUTCOME_ACCEPTED, 10_000, 50_000)]);
531 let servers = vec!["https://a".to_string(), "https://b".to_string(), "https://c".to_string()];
532 let out = classify(&cache, &HashMap::new(), servers, 60_000);
533 assert_eq!(out, vec!["https://a", "https://c", "https://b"]);
534 }
535
536 #[test]
537 fn any_server_likely_accepts_smoke_test() {
538 let cache = cache_with_size_cap(&[
540 ("https://a", OUTCOME_ACCEPTED, 1_000, 10_000),
541 ("https://b", OUTCOME_ACCEPTED, 1_000, 10_000),
542 ]);
543 for (_, st) in &cache {
544 assert!(st.min_rejected_size.unwrap() <= 50_000);
545 }
546 }
547
548 #[test]
549 fn a_server_whose_document_says_yes_outranks_everything_learned() {
550 let cache = cache_of(&[("https://b", OUTCOME_ACCEPTED, 10_000)]);
551 let verdicts: HashMap<String, bool> = [("https://c".to_string(), true)].into_iter().collect();
552 let servers = vec!["https://a".to_string(), "https://b".to_string(), "https://c".to_string()];
553 assert_eq!(classify(&cache, &verdicts, servers, 5_000), vec!["https://c", "https://b", "https://a"]);
554 }
555
556 #[test]
557 fn a_server_whose_document_says_no_goes_last_even_when_learned_good() {
558 let cache = cache_of(&[("https://b", OUTCOME_ACCEPTED, 10_000)]);
559 let verdicts: HashMap<String, bool> = [("https://b".to_string(), false)].into_iter().collect();
560 let servers = vec!["https://a".to_string(), "https://b".to_string()];
561 assert_eq!(classify(&cache, &verdicts, servers, 5_000), vec!["https://a", "https://b"]);
562 }
563
564 #[test]
565 fn classify_keys_are_normalized_against_cache() {
566 let cache = cache_of(&[("https://b.example.com", OUTCOME_ACCEPTED, 10_000)]);
567 let servers = vec!["https://a".to_string(), "https://B.Example.com/".to_string()];
568 let out = classify(&cache, &HashMap::new(), servers, 5_000);
569 assert_eq!(out, vec!["https://B.Example.com/", "https://a"]);
570 }
571
572 #[test]
573 fn mime_rejection_415_is_always_a_reject() {
574 assert!(is_mime_rejection(Some(415), ""));
575 }
576
577 #[test]
578 fn mime_rejection_413_and_409_never_mime_regardless_of_body() {
579 assert!(!is_mime_rejection(Some(413), "Payload Too Large mime"));
580 assert!(!is_mime_rejection(Some(409), "Conflict file type"));
581 }
582
583 #[test]
584 fn mime_rejection_401_treated_as_permanent() {
585 assert!(is_mime_rejection(Some(401), "Unauthorized"));
586 assert!(is_mime_rejection(Some(401), ""));
587 }
588
589 #[test]
590 fn mime_rejection_5xx_with_body_hint_recorded() {
591 assert!(is_mime_rejection(
593 Some(500),
594 r#"Upload failed with status 500 Internal Server Error: {"status":"error","message":"File could not be processed"}"#,
595 ));
596 }
597
598 #[test]
599 fn mime_rejection_content_type_sniff_mismatch_recorded() {
600 assert!(is_mime_rejection(
602 Some(400),
603 "Upload failed with status 400 Bad Request: Content-Type header does not match the file content, expected application/json",
604 ));
605 }
606
607 #[test]
608 fn mime_rejection_transient_5xx_without_hint_not_recorded() {
609 assert!(!is_mime_rejection(Some(500), "Internal Server Error"));
610 assert!(!is_mime_rejection(Some(503), "service unavailable"));
611 }
612
613 #[test]
614 fn mime_rejection_transient_5xx_with_unrelated_body_keywords_not_recorded() {
615 assert!(!is_mime_rejection(Some(503), "file type detection service down"));
617 assert!(!is_mime_rejection(Some(429), "rate limit reached for content-type uploads"));
618 }
619
620 #[test]
621 fn mime_rejection_payment_required_treated_as_permanent() {
622 assert!(is_mime_rejection(Some(402), ""));
623 assert!(is_mime_rejection(Some(402), "Payment Required"));
624 }
625
626 #[test]
627 fn mime_rejection_no_status_with_body_hint_recorded() {
628 assert!(is_mime_rejection(None, "Unsupported file type"));
629 }
630
631 #[test]
632 fn mime_rejection_no_status_no_body_hint_not_recorded() {
633 assert!(!is_mime_rejection(None, "network error"));
634 assert!(!is_mime_rejection(None, "timeout"));
635 }
636}
637
638#[cfg(test)]
639mod preflight_tests {
640 use super::*;
641
642 fn took(max: u64, min_rejected: Option<u64>) -> CapabilityState {
643 CapabilityState { outcome: OUTCOME_ACCEPTED, max_accepted_size: max, min_rejected_size: min_rejected }
644 }
645
646 #[test]
647 fn a_server_that_took_as_much_needs_no_asking() {
648 assert!(known_to_take(Some(&took(10_000, None)), None, 9_000));
649 assert!(known_to_take(Some(&took(10_000, None)), Some(true), 10_000));
650 }
651
652 #[test]
653 fn anything_unproven_is_asked_first() {
654 assert!(!known_to_take(None, Some(true), 1), "never uploaded there: the document alone is not proof");
655 assert!(!known_to_take(Some(&took(10_000, None)), None, 10_001), "larger than it has taken");
656 assert!(!known_to_take(Some(&took(10_000, Some(9_000))), None, 9_500), "a refusal at this size since");
657 let mime_refused = CapabilityState { outcome: OUTCOME_REJECTED_MIME, max_accepted_size: 10_000, min_rejected_size: None };
658 assert!(!known_to_take(Some(&mime_refused), None, 1));
659 }
660
661 #[test]
662 fn the_servers_own_no_overrides_its_history() {
663 assert!(!known_to_take(Some(&took(10_000, None)), Some(false), 1), "a quota or tier change the history can't know");
664 }
665}