From f0a3ed68321d36ed5f517e369aaca134cf0e2764 Mon Sep 17 00:00:00 2001 From: profit Date: Wed, 22 Apr 2026 03:48:05 -0500 Subject: [PATCH] Fix: UpsertOutcome newtype variants panicked serde from Phase 26 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/vectord/src/playbook_memory.rs | 28 ++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/crates/vectord/src/playbook_memory.rs b/crates/vectord/src/playbook_memory.rs index 7ad6f7b..3fd22d4 100644 --- a/crates/vectord/src/playbook_memory.rs +++ b/crates/vectord/src/playbook_memory.rs @@ -252,11 +252,21 @@ pub struct BoostEntry { /// Phase 26 — what happened during an upsert. The seed endpoint /// returns this shape so the caller sees whether its write was a new /// 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)] #[serde(tag = "mode", rename_all = "lowercase")] pub enum UpsertOutcome { /// New playbook appended. Carries the new playbook_id. - Added(String), + Added { playbook_id: String }, /// Existing same-day entry updated. Playbook_id unchanged; names /// merged (union, original order preserved, new names appended). Updated { @@ -265,7 +275,7 @@ pub enum UpsertOutcome { }, /// Identical same-day entry already exists; nothing changed. /// 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 @@ -597,7 +607,7 @@ impl PlaybookMemory { drop(state); self.persist().await?; self.rebuild_geo_index().await; - Ok(UpsertOutcome::Added(pid)) + Ok(UpsertOutcome::Added { playbook_id: pid }) } Some(i) => { let mut existing_names_sorted = state.entries[i].endorsed_names.clone(); @@ -605,7 +615,7 @@ impl PlaybookMemory { if existing_names_sorted == new_names_sorted { // NOOP — identical data, just report the existing id let pid = state.entries[i].playbook_id.clone(); - Ok(UpsertOutcome::Noop(pid)) + Ok(UpsertOutcome::Noop { playbook_id: pid }) } else { // UPDATE — merge names (union, stable order). 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 e = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith"]); match pm.upsert_entry(e).await.unwrap() { - UpsertOutcome::Added(_) => {} + UpsertOutcome::Added { .. } => {} other => panic!("expected Added, got {:?}", other), } 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"]); pm.upsert_entry(e1).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. 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 o1 = pm.upsert_entry(e1).await.unwrap(); let pid = match o1 { - UpsertOutcome::Added(p) => p, + UpsertOutcome::Added { playbook_id: p } => p, other => panic!("expected Added, got {:?}", other), }; 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"]); pm.upsert_entry(e1).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); } @@ -1659,7 +1669,7 @@ mod upsert_tests { // 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 o = pm.upsert_entry(e2).await.unwrap(); - assert!(matches!(o, UpsertOutcome::Added(_))); + assert!(matches!(o, UpsertOutcome::Added { .. })); assert_eq!(pm.entry_count().await, 2); } }