Original release notes
GRAIN v0.8.0-rc5
Fixes from rc4's first production run Release candidate 10.1.2026 Full Changelog: https://github.com/0ceanSlim/grain/compare/v0.8.0-rc4...v0.8.0-rc5
---
rc4 ran on a live relay for ten days, and that run turned up a long-standing bug: events containing &, < or > were acknowledged and then silently thrown away. rc5 fixes it, along with the problems the same run exposed. Purge now counts an event's age from when the relay received it. Request filters are strict. The public stats endpoint no longer scans the whole database per call. Logs land where --data-dir says.
---
Events with &, < or > are stored
- The bug: any event whose content or tags contained
&,<or>gotOK true, was logged as stored, and was never written. That included URLs with query strings in content or inr/imetatags. The cause: grain encoded events for nostrdb with Go's default JSON encoder, which writes those characters as\u0026-style escapes, and nostrdb's parser can't read escapes. Every release since grain moved to nostrdb did this. On the relay where it was found, not one stored note contained any of the three. - Profiles were erased, not just dropped. For replaceable events (profiles, contact lists, relay lists), the old version was deleted before the new one was handed to the database. A profile update containing
&left the author with no profile at all. - The fix: grain now builds the JSON it hands nostrdb, and computes event ids, with exactly the escaping NIP-01 specifies. The id check also handles U+2028/U+2029, control characters and a literal
\u0026typed in a note, and matches nostr-tools on all of them. - No more silent drops. Before anything is queued, and before a replaceable's old version or a NIP-09 target is deleted, grain runs nostrdb's own parser and id check on the event. If nostrdb would refuse it, the client gets
OK falseand the operator gets an ERROR line with the event id, kind and size. - Clients that use nostrdb can read these events too. Outgoing messages used the same escaping, and Damus and Notedeck ingest relay messages with nostrdb. Events are now sent with
&,<and>as-is. - Events dropped before this fix were never written, so there is nothing to recover. Each author's next publish will land.
Purge counts age from arrival
- The bug: purge measured
keep_interval_hoursfrom each event'screated_at. Clients often publish events long after writing them: republished profiles, contact lists, relay lists, backfills. Such an event arrived already past the window and was deleted at the next purge run, a few minutes later. - New default:
event_purge.retention_clock: received. An event that arrives more thanlate_arrival_minutes(default 10) after itscreated_athas its arrival time recorded, and purge ages it from then. Events that arrive sooner are aged fromcreated_at, so they can be purged at most that many minutes early. Arrivals are kept inarrivals.lognext to the database. The first arrival counts, so re-sending a stored event doesn't restart its clock. retention_clock: created_atrestores the old behavior. Both settings are in the dashboard's Event Purge form.- Purge reaches every kind. In category mode, purge found which kinds to walk by sampling the newest 50,000 events. On a larger database, any kind whose events were all older than that was never purged. It now checks every kind number for events past the cutoff, about 0.6 s per run.
Stricter request filters
- Malformed filters are rejected, not widened. A filter field with the wrong type used to be dropped, so
{"kinds":["1"]}, a 65-character author, a non-hex id or"limit": -1each returned events from the whole database. REQ and COUNT now parse filters strictly and answer bad input withCLOSED "invalid: filter N: ...". - An empty list matches nothing. An empty
ids,authors,kindsor#taglist now matches no events, as in nostr-tools and go-nostr. It used to match everything. - Prefix lookups still work.
idsandauthorsprefixes of 1-64 hex characters stay supported. The first prefix query on a connection gets an advisory NOTICE, because a prefix can't be verified against a full id.
Subscriptions and messages
- Evictions are announced. At
max_subscriptions_per_client, a new subscription replaces the oldest and the relay sendsCLOSEDfor it. The relay used to pick a random victim without telling the client, and also evicted when a REQ only replaced an existing subscription. - No reply to CLOSE. NIP-01 reserves
CLOSEDfor subscriptions the relay ends, so a client's own CLOSE now gets no answer. A malformed CLOSE gets a NOTICE. - NOTICE matches NIP-01. Notices are now
["NOTICE", <message>]. Some used to carry an extra empty element. - NIP-77 is refused. grain doesn't support negentropy yet.
NEG-OPENnow gets an immediateNEG-ERR, so a client falls back to REQ instead of waiting out a timeout.
Relay stats
GET /api/v1/relay/statsis cached. This public endpoint used to scan the whole database twice per request, about 340 ms at 300k events, so any caller could burn the relay's CPU. It now serves from a 30-second cache that refreshes in the background, and no longer returns an empty body while startup work is running.- Kind buckets are fixed. The "list" bucket reports kind 30000, so
by_kindno longer has two rows for kind 1. The dashboard donut shows a single "other" slice, and on phones it stacks above its legend. total_messages_sentis a real total. A 5-minute stats log reset the same counter, so the dashboard number went down as well as up. It now only grows.
Startup and logging
--data-dircovers every log line. Lines written before the data directory was resolved, including the whole nostrdb open, used to land indebug.login the working directory. They now go to the data directory from the first line.- Slow startup steps are visible. The nostrdb open, the startup purge and the NIP-40 expiration bootstrap each log how long they took.
- Config restarts say so. A config change restarts the server instance, which used to print "Server is running" again. The new instance now says it restarted after a config change.
Web
- Unknown pages return 404. Any path that isn't a real page used to return 200 with the app shell, which misled uptime monitors and crawlers. The shell still renders, so a mistyped URL lands on the home page as before.
/favicon.icoserves the icon. It used to return HTML.
Housekeeping
docs/design/is gone. The Beyond-1.0 plans are tracked as issues instead: #107 (search across the relay pool) and #108 (rich event rendering).
---
Upgrading
- Purge behavior changes by default. With
retention_clockunset, purge counts age from arrival. Arrival tracking starts with this version, and only while purging is enabled. Events stored before then keep thecreated_atclock. Setretention_clock: created_atto keep the old behavior. - The first purge may delete more than usual. Kinds that the old sampling never reached are now purged. Each run is still capped at 100,000 deletes.
- From rc3 or earlier: nostrdb upgrades the database from v5 to v6 on first start and rebuilds its profile search index. rc4 did this too, although its notes said nothing was migrated. Expect the first start on a large database to take longer. rc5's startup timings in
debug.logshow where the time goes. - Malformed filters now fail. Clients that sent malformed filters, and relied on getting results back anyway, now get
CLOSED "invalid: ...". - New config keys:
event_purge.retention_clockandevent_purge.late_arrival_minutes. Both are optional and default as described above. - Still a release candidate. Please file anything you hit before the final
v0.8.0.

