Skip to content
quarry Artifact [3608691243]
Login

Artifact [3608691243]

Artifact 3608691243b1cb1cf729ee690c38c6e8c8b765250a8522465af067e3079c8c81:


- Feature Name: `attention` (chat that never loses a message, and
  notifications that reach a person wherever they are — hooks become push)
- Start Date: 2026-08-29
- Status: Implemented (delivery plan: 0012). Its live model reaches
  chat, CI, landings, the inbox and the activity stream, and does NOT
  yet reach a team's room, forum threads, ticket pages or a team's
  settings surfaces — ticket 82b9615eaf3d. Streaming is the default
  here, so those four are a debt against this RFC rather than a choice
  made about them.
- Depends on: RFC 0001 §5 (chat: transport is NATS, durability is the
  window), §8 (OLTP), §9 (the ops seam), Principles 2, 3, 5, 6; RFC 0004 §4
  (capability letters); RFC 0005 (event vocabulary — what may and may not be
  raised); RFC 0008 (roles)
- Upstream: notify#300 (mark read), notify#301 (count/pagination), notify#302
  (Web Push provider), notify#303 (workflow upsert), notify#304 (README);
  none blocks Phase 0–2 of RFC 0012. dagnats — none expected (§12).
- Findings, with the survey that produced them:
  https://claude.ai/code/artifact/ed3bb5ca-d523-4e99-8446-3c48ffd82f2a

## Summary
[summary]: #summary

Quarry already has the transport for live chat (a JetStream window per
repository), the vocabulary for hooks (RFC 0005), durable signed webhooks, and
a notify companion that owns delivery. What it lacks is the last mile: nobody
is ever subscribed, a mention is fired at a handle nobody checked, the chat
stream cannot resume after a hidden tab, and the inbox cannot be marked read.

This RFC adds three things and changes no plane's truth status:

1. **A chat that cannot lose a message.** Every message has a sequence; every
   stream carries it; a reconnecting client replays from where it stopped.
   Unread is a per-account read pointer in OLTP, the `forum_read` shape.
   Mentions are resolved against accounts before anything is fired.
2. **An attention stream** — one per open tab — carrying what needs a
   person's eye: mentions, replies, verdicts, the inbox count. It works with
   no notify configured, because the wake signal is a NATS subject quarry
   owns. When notify is configured, the same stream also carries the inbox.
3. **Push when no tab is open**, delivered by notify through a Web Push
   provider, with quarry doing the two things only it can: provisioning its
   accounts into notify, and serving the service worker that receives.

References — `^412`, `#12`, `[hash]`, `@alice`, across repositories — resolve
through one resolver on every surface and preview in a popover that degrades
to a plain link (§14).

Everything is an op first (Principle 5); the HTML pages are translations of
those ops (§9); every live surface is plain SSE with `id:` and
`Last-Event-ID` (Principle 6); and every page still works with JavaScript
off, with one exception this RFC records explicitly (§11).

Fossil's chat is neither foundation nor competitor: it is a connector, bridged
per mirror when an operator opts in (§9), and quarry chat is whole without it.

## Motivation
[motivation]: #motivation

Three observations from the survey drive the shape.

**Push needs two transports.** A tab that is open wants a stream; a tab that
is closed wants the operating system to say something. Campfire (Basecamp's
ONCE) is the closest analogue to quarry's stack — server-rendered, SSE as
enhancement — and it keeps the two strictly separate: Turbo streams for
presence, Web Push for absence. Making one transport do both is how chat apps
end up with a native client.

**Datastar closes GET streams on hidden tabs** (`openWhenHidden` defaults to
`false`) and stops after ten retries. So a stream that cannot resume *will*
lose messages in ordinary use — switch tabs, close the laptop — and the loss
is silent. The fix is not on the client: Datastar's SSE client already
re-sends `last-event-id` on retry (verified in
`library/src/plugins/actions/fetch.ts`). The fix is a server that puts an
`id:` on every event and can answer "everything after N".

**Notify owns channels; quarry owns who.** RFC 0001 §5 drew this line and it
holds: digests, preferences, email, SMS, chat providers and idempotent fan-out
are all in notify today. The four things a live inbox needs that notify lacks
— mark read, unread count, pagination, Web Push — are small and additive
*there*, and would be wrong *here*.

