Fix: UpsertOutcome newtype variants panicked serde from Phase 26
Some checks failed
lakehouse/auditor 1 blocking issue: cloud: claim not backed — "Verified live after gateway restart:"

playbook_memory.rs:257 — UpsertOutcome had two newtype variants
carrying a bare String:
  Added(String)
  Noop(String)
under #[serde(tag = "mode")]. serde cannot tag newtype variants of
primitive types, so every serialization threw:
  "cannot serialize tagged newtype variant UpsertOutcome::Added
   containing a string"
This caused gateway /vectors/playbook_memory/seed to panic the
tokio worker on EVERY call that reached Added or Noop, returning
an empty socket close to the client. The bug was silent from commit
640db8c (Phase 26, 2026-04-21) until 2026-04-22 when the auditor's
hybrid fixture (auditor/fixtures/hybrid_38_40_45.ts on the
auditor/scaffold branch) exercised the endpoint live and gateway
logs showed the panic.

Fix — convert both newtype variants to struct-like:
  Added { playbook_id: String }
  Noop { playbook_id: String }
Updated all 7 construction + pattern-match sites. Updated rustdoc
on the enum explaining why the shape is what it is.

JSON wire format is now uniform across all three variants:
  {"mode":"added","playbook_id":"pb-..."}
  {"mode":"updated","playbook_id":"pb-...","merged_names":[...]}
  {"mode":"noop","playbook_id":"pb-..."}

Verified live after gateway restart:
  curl /seed new payload               → mode=added, playbook 860231f5
  curl /seed new payload + doc_refs    → mode=added, playbook 11d348d9
  curl /seed identical re-submit       → mode=noop,  same id 860231f5,
                                         entries_after unchanged (Mem0
                                         contract intact)

Tests: 51/51 vectord lib tests green. Release build clean.

This is a follow-up bug fix landed in its own branch
(fix/upsert-outcome-serde) rather than commingled with other work.
The auditor's hybrid fixture on the auditor/scaffold branch will
now light up layer 3 (phase45_seed_with_doc_refs) as a pass once
this merges — previously it failed here with an empty socket close.
This commit is contained in:
profit 2026-04-22 03:48:05 -05:00
parent affab8ac83
commit f0a3ed6832

View File

