Files
buzz/docs/buzz-v1-read-state.md
Bradley Axen 7afdc2aac9 feat(relay): record when a read-state account starts (#8157)
## Goal

Add `started_at` to read-state accounts from the day the table is
created. This prevents users seeing ALL their channels as unread when
they first install a client update that starts using these APIs

---------

Signed-off-by: hMB2 <0400eb0da32afbbf16cdd1dee6ffd243587e8f3d6a2823fe7dfa95d16b002b7d@buzz.block.builderlab.xyz>
Signed-off-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz>
Signed-off-by: hMurderbot <219c58afff8ac2c76e4e527a329aa61c2ebbb896786b1916d4f9cca5140ecd04@buzz.block.builderlab.xyz>
Co-authored-by: hMB2 <0400eb0da32afbbf16cdd1dee6ffd243587e8f3d6a2823fe7dfa95d16b002b7d@buzz.block.builderlab.xyz>
Co-authored-by: hMurderbot <219c58afff8ac2c76e4e527a329aa61c2ebbb896786b1916d4f9cca5140ecd04@buzz.block.builderlab.xyz>
Co-authored-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:04:02 -07:00

17 KiB
Raw Blame History

Private read-state accessory API

BUZZ_V1_ENABLED=true opts into /buzz/v1; it is disabled by default. Conversation history, live events, edits and deletion remain Nostr-authoritative. This API neither replaces Nostr reads nor writes artificial signed events. Legacy NIP-RS continues unchanged, but does not synchronize with these tables.

Discovery and identity

On a known community host, NIP-11 (GET / with Accept: application/nostr+json, or GET /info) includes buzz_v1 only when enabled:

{"buzz_v1":{"version":1,"base_path":"/buzz/v1","retention_seconds":2592000,
"max_channels":20,"max_intents":100,"max_contexts":20,"max_context_messages":100,
"max_thread_summaries":5,"eligible_kinds":[9,40002,45001,45003]}}

Use the requesting origin plus this relative prefix. Discovery is a configured capability, not a promise that the next request cannot fail. Unknown hosts and disabled deployments omit it, and a disabled deployment does not mount /buzz/v1 at all. Absence means read state is unavailable, not that everything is read: show no count rather than zero, and keep unsent intents. A client may also speak NIP-RS, which the relay still serves, but the two never synchronize; buzz-app uses v1 only.

Every API request requires NIP-98, including on development relays. Sign the exact externally addressed URL, including the encoded query, and method. POST also requires the SHA-256 payload tag for the exact body bytes. Each retry needs fresh authorization; replay protection is shared with the bridge. Applicable NIP-FI admission is enforced and its asserted key must match the request signer. The host chooses the community; the signer chooses /me. NIP-OA admission does not grant access to the owner's personal state. Relay membership, bans and resource access are enforced; moderation timeouts do not prohibit reading.

Responses produced by the v1 handlers are Cache-Control: private, no-store. Application errors (including NIP-98 failures with NIP-FI Off or Shadow) use {"error":{"code":"invalid_request","request_id":"..."}}, with 400 invalid, 401 unauthorized/replay, 403 forbidden, 404 unavailable capability/host/path, 429 rate limited or 503 temporarily unavailable. Application 429/503 errors include Retry-After. When NIP-FI restricts (Enforce or DenyProtected), admission failures instead preserve the shared NIP-FI HTTP denial contract: status, fixed plaintext body, Content-Type and (for 401) WWW-Authenticate: Nostr. The v1 handler adds private, no-store without changing those fields, unlike the bridge's direct passthrough. Denials from the outer shared router middleware use its common response policy, not the v1 handler's cache or JSON policy. Unknown request fields are rejected. Never turn a transport failure into read.

Sidebar

GET /buzz/v1/me/sidebar?limit=20&cursor=<exclusive-channel-uuid> returns account, channels, and next_cursor. Omit the cursor on the first request. Each channel includes identity/name/type, archived and hidden flags, unread, attention, latest_message_id (the last eligible message to arrive: the row's read anchor, see Order), latest_message_at (the greatest author time among eligible messages, in seconds: display activity, not necessarily that message's own time; null exactly when the ID is null), latest_message_complete, and threads. Only joined, nondeleted channels are listed. Hidden/archived presentation remains client-owned. Each page has a writer-consistent snapshot; separate pages do not share a snapshot, and an unfinished traversal cannot prove channel removal.

GET /buzz/v1/me/sidebar?channel_ids=<uuid>,<uuid> refreshes 1–20 unique channels in one snapshot, ordered by ID with next_cursor: null. It cannot be combined with limit or cursor. A requested ID absent from the result was not a joined, nondeleted, accessible sidebar row at that snapshot: remove its row. Absence says nothing else about access to an open channel.

threads lists unread threads in the row, newest unread reply first:

{"items":[{"root_id":"<64-hex>","unread":{"status":"exact","value":2},
  "latest_reply_id":"<64-hex>","latest_reply_at":1700000000}],"complete":true}

Items are canonical roots with unread replies that count (see below), ordered by latest_reply_at descending, then root_id; at most 5. latest_reply_id is the last such reply to arrive (equal arrivals prefer the smaller ID), so a thread mark_through at it reads every reply listed; latest_reply_at is its author time. Replies that do not count are filtered out before the count, the latest reply and the cap are chosen. unread uses the row's definition; every counted reply is also attention, so items carry no separate attention count. complete=true means the unread window was exhausted, no evidence had unresolved ancestry, unusable tags or undecided membership, and no thread was omitted; then item unread counts sum to the row's unread replies. Otherwise the list is a cut of observed evidence and counts may be lower bounds or unknown. No message bytes are included.

Counts have exactly three representations:

[{"status":"exact","value":0},{"status":"at_least","value":7},{"status":"unknown"}]

Only exact zero proves absence. Unknown has no numeric value. A message is eligible when it is non-own, nondeleted, of the advertised eligible_kinds and inside the horizon. The same kinds alone define latest activity, so an edit, reaction or diff (40008) neither makes a channel unread nor moves it. Classify live arrivals with the advertised set, not a client copy.

An eligible message beyond the matching context frontier counts as unread for the first reason that holds:

reason Holds when
direct its channel is a DM
mention it tags the actor with p
conversation it is a reply, and the actor wrote its direct parent or has a reply to that same parent, in that channel
broadcast it carries broadcast=1
null it is top-level

A reply with no reason does not count: it is not unread and appears in no thread list. unread counts the messages that count; attention is the subset with a reason. Conversation membership uses only the actor's live eligible messages (a deleted parent proves nothing; a surviving reply to it still does), looks at the direct parent only (owning the root or replying elsewhere in the thread proves nothing), and is independent of read progress and retention. A reply whose membership is undecided is left out, and the row's counts become lower bounds or unknown. The sidebar counts a broadcast reply without asking which of the two reasons applies. This is not Desktop notification policy: follows and mutes do not affect these counts.

The unread horizon defaults to 30 days (BUZZ_V1_RETENTION_SECONDS) and is measured in author time (created_at): a message counts while its author time is at or after account.cutoff_ms. It filters unread/attention, not latest activity, event storage or frontier state. Frontiers use a different clock, relay arrival (see Order). Three consequences:

  • A message accepted late with an author time beyond the horizon (an import, a backfill, a long-offline sender) is excluded under the current horizon, however recently the relay accepted it. It can still be latest when the horizon holds no message.
  • Unread expires at author time plus the horizon, so a future-dated author time extends how long a message counts.
  • A context's unread set is two tests, not one range: arrived after the frontier, and author time at or after the cutoff.

A later configuration expansion can change counts without having lost progress. Latest activity is independent of actor and frontiers. A null latest ID proves an empty eligible history only when latest_message_complete=true.

Explicit contexts

GET /buzz/v1/me/read-state?targets=<URL-encoded-JSON-array> accepts up to 20 contexts and 100 total concrete message selectors. It is not event history or a global export of frontiers. Example decoded targets:

[{"target":{"channel_id":"<uuid>","root_id":"<64-hex-root>"},
  "message_ids":["<64-hex-event>"]}]

Omitting root_id selects the channel timeline. The result contains account and one contexts entry per request entry, in order. Context status is available (with messages), unknown, or unavailable. A thread context's frontier includes any whole-channel cut. Message status is read, not_counted, unread (with reason), unknown, or unavailable. Wrong-context, missing and forbidden selectors share unavailable. Status is decided in this order: ancestry and context; eligibility (not_counted for own, deleted, other kinds and outside the horizon); the frontier (read, with no membership lookup); then the reason. A reply past the frontier with no reason is not_counted when it is proven outside the actor's conversations and unknown when membership is undecided. A broadcast reply whose membership is undecided is unread with reason broadcast and may report conversation on a later request. Conversation bytes must still come from the existing Nostr path.

Fixed-operand writes

POST /buzz/v1/me/read-state accepts 1–100 independent intents:

{"intents":[
 {"type":"mark_through","target":{"channel_id":"<uuid>"},"message_id":"<64-hex-event>"},
 {"type":"mark_through","target":{"channel_id":"<uuid>","root_id":"<64-hex-root>"},"message_id":"<64-hex-event>"},
 {"type":"mark_channel_read","channel_id":"<uuid>","message_id":"<64-hex-event>"}
]}

Each intent commits atomically and returns its own applied, blocked or invalid outcome. An ambiguous timeout/storage failure returns {"status":"unknown","retryable":true}. Earlier committed outcomes survive later failures. projection_status is not_requested; a successful write does not assert a client has refreshed. Retry the same operands, never substitute latest. Keep pending intent durably on the client until its outcome is resolved.

A mark-through validates a fixed message and advances its context's monotone frontier to that message's relay arrival (see Order). Channel and thread frontiers never inherit in either direction. Opening a view is not itself a reading action; client dwell/focus policy determines when to send an actual observed anchor. Old or deleted valid anchors may advance a frontier.

mark_channel_read is the one whole-channel cut: it advances the channel timeline and every thread in that channel, including unlisted ones, through the anchor's arrival. The anchor must be an accessible eligible-kind message in the channel, top-level or reply, deleted or not; ancestry is not checked. A reply is read at or below the greater of its thread frontier and this cut. An anchor that no longer exists is blocked. latest_message_id is the anchor that reads the whole row. A null ID with latest_message_complete=false does not prove empty history; it only leaves the client without an anchor. Thread marks and channel mark_through never set the cut.

Order

A frontier is the relay arrival time (events.received_at) of the message a context was read through, never its author time, which the sender chooses and the relay accepts up to 15 minutes either way. Everything that arrived at or before the anchor is read, whatever its author time. A message that arrives later is unread even when backdated, and a future-dated anchor reads nothing that arrives after it.

The order is the relay's and is not exposed: no response carries a frontier, and no field lets a client compute what a mark will cover. Send the anchors the user actually saw, let the relay take the greatest, and ask a context which messages are read. Do not compare author times, IDs or local receipt order to drop one pending anchor in favor of another.

Arrival is the accepting relay process's clock, read just before the insert, at microsecond resolution. Three limits follow, none of which strands a badge:

  • It is not commit order. An insert that commits after a later-stamped message was already marked read lands read.
  • Relay processes with different clocks can stamp out of true order by their skew.
  • Messages with the identical stamp are read together.

latest_message_id is the last message to arrive among those the unread count examined: the 4,096 most recent events by author time inside the horizon. So marking through it reads everything counted. When the horizon holds no message, it is the last to arrive among the channel's 256 most recent events.

There is no import of earlier client read state. The relay records the account's started_at at its first applied read intent and never moves it. Every frontier is floored at started_at: until the account starts nothing counts as unread, and from then it starts caught up, so a context it has never marked counts only arrivals after the start. Manual unread remains device-local.

Bounds and deployment

  • 20 sidebar rows, 100 intents, 20 contexts / 100 selectors per request.
  • 64 KiB write body; 16 KiB context URL; 1 MiB serialized API response.
  • 4096 raw events per channel inside the horizon plus one exhaustion sentinel, before eligibility.
  • Latest activity probes 256 events plus a sentinel; long ineligible tails may leave latest incomplete even when unread is exact.
  • Tag documents over 8192 bytes or malformed relevant tags yield uncertainty. Compact boolean facts cross the database boundary, never raw tag payloads.
  • Conversation membership: at most 1024 unique parents per request, with a 500 ms savepoint budget. The lookup is exact, so its work grows with the replies under each parent. Past either bound a reply is undecided, never absent: counts become lower bounds or unknown and a context reports unknown.
  • DB statement/lock deadlines and HTTP read deadlines bound work; writes use a shared eight-second intent-processing deadline after admission. Limits are containment, not a production capacity claim.

Apply migrations 0056 and 0057 (or the equivalent desired schema). 0056 creates two empty private tables and no index on events, and 0057 adds the nullable personal_read_accounts.started_at column. Both sidebar scans are served by the existing idx_events_community_channel_created. No new per-message ingest write path or stored unread counters are introduced.

Use existing HTTP route/status/latency metrics for /buzz/v1/me/sidebar and /buzz/v1/me/read-state, plus database pool/statement metrics. Inspect exact / lower-bound / unknown proportions in controlled acceptance captures; no payload, actor, channel or frontier values should become metric labels. The measured local seed is not a DAU/concurrency or p95/p99 production acceptance result.

Compatibility and extension rules

Within /buzz/v1, clients must ignore unknown response object fields. Existing required fields, status variants and their meanings remain stable; additive fields do not authorize silently changing attention or frontier semantics. Breaking changes require an explicitly negotiated contract or a new API version. Requests remain strict: send new parameters or intent types only after the relay advertises the corresponding capability. Missing optional data means unsupported or not requested, never an empty list, zero count or unchanged revision.

Follow-up design constraints are recorded in the extension design note; they do not advertise additional capabilities.

Privacy and lifecycle

These typed relational frontiers are signer-private application state, not self-encrypted. Database operators can see reading progress; ordinary Nostr queries, search and moderator interfaces do not expose it. No public receipts are emitted. Storage grows by touched contexts, not observed messages, and has no fixed context-count ceiling.

Leaving/rejoining does not erase progress; revoked access hides it. Soft-deleted channels are inaccessible, while hard channel deletion cascades their frontiers. Deleting an account row cascades that actor's frontiers in the same community; community erasure inventories both tables under the existing write fence. There is no new public account export/reset endpoint. Operator-assisted erasure/export must use the established authenticated operational process and explicitly scope both community and actor; never equate the read-time horizon with data erasure.

There is no down migration, and disabling the API is not a rollback. A relay built before 0056 that restarts with BUZZ_AUTO_MIGRATE=true (the Helm default) refuses to start on the migrated schema. Whole-community deletion run from a build before 0056 rejects the two new tables. A deletion approved on the earlier schema and not yet fenced fails structural revalidation after 0056: take pending approvals back through operator review before rollout, and do not rewrite them.

Roll out disabled-by-default to controlled accounts after agent and human live acceptance. Disabling the API unmounts it and leaves both tables in place: v1 clients lose access to read state and keep their unsent intents until it returns. Neither setting changes NIP-RS state or how NIP-RS requests are processed. While enabled, v1 requests count against the signer's existing API-call quota and share the existing writer database pool with other relay work.