## Guide-level explanation
[guide]: #guide-level-explanation

**What a person sees.**

On a repository's chat page, messages are grouped by author and minute, with
markdown and highlighted code. A divider marks where they stopped reading.
The repository nav shows an unread count; a mention shows a separate mark.
Typing `@` offers the people who can see the repository. Sending does not
reload the page; a message shows as pending, then settles, or shows "not
sent — retry" if it did not. Coming back to the tab after an hour, the
messages posted meanwhile are there, in order, above the divider. Their own
messages can be deleted, or edited within five minutes, and the log says so.
A run of messages can be promoted to a forum thread, which is where anything
worth keeping goes — chat itself still forgets after seven days.

On every page, a small toast says "alice mentioned you in sqlite chat" with a
link, the inbox mark in the bar updates, and the tab title gains a count. With
notifications allowed in the browser, the same arrives as a system
notification while the tab is in the background. With push enabled on a
device, it arrives with no tab open at all.

The inbox lists what happened *to them* — "mentioned you", "replied to a
thread you follow", "your landing was accepted" — each with a reason, a link,
and a way to mark it done or stop following the thread. Preferences say which
kinds arrive immediately, which arrive as a digest, and on which channel.

**What an operator sees.**

Nothing to `curl`. Adding an account makes it a notify subscriber; granting
it a repository puts it in that repository's activity topic; starting quarry
ensures the workflows exist. A mirror of a Fossil upstream can, per mirror,
bridge that upstream's chat into the quarry channel and back.

**What an integrator sees.**

`GET /api/v1/r/{repo}/chat/stream` and `GET /api/v1/me/attention` are plain
SSE with JSON `data:` and monotonic `id:`; `Last-Event-ID` resumes. A chat
client, a bot, or a desktop notifier is buildable from the OpenAPI document
alone (Principle 5). The NATS subjects remain available to those who want
them and remain unsupported as a contract (Principle 6).

## Reference-level explanation
[reference]: #reference-level-explanation

### 1. Ops — the API is the feature
[ops]: #1-ops--the-api-is-the-feature

Every capability below is registered in the ops registry first; the HTML
routes are thin translators of the same ops (§9). Caps follow RFC 0004 §4.

| op | kind | auth | caps | route |
|---|---|---|---|---|
| `chat.history` | Query | anonymous | `C` | `GET /api/v1/r/{repo}/chat` — gains `before` (seq cursor) and `limit`; each message carries `seq`; response carries `next` |
| `chat.stream` | Query (SSE) | anonymous | `C` | `GET /api/v1/r/{repo}/chat/stream` — `id: <seq>`, honours `Last-Event-ID` |
| `chat.post` | Command | **token** | `C` | `POST /api/v1/r/{repo}/chat` — `text`, optional `ref` (reply-to seq); returns `seq`; resolves mentions and references |
| `chat.edit` | Command | token | `C` (own, ≤5 min) | `POST /api/v1/r/{repo}/chat/{seq}/edit` |
| `chat.delete` | Command | token | `C` (own) / `a` (any) | `POST /api/v1/r/{repo}/chat/{seq}/delete` |
| `chat.read` | Command | token | `C` | `POST /api/v1/r/{repo}/chat/read` — `seq` (default: latest) |
| `chat.unread` | Query | token | `C` | `GET /api/v1/me/chat/unread` — per-repo `{unread, mentions, since}` |
| `chat.get` | Query | anonymous | `C` | `GET /api/v1/r/{repo}/chat/{seq}` — one message, folded, with its replies; the permalink; says `expired` past the window |
| `ref.resolve` | Query | anonymous | per target | `GET /api/v1/r/{repo}/ref?ref=` — resolves one reference token to `{kind, id, title, summary, state, href, expired}`; the preview card's data (§14) |
| `chat.promote` | Command | token | `C` + `3` | `POST /api/v1/r/{repo}/chat/promote` — `from`, `to` seq → opens a forum thread; returns the thread root |
| `chat.robot` | Command | token | `a` | `POST /api/v1/r/{repo}/chat/robot` — event patterns (webhook syntax) or empty to disable |
| `people.mention` | Query | token | `h` | `GET /api/v1/r/{repo}/mention?q=` — handles with any cap on the repo, ≤10 |
| `me.attention` | Query (SSE) | token | login | `GET /api/v1/me/attention` — `id: <seq>`, `Last-Event-ID` |
| `inbox.list` | Query | token | `p` | `GET /api/v1/me/inbox?before=&limit=` — proxies notify; adds `reason` |
| `inbox.mark` | Command | token | `p` | `POST /api/v1/me/inbox/mark` — `ids` or `all`, `as: read\|done` |
| `inbox.count` | Query | token | `p` | `GET /api/v1/me/inbox/count` |
| `me.prefs` / `me.prefs.set` | Query / Command | token | `p` | `GET\|POST /api/v1/me/prefs` — family × channel matrix, mute per repo |
| `me.push.subscribe` / `.unsubscribe` | Command | token | `p` | `POST /api/v1/me/push` / `.../delete` — a browser `PushSubscription` |
| `mirror.chatbridge` | Command | token | `a` | `POST /api/v1/r/{repo}/mirror/chatbridge` — `url, user, password` or detach |

