Summary
copy_publish_fields gates storing the scalar relay_url on "relay_urls" not in kind (key-existence), while the companion copy_optional_str_list("relay_urls") treats a present-but-null/empty relay_urls the same as absent. When an event carries both a real relay_url and a null/empty relay_urls, the two functions disagree on what "relay_urls is present" means and neither stores the relay — silently, with no validation error.
Location
forensics/ingest.py:1272-1277
relay_url = kind.get("relay_url")
if relay_url is not None:
if not isinstance(relay_url, str):
errors.append("relay_url must be a string when present")
elif "relay_urls" not in kind: # key-existence, not "a usable list was produced"
normalized["relay_urls"] = [relay_url]
Failure scenario
{"type":"publish_failure", "relay_url":"wss://relay.example", "relay_urls":null} (or "relay_urls":[]):
copy_publish_fields runs. relay_url is a valid string, but "relay_urls" not in kind is False (the key exists), so relay_url is not stored.
- For
publish_attempt/publish_outcome, the follow-up copy_optional_str_list(kind, ..., "relay_urls") sees null/[] and stores nothing. For publish_failure, there is no follow-up call at all.
Net: the relay attribution is discarded from normalized["relay_urls"] with the event still marked valid (no error). The raw line is preserved, so this is a normalized-projection evidence gap rather than raw-evidence loss, but it silently weakens relay attribution in every derived view.
Fix
Gate on whether a usable relay_urls list was actually produced, not on key existence — e.g. check not normalized.get("relay_urls") after the list copy, or treat a null/empty relay_urls as absent when deciding whether to fall back to the scalar relay_url.
Relationship to existing issues
No existing issue touches the relay_url ↔ relay_urls interaction or this silent-drop path.
Filed by an automated code-review pass.
Summary
copy_publish_fieldsgates storing the scalarrelay_urlon"relay_urls" not in kind(key-existence), while the companioncopy_optional_str_list("relay_urls")treats a present-but-null/emptyrelay_urlsthe same as absent. When an event carries both a realrelay_urland a null/emptyrelay_urls, the two functions disagree on what "relay_urls is present" means and neither stores the relay — silently, with no validation error.Location
forensics/ingest.py:1272-1277Failure scenario
{"type":"publish_failure", "relay_url":"wss://relay.example", "relay_urls":null}(or"relay_urls":[]):copy_publish_fieldsruns.relay_urlis a valid string, but"relay_urls" not in kindisFalse(the key exists), sorelay_urlis not stored.publish_attempt/publish_outcome, the follow-upcopy_optional_str_list(kind, ..., "relay_urls")seesnull/[]and stores nothing. Forpublish_failure, there is no follow-up call at all.Net: the relay attribution is discarded from
normalized["relay_urls"]with the event still marked valid (no error). The raw line is preserved, so this is a normalized-projection evidence gap rather than raw-evidence loss, but it silently weakens relay attribution in every derived view.Fix
Gate on whether a usable
relay_urlslist was actually produced, not on key existence — e.g. checknot normalized.get("relay_urls")after the list copy, or treat a null/emptyrelay_urlsas absent when deciding whether to fall back to the scalarrelay_url.Relationship to existing issues
No existing issue touches the
relay_url↔relay_urlsinteraction or this silent-drop path.Filed by an automated code-review pass.