@ -252,11 +252,21 @@ pub struct BoostEntry {
/// Phase 26 — what happened during an upsert. The seed endpoint /// Phase 26 — what happened during an upsert. The seed endpoint
/// returns this shape so the caller sees whether its write was a new /// returns this shape so the caller sees whether its write was a new
/// entry, a merge, or a dedup'd no-op. /// entry, a merge, or a dedup'd no-op.
///
/// SHAPE NOTE: `#[serde(tag = "mode")]` requires struct-like variants —
/// bare `Added(String)` and `Noop(String)` newtype variants would
/// panic serialization at runtime. That bug silently 500-ed every
/// /seed call from Phase 26 (commit 640db8c) until 2026-04-22 when the
/// auditor's hybrid fixture surfaced it. All three variants are now
/// struct-like, producing uniform JSON:
/// {"mode":"added","playbook_id":"pb-..."}
/// {"mode":"updated","playbook_id":"pb-...","merged_names":[...]}
/// {"mode":"noop","playbook_id":"pb-..."}
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(tag = "mode", rename_all = "lowercase")] #[serde(tag = "mode", rename_all = "lowercase")]
pub enum UpsertOutcome { pub enum UpsertOutcome {
/// New playbook appended. Carries the new playbook_id. /// New playbook appended. Carries the new playbook_id.
Added(String), Added { playbook_id: String },
/// Existing same-day entry updated. Playbook_id unchanged; names /// Existing same-day entry updated. Playbook_id unchanged; names
/// merged (union, original order preserved, new names appended). /// merged (union, original order preserved, new names appended).
Updated { Updated {
@ -265,7 +275,7 @@ pub enum UpsertOutcome {
}, },
/// Identical same-day entry already exists; nothing changed. /// Identical same-day entry already exists; nothing changed.
/// Returns the stable playbook_id so caller still has a reference. /// Returns the stable playbook_id so caller still has a reference.
Noop(String), Noop { playbook_id: String },
} }
/// Phase 27 — shape returned from `revise_entry`. Reports both ends of /// Phase 27 — shape returned from `revise_entry`. Reports both ends of
@ -597,7 +607,7 @@ impl PlaybookMemory {
drop(state); drop(state);
self.persist().await?; self.persist().await?;
self.rebuild_geo_index().await; self.rebuild_geo_index().await;
Ok(UpsertOutcome::Added(pid)) Ok(UpsertOutcome::Added { playbook_id: pid })
} }
Some(i) => { Some(i) => {
let mut existing_names_sorted = state.entries[i].endorsed_names.clone(); let mut existing_names_sorted = state.entries[i].endorsed_names.clone();
@ -605,7 +615,7 @@ impl PlaybookMemory {
if existing_names_sorted == new_names_sorted { if existing_names_sorted == new_names_sorted {
// NOOP — identical data, just report the existing id // NOOP — identical data, just report the existing id
let pid = state.entries[i].playbook_id.clone(); let pid = state.entries[i].playbook_id.clone();
Ok(UpsertOutcome::Noop(pid)) Ok(UpsertOutcome::Noop { playbook_id: pid })
} else { } else {
// UPDATE — merge names (union, stable order). // UPDATE — merge names (union, stable order).
let existing = state.entries.get_mut(i).ok_or("index invalidated")?; let existing = state.entries.get_mut(i).ok_or("index invalidated")?;
@ -1600,7 +1610,7 @@ mod upsert_tests {
let pm = PlaybookMemory::new(Arc::new(InMemory::new())); let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
let e = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith"]); let e = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith"]);
match pm.upsert_entry(e).await.unwrap() { match pm.upsert_entry(e).await.unwrap() {
UpsertOutcome::Added(_) => {} UpsertOutcome::Added { .. } => {}
other => panic!("expected Added, got {:?}", other), other => panic!("expected Added, got {:?}", other),
} }
assert_eq!(pm.entry_count().await, 1); assert_eq!(pm.entry_count().await, 1);
@ -1613,7 +1623,7 @@ mod upsert_tests {
let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith", "Bob Jones"]); let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith", "Bob Jones"]);
pm.upsert_entry(e1).await.unwrap(); pm.upsert_entry(e1).await.unwrap();
let outcome = pm.upsert_entry(e2).await.unwrap(); let outcome = pm.upsert_entry(e2).await.unwrap();
assert!(matches!(outcome, UpsertOutcome::Noop(_))); assert!(matches!(outcome, UpsertOutcome::Noop { .. }));
// Still exactly one entry, no duplicate from the re-seed. // Still exactly one entry, no duplicate from the re-seed.
assert_eq!(pm.entry_count().await, 1); assert_eq!(pm.entry_count().await, 1);
} }
@ -1625,7 +1635,7 @@ mod upsert_tests {
let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith", "Bob Jones"]); let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith", "Bob Jones"]);
let o1 = pm.upsert_entry(e1).await.unwrap(); let o1 = pm.upsert_entry(e1).await.unwrap();
let pid = match o1 { let pid = match o1 {
UpsertOutcome::Added(p) => p, UpsertOutcome::Added { playbook_id: p } => p,
other => panic!("expected Added, got {:?}", other), other => panic!("expected Added, got {:?}", other),
}; };
let o2 = pm.upsert_entry(e2).await.unwrap(); let o2 = pm.upsert_entry(e2).await.unwrap();
@ -1646,7 +1656,7 @@ mod upsert_tests {
let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-22", &["Alice Smith"]); let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-22", &["Alice Smith"]);
pm.upsert_entry(e1).await.unwrap(); pm.upsert_entry(e1).await.unwrap();
let o2 = pm.upsert_entry(e2).await.unwrap(); let o2 = pm.upsert_entry(e2).await.unwrap();
assert!(matches!(o2, UpsertOutcome::Added(_)), "different day → fresh ADD"); assert!(matches!(o2, UpsertOutcome::Added { .. }), "different day → fresh ADD");
assert_eq!(pm.entry_count().await, 2); assert_eq!(pm.entry_count().await, 2);
} }
@ -1659,7 +1669,7 @@ mod upsert_tests {
// A new seed on same day should ADD, not merge into the retired one. // A new seed on same day should ADD, not merge into the retired one.
let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Carol Davis"]); let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Carol Davis"]);
let o = pm.upsert_entry(e2).await.unwrap(); let o = pm.upsert_entry(e2).await.unwrap();
assert!(matches!(o, UpsertOutcome::Added(_))); assert!(matches!(o, UpsertOutcome::Added { .. }));
assert_eq!(pm.entry_count().await, 2); assert_eq!(pm.entry_count().await, 2);
} }
} }