`chat.post` changes from `Auth: Anonymous` to `Auth: Token`. Anonymous
reading stays; anonymous writing was never gated at the API and is a
mentions-from-"anonymous" bug, not a feature.

**One namespace for async shapes** (§9): `chat.stream` and `me.attention`
emit the same `chatMessage` and `attentionEvent` structs the OpenAPI document
describes. The HTML pages' `/r/{repo}/chat.sse` and `/me.sse` emit
`datastar-patch-elements` rendered *from* those structs — same op, second
translator.

### 2. The chat plane, addressed by sequence
[plane]: #2-the-chat-plane-addressed-by-sequence

The message identity is the JetStream sequence on `chat.<code>`. Nothing new
is minted; the number already exists and is monotonic per channel.

```go
type Message struct {
    Seq    uint64 `json:"seq"`
    Kind   string `json:"kind"`   // "msg" | "edit" | "del" | "robot"
    Ref    uint64 `json:"ref,omitempty"` // edit/del: the seq it replaces
    Repo   string `json:"repo"`
    User   string `json:"user"`
    Text   string `json:"text"`
    Origin string `json:"origin,omitempty"` // "" | "fossil" | "robot"
    At     string `json:"at"`     // RFC3339Nano UTC
}
```

- **Append-only.** Edit and delete are new messages that reference an
  earlier `seq`; the reader folds them. History stays honest, replay stays
  trivial, and nothing needs a delete permission on the stream.
- **Replay is an ordered consumer**, not the core subscription. Today
  `Subscribe` drops on backpressure with no marker. A per-client ordered
  consumer starting at `Last-Event-ID + 1` (or at "now" for a fresh page)
  never drops and never needs the 32-slot buffer. Core `Subscribe` stays for
  `Watch`-style "something changed" ticks, where dropping is correct.
- **Paging backward** is `before=<seq>`: an ordered consumer with
  `StartSequence` walking a bounded range — the same contract Fossil's
  `chat-poll?before=` offers.
- **Window semantics are unchanged** (RFC 0001 §5): 7 days / 10 000 per
  channel. A read pointer older than the window simply means everything
  visible is unread. Anything that must outlive the window goes through
  `chat.promote` into the forum, where it is an artifact and syncs.

### 3. Read-state and unread
[unread]: #3-read-state-and-unread

```sql
CREATE TABLE IF NOT EXISTS chat_read (
  repo    TEXT NOT NULL,
  account TEXT NOT NULL,
  seq     INTEGER NOT NULL,  -- highest seq the account has seen
  at      TEXT NOT NULL,
  PRIMARY KEY (repo, account)
);
```

The `forum_read` shape, one row per channel rather than per thread, and the
same write guard: a re-visit that advances nothing is a read, not a write.
Unread count is `last seq on the subject − seq`, one `GetLastMsg` call, no
scan. The mention mark is separate (§4), because "unread" and "for me" are
different questions and every chat app that conflates them is muted within a
week.

