Files
buzz/docs/buzz-v1-read-state.md
Bradley Axen 1e80066c93 feat(relay): reshape read-state API to the sidebar wire format (#8220)
## Summary

Changes the private read-state API (`/buzz/v1`) to the sidebar wire
format agreed in #buzzin-in-secret. **The counting code is unchanged.**
Requests and responses take the new shape, so the client integration can
build against it now and later behavior fixes (counting rules, followed
threads, another-device pings) ship without another wire change. Nothing
ships against v1 yet (no buzz-app use on `main`), so this replaces
version 1 directly.

Supersedes #8188 (same branch content, rebased onto current `main`, plus
the thread-cap and doc changes below).

```
GET /buzz/v1/me/sidebar?limit=&cursor=  |  ?channel_ids=a,b,…
→ { channels: [channel], next_cursor }
channel: { channel_id, unread: bool, mentions, read_through_id|null, latest_id|null, threads: [thread] }
thread:  { root_id, unread: bool, mentions, read_through_id|null, latest_id }

POST /buzz/v1/me/read-state
{ intents: [{ type: "mark_through", target: { channel_id [, root_id] }, message_id }] }
→ { outcomes: [applied|blocked|invalid|unknown], channels: [channel] }
```

What changed and why:
- **Channel and thread positions are fully separate.** Channel
`unread`/`mentions` count only top-level messages. Thread replies count
only on their thread row. `mark_channel_read` and its whole-channel cut
are gone. "Mark channel read" sends a channel `mark_through` at
`latest_id`, plus one per thread row to clear threads too.
- **Channel `latest_id` is always a top-level message.** It used to be
able to point at a reply, and a channel `mark_through` rejects a reply
as its anchor. It also skips a reply-marked message whose ancestry never
resolved, because marking through one would fail on every retry.
- **`read_through_id`:** frontiers stored only the anchor's arrival
time. Migration 0058 adds `personal_read_frontiers.through_message_id`,
which follows the greatest arrival, and drops the now-unused
`threads_through_timestamp`. This is the only storage change.
- **Counts are plain numbers.** The exact/at_least/unknown wrappers are
gone. Evidence the relay can't use (unusable tags, unresolved ancestry,
undecided conversation membership, past the scan cap) leaves the message
out, so counts can undercount.
- **POST returns the updated row** of each distinct channel it applied
to, so the client doesn't refetch after every read. If those rows can't
be read after the writes commit, `channels` is omitted and the outcomes
still stand. The row refresh shares the 8 s intent deadline.
- **Up to 25 thread rows per channel** (was 5). Five hid a mention in a
sixth unread thread. 25 is the most that keeps a POST's rows for
`MAX_INTENTS` (100) channels inside the 1 MiB response limit; 50 would
be ~1.4 MB, a 503 that hides already-committed outcomes. A unit test
builds that worst-case response and fails if the cap outgrows the limit.
- **The doc separates shape from behavior.** `/buzz/v1` promises a
stable shape (fields, types, status variants, what `mark_through` does).
What the counts include (which messages are unread or directed, which
threads are listed, the bounds) may change while v1 is pre-release, so
clients replace their rows with each response instead of reconciling
against local counts.
- **Removed from the wire:** the explicit-contexts read (`GET
/me/read-state?targets=`), `account`, channel name/type/archived/hidden
(these come from Nostr metadata), `latest_message_at`/`_complete`, and
the NIP-11 `max_contexts`/`max_context_messages`/`max_thread_summaries`.

Deliberately deferred (behavior or additive, no shape change):
- Thread counting rule (relay counts a reply if you wrote or replied to
its direct parent; the client uses "posted anywhere in the thread or
followed"), the workflow-owner attribution p-tag, and thread-row
`mentions` counting every counted reply rather than directed ones.
- `follow`/`unfollow` intents, listing followed threads that are already
read, and a truncation flag for the thread list.

Thread: buzz://channel/6f773b94-34d0-4004-bec9-a100b6ad17d5 (#buzz-bff,
root 31020fff)

### Related issue
Follows #8157. Supersedes #8188; replaces the wire parts of the closed
#8155.

### Testing
- `cargo test --no-fail-fast -p buzz-db -p buzz-relay` at 1f041e46: all
pass except 7 relay unit tests (`api::media::tests` ×6 needing a seeded
local DB, `api::mesh_demo` round trip) that fail identically on `main`
(394d4a43) in the same environment.
- `scripts/postgres-test-run.sh -p buzz-db -p buzz-relay` against local
PG14 + Redis at 88d2f6fc: 836 passed, 26 failed. 25 also fail on `main`
in the same environment (partition maintenance tests and
`huddle_started_links`, which need PG16). The 26th,
`boot_lifecycle::hanging_redis_peer_times_out…`, is a timing assertion
that failed under full-suite load and passed 3/3 when rerun alone on
this branch.
- All `personal_read`, `migration`, `buzz_v1`/`accessory` and NIP-FI
wire-contract tests pass, including the new response-size bound test and
the 25-thread `summarize` cap test.
- `cargo fmt --check` and `cargo clippy -p buzz-db -p buzz-relay --tests
-D warnings` are clean.
- Not done: running a standalone relay binary (the dev object store
rejected writes with HTTP 403). The relay tests drive the real HTTP
router with NIP-98-signed requests. A human test against a local relay
is still needed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>
Co-authored-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:07:19 -07:00

