diff --git a/workspace-server/internal/memory/pgplugin/store.go b/workspace-server/internal/memory/pgplugin/store.go index 3bb6ad2a1..61afaf42f 100644 --- a/workspace-server/internal/memory/pgplugin/store.go +++ b/workspace-server/internal/memory/pgplugin/store.go @@ -349,8 +349,23 @@ func scanMemory(row interface { } func marshalMetadata(m map[string]interface{}) ([]byte, error) { + // Returning Go nil here previously made lib/pq send the parameter + // as a bytea-typed NULL placeholder that PostgreSQL refused to + // implicitly cast into the jsonb column. The resulting INSERT + // failed with `pq: invalid input syntax for type json`, which + // surfaced first as "scan namespace" errors from the QueryRow + // path (the row never gets created so RETURNING produces no rows; + // the QueryRow error propagates through Scan). Caught 2026-05-24 + // during the Phase A2 backfill — UpsertNamespace failed on every + // call, then the client's circuit breaker opened and ALL plugin + // writes started failing. The same hazard exists on the + // memory_records.propagation column (also marshalled via this + // helper). Sending the JSON literal `null` keeps the parameter + // typed as a valid jsonb value regardless of pq's auto-conversion + // heuristics — semantically identical to NULL for our consumers + // (they treat empty propagation/metadata as absent). if m == nil { - return nil, nil + return []byte(`null`), nil } b, err := json.Marshal(m) if err != nil { diff --git a/workspace-server/internal/memory/pgplugin/store_test.go b/workspace-server/internal/memory/pgplugin/store_test.go index ac025f3f4..e05796cf5 100644 --- a/workspace-server/internal/memory/pgplugin/store_test.go +++ b/workspace-server/internal/memory/pgplugin/store_test.go @@ -17,12 +17,19 @@ import ( // --- marshalMetadata corner cases --- func TestMarshalMetadata_Nil(t *testing.T) { + // Post-fix (2026-05-24): nil input must return the JSON literal + // `null` (4 bytes), NOT Go nil. Returning Go nil made lib/pq send + // a bytea NULL parameter that PostgreSQL refused to implicitly + // cast into the target jsonb column, breaking every UpsertNamespace + // and CommitMemory call with `pq: invalid input syntax for type + // json`. The Phase A2 backfill exposed it (production saw every + // POST /memories return 500 immediately post-tenant-recycle). got, err := marshalMetadata(nil) if err != nil { t.Errorf("err = %v", err) } - if got != nil { - t.Errorf("got = %v, want nil", got) + if string(got) != "null" { + t.Errorf("got = %q (%v), want %q", string(got), got, "null") } }