`chat_read` is authoritative per-account state in the sense of RFC 0001 §8:
small, OLTP, not derivable from the DAG. It passes disposability (§11) the
way `forum_read` does — a wiped OLTP forgets where people were, which is a
loss of convenience, not of truth.

### 4. Mentions, resolved
[mentions]: #4-mentions-resolved

`mentions()` today returns every `@token`. It will return only tokens that
name a real account with any EFFECTIVE capability on the repository —
explicit grant or category default alike (RFC 0008's grants feed
`EffectiveCaps`, which is the oracle, so on a public repo this is "any real
account", since the public-read default already gives every account
something). Unknown handles render as plain text and fire nothing. The
mention is recorded before it is raised:

```sql
CREATE TABLE IF NOT EXISTS mention (
  repo TEXT NOT NULL, ref TEXT NOT NULL,   -- chat:<seq> | forum:<hash>
  who  TEXT NOT NULL, by TEXT NOT NULL, at TEXT NOT NULL,
  PRIMARY KEY (repo, ref, who)
);
```

which makes `chat.mentioned` an **operational** event under RFC 0005 §2.1 —
a fact about a person, backed by a row — and the one chat event this RFC
adds to the vocabulary. `chat.posted` is deliberately not added: it is
neither derived from the DAG nor operational state, it would put every line
of chat on every `*` webhook, and Principle 6 already lets an integrator who
wants the firehose read `QUARRYCHAT`.

Idempotency keys become address-based: `mention:chat:<repo>:<seq>:<who>`
and `mention:forum:<hash>:<who>`, never text-based.

### 5. The attention stream
[attention]: #5-the-attention-stream

A small JetStream stream, `QUARRYATTN`, subject `attn.<account>`, 30 days /
1 000 per subject. `fireNotify` publishes there *first*, for every event
addressed to a person (mention, reply, landing verdict, CI verdict on a
landing they opened), and then triggers notify if configured. The order
matters: an open tab is told by quarry, not by a round trip through notify,
and a forge with no notify still tells people things.

`GET /api/v1/me/attention` tails `attn.<account>` from `Last-Event-ID`.
`GET /me.sse` renders the same events as Datastar patches:

- `#inbox-dot` — count and title (from `inbox.count` when notify is
  configured, else from the stream's unread tail);
- `#toasts` — an appended `<aside role="status" aria-live="polite">` with the
  link, self-dismissing;
- a `title` signal the base layout reflects as `(3) quarry`;
- when the page is visible and the browser has granted permission, a
  `Notification` — the first-time prompt is only ever shown from an explicit
  control in preferences, never on page load.

The stream is opened by the base layout on every page. On plain HTTP/1.1 a
browser allows six connections per origin, and a repository page already
opens one or two streams; RFC 0012 Phase 2 requires h2 in front (TLS or
h2c at the proxy) before the stream goes on every page, and gates it to repo
pages otherwise. This is a deployment constraint, not a design choice, and
`docs/running.md` will say so.

### 6. Push when no tab is open
[push]: #6-push-when-no-tab-is-open

Quarry serves two static things: `/sw.js` (receive a push → `showNotification`
with the link; click → focus or open the tab) and a preferences control that
registers it and posts the resulting `PushSubscription` to
`me.push.subscribe`. Quarry stores nothing; it forwards the subscription to
notify as subscriber credentials (`PUT /v1/subscribers/{id}/credentials`,
provider `webpush`), which is where every other channel's credentials live.

Delivery is notify's `webpush` provider (§12, issue 3): VAPID keys in notify's
environment, RFC 8291 encryption via `webpush-go`, a `404`/`410` from the
push service deleting the credential. Quarry never holds a VAPID key.

Routing is by workflow, defined in quarry (§7) and run by notify:

| workflow | steps |
|---|---|
| `quarry-mention` | in_app → push (immediate) |
| `quarry-forum-reply` | in_app → push (immediate) |
| `quarry-landing` (opened/accepted/rejected, addressed to opener/reviewers) | in_app → push |
| `quarry-activity` | digest 15 min → in_app; email optional per preference |
| `quarry-error`, `quarry-slow` | in_app → push, to `admins` (unchanged) |

Preferences (`me.prefs`) are notify preferences surfaced in quarry: per
workflow family × channel (in-app, push, email), plus a per-repo mute that
quarry implements by not triggering.

### 7. Provisioning is quarry's job
[provisioning]: #7-provisioning-is-quarrys-job

Today `docs/running.md` asks an operator to create subscribers, topics, topic
members and workflows by hand, and nothing in Go ever does. That is the gap
that makes every trigger today reach nobody. Quarry owns the mapping and so
must own the state:

- `account.add` / first login → `EnsureSubscriber(account)`;
- `account.grant` / `forum.subscribe` / `chat.read` (first) →
  `EnsureTopicMember(repo-<name>, account)`; `account.revoke` → remove;
- startup → `EnsureWorkflows()`, definitions in
  `internal/notify/workflows.go` as Go values, upserted (until notify#303
  lands: GET, then POST if absent — never PUT over an operator's edits);
- all best-effort, logged, retried on the next occasion; a notify outage
  never fails the op that raised it (RFC 0001 §5).

The `notify.Client` grows exactly those methods. It stays a thin edge.

### 8. Capabilities
[caps]: #8-capabilities

`C` gates reading, posting, read-state and own-message edit/delete, as RFC
0004 already says for chat. Deleting another's message is `a`. Inbox,
preferences and push are `p` + login — they are about the person, not a
repository. Mention lookup is `h`, because it reveals handles. The bridge and
the robot are repository configuration, `a`. No new letters.

### 9. The Fossil chat bridge
[bridge]: #9-the-fossil-chat-bridge

Fossil's chat is a server-local table, never synced, gated by `C`, long-polled
through `/chat-poll` (blocks up to `chat-poll-timeout`, default 420 s),
written through `/chat-send`, with deletion markers, attachments, markdown,
and no mentions, edits, threads or read-state. Every key below was verified
against `src/chat.c`.

Per mirror, opt-in (`mirror.chatbridge`), stored in OLTP beside the mirror
row, never in the DAG:

- **Inbound.** One goroutine per bridged mirror: `GET /chat-poll?name=<last
  msgid>&raw=1` with a client timeout above the hang; each `msgs[]` entry
  becomes a `Message{Kind: msg, Origin: fossil, User: "fossil:"+xfrom}` on
  `chat.<code>`; an `mdel` entry becomes `Kind: del`. Attachments link to the
  upstream's `/chat-download`. Messages from the upstream's
  `chat-timeline-user` are skipped when the quarry robot is on for the
  channel, or every commit is announced twice.
- **Outbound.** Quarry messages with `Origin == ""` are posted to
  `/chat-send` as `msg="**name:** text"`, since Fossil attributes everything
  to the bridge login. Loop guard: inbound messages from the bridge user are
  ignored.
- **What it does not pretend.** Fossil messages cannot be edited from quarry;
  two Fossil servers have two chats; a bridged channel's history is still
  quarry's window. The bridge is a connector, in the spirit of the IRC bridge
  on Fossil's own forum, and the channel is whole without it.

### 10. The activity robot
[robot]: #10-the-activity-robot

Fossil's `chat-timeline-user` is its one good chat idea: the timeline talks in
the channel. Quarry does it on its own stream: `chat.robot` stores an event
pattern per repository (webhook pattern syntax, validated the same way), and
a consumer of `QUARRYEVT` writes `Kind: robot` lines — one compact line per
check-in, landing verdict, CI verdict — into the channel. Robot lines never
count as unread and never mention anyone.

### 11. Principle 3, and the one exception
[p3]: #11-principle-3-and-the-one-exception

Every page in this RFC renders its state on load and works through forms and
links: chat history and posting, read-state (a "mark read" button), the inbox
and its actions, preferences. Live delivery, toasts, the title count and the
mention autocomplete are enhancements; without them the page is the load-time
state plus refresh, which Principle 3 calls acceptable.

The exception is push. A service worker is JavaScript by definition, and there
is no form that can receive a push. This RFC records that as an explicit,
scoped exception: with JavaScript off, the push control in preferences says
so and nothing else changes. It is the only feature in quarry that requires
script, and it is opt-in per device.

### 12. Upstream
[upstream]: #12-upstream

notify (filed 2026-08-29):

1. **notify#300** — mark seen / read / archive endpoints for in-app messages — fields exist
   in `domain.Message`, no route sets them;
2. **notify#301** — `unreadCount` and cursor pagination on `GET /v1/messages`, plus
   `GET /v1/messages/count`;
3. **notify#302** — a `webpush` provider for the `push` step (VAPID, RFC 8291, credential
   pruning on 404/410);
4. **notify#303** — idempotent `PUT /v1/workflows/{slug}` upsert, no-op when unchanged;
5. **notify#304** — README says dagnats is embedded; the runtime dials a sidecar — fix one.

Issues 1–2 gate the inbox half of Phase 3; issue 3 gates push; issue 4 is a
convenience (§7 has the fallback). Phases 0–2 need nothing upstream.

dagnats: nothing expected. Digest, delay and idempotency already live where
notify needs them; see RFC 0012 §Upstream for the verification.

### 13. Disposability (RFC 0001 §11), applied
[disposability]: #13-disposability

`QUARRYCHAT` and `QUARRYATTN` are windows and were never truth; wiping them
loses the window, exactly as today. `chat_read`, `mention`, robot and bridge
settings are OLTP: small, authoritative, and rebuildable in the weak sense
that losing them loses convenience. Nothing here writes to the DAG except
`chat.promote`, which writes a forum thread through the ordinary write path
and is the point.

### 14. References, reply-quotes and previews
[refs]: #14-references-reply-quotes-and-previews

Decided 2026-08-29, one question at a time; recorded here so the choices
read as choices.

**Syntax — one resolver, every surface.** `^412` is a chat message by
sequence in the current channel, and `#412` is ticket 412 on every
surface, chat included; `ticket #412` still names the ticket. Until
2026-10-01 a bare `#412` in chat was the message (Fossil's own `#` =
msgid convention, which Fossil honours only in `/chat-query`); a chat
line posted before then still reads that way, edited or not, because
each line records the grammar it was written in (quarry ticket #309).
A message reference, `^412` or an old line's `#412`, renders as a chat
bubble and the number, as a ticket's renders its state and number. `^412`
is text outside chat, in code, after a word (`2^10`) and in a
footnote's `[^1]`. `[hash]` and
bare hash prefixes resolve to check-ins, tickets, threads, landings, wiki
pages and technotes exactly as the `markup` resolver already does in
forum and ticket text; `@alice` is a person; a pasted quarry URL is a
reference to whatever it names. `sqlite:^412` and `sqlite:[a91f3c2e]`
reach across repositories, resolving only when the reader can see the
target. The same syntax works in chat, forum posts, tickets and landings;
a chat reference in durable text is allowed and rendered as ephemeral.

**Reply-quotes, one timeline.** A message posted with `ref` (the Reply
control) renders a compact quote of the referenced
message — author and first line — above its own text; the quote previews
on hover and jumps on click; the original gains "3 replies →". The log
stays one scroll. `Ref` is in the data (§2), so a later "view as threads"
toggle is a rendering preference, not a migration.

**Previews degrade to links.** Without JavaScript every reference is a
plain link: `^412` to `chat.get`'s page, a hash to the artifact page, a
person to their page. With JavaScript, hover (debounced ~300 ms) or
keyboard focus fetches `/r/{repo}/ref.frag?ref=…` — the HTML translation
of `ref.resolve` — into one shared popover element positioned at the
anchor; click still navigates. On touch, the first tap opens the preview,
whose title is the link. Escape and tapping outside dismiss; focus is
never trapped. Fragments are GETs with the ingest-cursor ETag (§9), so a
hovered reference costs one request per change of the underlying store.

**Cards, per kind, glanceable.** One to three lines and an "open →":
check-in — hash, branch, author, first comment line, CI or landing badge;
ticket — status, title, last change; landing — branch → target, state,
position in its stratum; thread — title, replies, last reply; wiki —
title, first sentence; chat — author, time, folded text, reply count, or
"aged out"; person — identicon, handle, capabilities on this repository,
last active, and no address (RFC 0004 keeps those behind `e`). Diffs are
never in a card.

**Backlinks, window-bounded, labelled.** A reference from chat to an
artifact is recorded at post time:

```sql
CREATE TABLE IF NOT EXISTS chat_ref (
  repo TEXT NOT NULL, target TEXT NOT NULL,  -- artifact hash or thread root
  seq  INTEGER NOT NULL, at TEXT NOT NULL,
  PRIMARY KEY (repo, target, seq)
);
```

pruned with the channel window. An artifact page shows a "chat" row —
count, last time, link to the latest mention — beside the index's durable
`MentionRef` rows, visibly distinct from them. A link to an aged-out
message lands on `chat.get`'s honest page. Promotion (§1) writes the
references it carries into the forum thread it creates, where they become
durable `MentionRef`s the ordinary way.

## Drawbacks
[drawbacks]: #drawbacks

- **A second live stream on every page.** It is one connection, and it is
  the price of a forge that can tap someone on the shoulder; but it makes h2
  a deployment requirement for the full experience.
- **A service worker.** Sixty lines of script that Principle 3 would rather
  not exist. Scoped, opt-in, recorded.
- **Quarry now writes to notify.** Provisioning makes notify's subscriber and
  topic tables a projection of quarry's OLTP. That is the correct direction —
  quarry knows who — but it is a second place the same fact lives.
- **Append-only edit/delete** means a reader folds tombstones; a naive
  consumer of `chat.stream` sees deleted messages. The API documents `kind`
  and `ref`, and the OpenAPI example shows the fold.

## Rationale and alternatives
[alternatives]: #rationale-and-alternatives

- **WebSockets** would give one bidirectional connection per tab. SSE is what
  Datastar speaks, what `Last-Event-ID` gives resume for free, and what
  Principle 6 lists first. Sends are ordinary POSTs; nothing here needs
  client-to-server streaming.
- **Zulip-style topics** are the strongest chat-structure argument in the
  reference set. Quarry already has a durable, threaded, synced conversation
  surface — the forum — and a second threading model inside an ephemeral
  channel would compete with it. `chat.promote` is the bridge between "talk"
  and "keep". Left open in Unresolved questions.
- **Web Push in quarry** would work without notify and hold VAPID keys in a
  second place. RFC 0001 §5 says notify owns channels; the survey found the
  provider is a small addition there; the upstream-first rule applies.
- **Putting chat in `QUARRYEVT`** would give webhooks chat for free and
  break RFC 0005 §2.1's classification while flooding every `*` subscription.
  `chat.mentioned` is the only chat fact that is operational state.
- **Long-polling**, Fossil's choice, survives CGI. Quarry is a process; SSE
  is strictly better here.

## Prior art
[prior-art]: #prior-art

- **Fossil** `chat.c`, `alerts.md`, `hooks.md`: ephemeral local chat under
  `C`; alerts by noun; `after-receive` hooks. Quarry keeps the first two
  decisions and already exceeds the third.
- **Campfire (ONCE)**: server-rendered chat with streams as enhancement, Web
  Push through VAPID, PWA badge; ~3 700 lines. The existence proof for this
  RFC's shape.
- **Zulip**: topics; the unread model that separates "unread" from "for me".
- **Linear inbox, GitHub notifications**: one inbox, a reason on every row,
  triage rather than deletion, immediate-vs-digest split by kind.
- **Datastar** `fetch.ts`: `last-event-id` resend; `openWhenHidden`
  semantics.

## Unresolved questions
[unresolved]: #unresolved-questions

1. ~~Threads.~~ Resolved: reply-quotes in one timeline (§14); a threads
   view may follow as a rendering preference.
2. **Anonymous posting.** This RFC removes it. Keep it behind `C` for
   forges that grant `C` to `nobody`?
3. **The attention stream on every page** (h2 required) or on repository
   pages only?
4. **Read receipts / presence.** Not proposed; "recently active" only.
   Revisit once the channel has people in it.
5. **Durable archive.** RFC 0001 §5 defers it. `chat.promote` covers the
   need this RFC has seen; search over chat waits on the archive.

## Future possibilities
[future]: #future-possibilities

Reactions as `Kind: react` messages (append-only fits them); a per-channel
search once an archive exists; `chat.quarry` as the fleet channel RFC 0001 §5
names; a bot capability letter so the robot and the bridge post as accounts
rather than origins; PWA install with an app badge, which the attention
stream's count already computes.