282 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:
```json
{"buzz_v1":{"version":1,"base_path":"/buzz/v1","retention_seconds":2592000,
"max_channels":20,"max_intents":100,"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](nips/NIP-FI.md): 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
`channels` and `next_cursor`. Omit the cursor on the first request. Only
joined, nondeleted channels are listed; names, types, archived and hidden state
come from Nostr channel metadata, not this API. 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.
```json
{"channels":[{"channel_id":"<uuid>","unread":true,"mentions":1,
"read_through_id":"<64-hex>","latest_id":"<64-hex>",
"threads":[{"root_id":"<64-hex>","unread":true,"mentions":2,
"read_through_id":null,"latest_id":"<64-hex>"}]}],
"next_cursor":null}
```
A channel's timeline and each of its threads keep independent read positions;
marking one never moves another.
| Field | Channel row | Thread row |
|---|---|---|
| `unread` | an unread top-level message counts | an unread reply counts |
| `mentions` | unread top-level messages directed at you | unread replies that count |
| `read_through_id` | the anchor of the timeline's frontier, or null | the anchor of the thread's frontier, or null |
| `latest_id` | the last eligible top-level message to arrive, or null | the last counted unread reply to arrive |
A thread reply never makes the channel row unread and never becomes its
`latest_id`, including a reply carrying `broadcast=1`: it shows on its thread
row. `latest_id` is the anchor that reads its scope: "mark channel read" sends a
channel `mark_through` at the channel's `latest_id`, plus one thread
`mark_through` at each listed thread's `latest_id` to clear those too. The
channel `latest_id` counts any author and ignores read progress. Null means the
bounded scans found no top-level message, not that the channel is empty.
`read_through_id` may name a message that has since been deleted.
`threads` lists threads with unread replies that count, newest unread reply
first by author time (then `root_id`), at most 25. Threads past the 25th are
omitted with their mentions, and nothing signals the omission: after marking
the listed threads read, a refresh can list the next ones. A thread row's
`unread` is always true in this version. No message bytes are included.
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.
A top-level message is directed at you when its channel is a DM, it tags you
with `p`, or it carries `broadcast=1`. A reply counts only when it is directed
in the same way, or it is in one of your conversations: you wrote its direct
parent or have a reply to that same parent, in that channel. Conversation
membership uses only your 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. This is not Desktop notification
policy: follows and mutes do not affect these counts.
Counts are what the bounded scans could establish, and may undercount. A message
whose tags are unusable, whose ancestry is unresolved, or whose conversation
membership is undecided is left out, as are messages beyond the scan bounds
(see [Bounds](#bounds-and-deployment)).
The unread horizon defaults to 30 days (`BUZZ_V1_RETENTION_SECONDS`) and is
measured in author time (`created_at`). It filters unread and mentions, not
latest activity, event storage or frontier state. Frontiers use a different
clock, relay arrival (see [Order](#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.
## Fixed-operand writes
`POST /buzz/v1/me/read-state` accepts 1–100 independent intents:
```json
{"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>"}
]}
```
The response is `{"outcomes":[...],"channels":[...]}`. Each intent commits
atomically and returns its own `applied`, `blocked` or `invalid` outcome, in
request order. An ambiguous timeout/storage failure returns
`{"status":"unknown","retryable":true}`. Earlier committed outcomes survive
later failures. `channels` holds the updated sidebar row of each distinct
channel with an applied intent, in the sidebar's row shape and ordered by ID;
a channel you have not joined has no row. Replace those rows rather than
re-deriving them. `channels` is omitted when the rows could not be read within
the request's deadline after the writes committed: refresh them with `?channel_ids=`. 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](#order)). A channel
anchor must be a top-level message; a thread anchor must be the thread's root
or one of its replies. The frontier records the anchor as `read_through_id`
when it advances; an anchor that arrived at or before the current frontier
changes nothing. 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.
### 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: a response carries the anchor
message (`read_through_id`), never its arrival time, and no field lets a client
compute what a mark will cover. Send the anchors the user actually saw and let
the relay take the greatest. 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.
A channel's `latest_id` is the last top-level 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 every top-level message counted. When
that scan holds no top-level message, it is the last top-level message 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 and 100 intents per request; 25 thread rows per channel, so
a POST's rows for 100 channels fit the response limit.
- 64 KiB write body; 1 MiB serialized API response.
- 4096 raw events per channel inside the horizon, before eligibility.
- The latest fallback probes 256 events; a long ineligible or reply-only tail
can leave `latest_id` null.
- Tag documents over 8192 bytes or malformed relevant tags leave the message
out. 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 and left
out.
- DB statement/lock deadlines and HTTP read deadlines bound work; a write's intents
and its row refresh share one eight-second deadline after admission. Limits are
containment, not a production capacity claim.
Apply migrations 0056 through 0058 (or the equivalent desired schema). 0056
creates two empty private tables and no index on `events`, 0057 adds the
nullable `personal_read_accounts.started_at` column, and 0058 adds the nullable
`personal_read_frontiers.through_message_id` column and drops
`threads_through_timestamp`. 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. 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. The
shape is stable: existing fields, their types, status variants and what a
`mark_through` does remain. What the counts include is not yet: which messages
are unread or directed, which threads are listed, and the bounds may change
while v1 is pre-release. A client therefore replaces its rows with each
response rather than reconciling them with its own counts. Breaking shape
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](buzz-v1-extension-design.md); 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.