Skip to content
quarry Documentation
Login

Documentation

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, [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

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

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

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

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

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
}

3. Read-state and unread

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() 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:

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

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:

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

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

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:

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

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

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:

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

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

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

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

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 (Fossil's own # = msgid convention, which Fossil honours only in /chat-query; quarry links it). [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, or a leading #412) 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:

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 MentionRefs the ordinary way.

Drawbacks

Rationale and alternatives

Prior art

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

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.