A profile created in the app could never start a chat with another
profile created in the app: the peer always looked like it had no
metadata, no DM relay list and no MLS key package.
Root cause was in neither the chat code nor the relay list — nothing a
new profile signed ever reached a relay. Three queue observers used
`createdAt > :createdAt` with a `Clock.System.now()` default argument.
Kotlin evaluates that default once, at the call site, and Room binds it
for the life of the Flow; Instants persist at second resolution, so
every request enqueued in the observer's own start second (the whole
profile-creation burst) and everything left pending by a previous
session was permanently invisible. Nothing else drains those tables.
The failure was silent because `publishNostrEvent` stamps `signedAt`
and indexes the Profile in one transaction, satisfying the
ProfileLoaded branch before the UnannouncedProfile gate could be
reached — so a device-only profile looked fully announced.
Dropping the cutoff needs no schema change, so existing installs
self-heal on next launch: the stranded rows are still pending.
Also fixed, since they gate the same flow once events start moving:
- Broadcasts now always reach a terminal status (outer timeout plus
try/catch — `.catch` cannot see the suspend call that builds the
flow), interrupted ones are requeued once at startup, `OK: false` is
a failure rather than a recorded success, fan-out is bounded, and an
uncorrelated NOTICE no longer fails whatever publish shares the
socket. `take(1)` keeps the publish timeout from firing after a
success on a SharedFlow that never completes.
- CLOSED is parsed and handled, so a relay refusing a NEG subscription
falls back to REQ instead of waiting forever; NOTICE is parsed as
the two-element frame it is; negentropy timestamps use seconds, the
unit relays use.
- Both chat gates observe the peer's key package instead of reading it
once and latching a terminal error, and queue the sync they claimed
to be doing. Same-minute retries are no longer swallowed by IGNORE.
- Group rooms were keyed by the MLS group id instead of the Marmot
nostrGroupId (unrelated randoms, so neither side saw the other's
events); inviting a member wrote no Participant row, so the Welcome
produced no gift wraps, and discarded the post-addMember group state;
the invite reported success unconditionally.
- An inverted `containsKey` made the "missing peer DM relay list"
recovery a no-op, and the wrong RelayTag class wrote "r" tags where
NIP-51 relay lists expect "relay".
Verified with `:composeApp:compileDebugKotlinAndroid`, including that
Room's KSP regenerated the DAO impls without the frozen cutoff. Not yet
exercised against live relays.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>