Artifact f338eb8e223486a66818f047554171f55682e2d9455f44ce8a53be3923bc18dc:
- File docs/rfcs/0012-attention-plan.md — part of check-in [88bd060725] at 2026-08-29 15:27:35 on branch attention-rfc — rfcs: 0011 §14 references, reply-quotes, previews; 0012 phase 1 work (user: dmestas, size: 15421) [annotate] [blame] [check-ins using] [more...]
- Feature Name:
attention-plan(how RFC 0011 lands: five strata, each useful alone, what each touches, and what proves it) - Start Date: 2026-08-29
- Status: Draft for review
- Depends on: RFC 0011 (the design); RFC 0006 (strata — this is one); RFC 0003 (the precedent for a plan beside its RFC)
- Upstream: notify#300–#304 (RFC 0011 §12); gating noted per phase
Summary
Five landings, stacked, bottom-up. Each is a complete, tested, API-first unit that ships value without the ones above it. Phases 0–2 need nothing outside quarry; Phase 3 waits on three notify issues; Phase 4 is optional. Estimates are focused engineering days, not calendar.
| phase | lands | days | upstream |
|---|---|---|---|
| 0 | what exists reaches people | 2–3 | — |
| 1 | a chat that cannot lose a message | 5–7 | — |
| 2 | attention in the open tab | 4–6 | — |
| 3 | push with no tab; an inbox you can clear | 2 (+3–4 in notify) | notify#300–#302 |
| 4 | Fossil bridge, promote, robot | 3–4 | — |
How this plan uses the stack
- Ops first. Every phase begins by registering its ops;
make docsregeneratesdocs/api.md; the harness enumerates the registry. A phase whose API is not indocs/api.mdis not done (Principle 5). - HTML is a translator. Handlers parse form → In, call the op, render
Out. Live pages are the same Out rendered as
datastar-patch-elements. - Tests at three levels, the existing shape: package tests for planes
(
internal/chat,internal/oltp),internal/webtests for rendering and attribute shape (no copy pins — assert data hooks, never display text), andinternal/harnessend-to-end through real ops with a real NATS. - NATS is invisible to callers (Principle 6). New subjects
(
attn.<account>) live behindinternal/eventsandinternal/chat; nothing ininternal/apiorinternal/webimportsnats.go. - Branch and landing per phase, stacked;
zit landing acceptbottom-up; trunk merged into each segment before accept;go vet,go build ./...,go test ./...,gofmt -lgreen at every accept.
Phase 0 — what exists reaches people
Why first. Today every trigger reaches nobody: no subscriber, no topic member, no workflow exists unless an operator typed them. Fixing this makes the current forum-reply and mention paths work end to end and gives every later phase a working notify to build on.
Touches
internal/notify/notify.go—EnsureSubscriber,EnsureTopic,AddTopicMember,RemoveTopicMember,EnsureWorkflow(GET then POST until notify#303). Same thin-edge style; 5 s timeouts; nil client no-ops.internal/notify/workflows.go— the five workflow definitions as Go values (steps, control values), the single sourcedocs/running.mdcurrently spells out incurl.internal/api/register.go—mentions()takes a resolver; only handles with any cap on the repo survive.DepsgainsProvision(interface, nil-safe).account.add/grant/revoke,forum.subscribecall it.internal/api/subs.go,register.go:8517— TxnIDs become address-based (mention:chat:<repo>:<seq>:<who>once Phase 1 gives chat a seq; until then<at>at nanosecond precision).internal/api/register.go:8493—chat.postbecomesAuth: Token,Caps: "C".cmd/quarry/main.go— provisioning adapter;EnsureWorkflowsat startup after the notify client exists; registerchat.quarry.internal/oltp—mentiontable (RFC 0011 §4).docs/running.md— delete thecurlritual; describe what quarry does.
Proves it (internal/harness/notify_test.go, a fake notify HTTP server
recording calls)
- adding an account creates the subscriber; granting a repo adds it to
repo-<name>; revoking removes it; - startup creates absent workflows and leaves present ones alone;
@unknownin a reply triggers nothing;@alicewith a cap triggers once with an address-based TxnID; the same post replayed triggers nothing new;- an anonymous
POST /api/v1/r/{repo}/chatis refused.
Phase 1 — a chat that cannot lose a message
Touches
internal/chat/chat.go—MessagegainsSeq, Kind, Ref, Origin;Postreturns the seq (PubAck.Sequence);History(code, before, limit)pages by seq;Tail(code, from) (<-chan Message, cancel)is an ordered consumer fromfrom+1that never drops;Subscribestays for ticks.Fold([]Message)applies edits/deletes for renderers.internal/oltp/chatread.go—chat_read;MarkChatRead(repo, account, seq)with the same "advance or no-op" guard asforum_read;ChatUnread(account) map[repo]Unread.internal/api/register.go—chat.history(cursor),chat.stream(SSE op: the registry's first streaming Query; the router servestext/event-streamwithid:and readsLast-Event-ID),chat.edit,chat.delete,chat.read,chat.unread,people.mention.internal/web/web.go— chat page: grouped rendering, the divider atchat_read.seq, markdown throughmarkup, mention links, Datastar composer (@postto the op route; pending row via a signal; failure state), jump-to-latest,before=paging, own-message edit/delete controls, a "mark read" form for no-JS;/r/{repo}/chat.sserewritten onTailwithid:; nav unread count and mention mark fromchat.unread;@autocomplete fragment frompeople.mention.cmd/quarry/main.go—chatAdapterexposesTail.internal/markup— the resolver learns#seq,repo:prefixes and quarry URLs (RFC 0011 §14); oneResolve(token) Refused by chat, forum, tickets and landings.internal/api—ref.resolve,chat.get;chat.posttakesref.internal/oltp/chatref.go—chat_ref, pruned with the window.internal/web—/r/{repo}/ref.fragrendering the per-kind card; one shared<div popover id="refpop">in the base layout; reference links carrydata-on:mouseenter__debounce.300ms/data-on:focus(hover media only) and a tap handler for touch; reply-quote block; "N replies" link;chat.getpermalink page incl. the aged-out state; the "chat" row on ticket, check-in, landing and thread pages.
Proves it
internal/markup:#412,sqlite:#412,[hash],sqlite:[hash],@alice, and a pasted URL each resolve to the right kind; an unknown token stays text; a cross-repo token the reader cannot see resolves to nothing.internal/web: a reference renders as a plain<a href>with the Datastar attributes as enhancement (assert shape, not copy); the card for each kind carries itsdata-kindand an open link; the popover is the single shared element; a reply renders its quote above the text.internal/oltp:chat_refinsert at post, prune at window, count/last per target.internal/chat: seq monotonic;History(before)pages exactly;TailfromNyieldsN+1…then live; a slow reader loses nothing (compare against the drop-on-backpressure behaviour ofSubscribe).internal/oltp: read pointer advances only forward; unread arithmetic; per-account isolation.internal/web: the stream emitsid:on every event; reconnecting withLast-Event-ID: Nreplays fromN+1(driveliveChatwith a fakeChatLive); the divider sits at the right row; the composer attribute targets the op route and carries no reload (syncurl_test.gois the pattern for asserting attribute shape).internal/harness: post, hide (close the stream), post twice, reopen withLast-Event-ID→ both arrive in order; edit within window replaces; delete tombstones;chat.readclears unread and a new post re-raises it; a mention of a capped handle sets the mention mark for that account only.
Watch for. Second-granular timestamps bit forum_read yesterday; chat
uses seq for ordering and RFC3339Nano for display, so the class of bug
cannot recur — pin that in a test anyway.
Phase 2 — attention in the open tab
Touches
internal/events/attn.go—QUARRYATTNstream (attn.>, 30 d / 1 000 per subject);Attend(account, AttentionEvent) (seq, error);TailAttention(account, from).internal/api/register.go—fireNotifypublishes toattn.<who>before triggering notify, for every person-addressed event;me.attentionSSE op;inbox.count(local tail count when notify is absent).internal/web/web.go—/me.ssein the base layout (gated to repo pages unless--h2or a proxy flag says the deployment multiplexes);#toastsregion witharia-live="polite";#inbox-dotfrom the stream instead of the one-shot/inbox.unread; title count via a signal; the Notification-API call only when a preference the user set says so.internal/api—chat.robotop andinternal/chat/robot.go: aQUARRYEVTconsumer per opted-in repo writingKind: robotlines.docs/running.md— the h2 note.
Proves it
internal/events: attention seq per account; tail fromN.internal/web:/me.sseemits patches for#inbox-dotand#toastswith the link; the toast carriesrole="status"; the title signal is set; no Notification call without the preference.internal/harness: a reply to a subscribed thread appears on the subscriber's attention stream and not the author's; the same with notify absent (--notifyunset) — the whole point of the local subject; robot lines appear for a check-in when the pattern matches and never count as unread.
Phase 3 — push with no tab; an inbox you can clear
Gated on notify#300 (mark), notify#301 (count/pagination), notify#302 (webpush). The quarry half can be built against a fake notify implementing the agreed routes and merged once the real ones land; do not ship a quarry-side workaround for any of the three.
Touches
internal/notify/notify.go—Mark,Count,Inbox(before, limit),PutCredentials(subscriber, provider, payload).internal/api—inbox.list(addsreasonfrom the workflow name and payload),inbox.mark,inbox.count,me.prefs,me.prefs.set,me.push.subscribe/unsubscribe.internal/web— inbox page: reason label, link, mark read / done / all, unsubscribe-from-thread inline (callsforum.subscribewithunsub),before=pagination,j/kkeys as enhancement; preferences page: family × channel matrix, per-repo mute, the push control (registers/sw.js, posts the subscription; says "needs JavaScript" without it — RFC 0011 §11).internal/web/sw.js— served static, versioned by build; ~60 lines.internal/notify/workflows.go— push steps added to the immediate workflows; digest step onquarry-activity.
Proves it
internal/notify: each client method against a recorded fake.internal/web: inbox rows carry a reason data attribute and a link; the mark form targets the op route; the push control renders its no-JS fallback.internal/harness: mark-all clears the count; unsubscribe from the inbox stops the next reply from arriving; the push subscription reaches notify's credentials route with the subscriber id.
Phase 4 — Fossil bridge, promote, robot polish
Touches
internal/api—chat.promote(reads the seq range, opens a forum thread through the ordinary write path with a back-link, posts aKind: robotline in the channel pointing at it),mirror.chatbridge.internal/oltp— bridge config besidemirror.internal/chat/bridge.go— the poller/sender per RFC 0011 §9; the scheduler incmd/quarry/main.gostarts one per bridged mirror.internal/web— selection-to-promote on the chat page (checkboxes in no-JS; range select with JS); bridge settings on the mirror page.
Proves it
internal/chat: against a fixture Fossil server (the harness already spinsfossilfor mirror tests): inbound messages arrive withOrigin: fossil; anmdelbecomes a tombstone; outbound posts land with the name prefix; the bridge user's own messages are not re-imported;chat-timeline-userlines are skipped when the robot is on.internal/harness: promote opens a thread whose body is the folded messages and whose opener links back to the channel.
Definition of done, every phase
- ops registered,
docs/api.mdregenerated, harness enumerates them; - page works with JavaScript off (Principle 3), except the push control, which says so;
- no NATS import outside
internal/{events,chat,webhook}; go vet,go build ./...,go test ./...,gofmt -lclean;- one-line commit messages; rationale in the landing thread, not the commit;
- a screenshot pair (light/dark) on the landing for any page that changed.
Upstream verification
notify: #300 mark read, #301 count/pagination, #302 Web Push, #303 workflow upsert, #304 README — filed 2026-08-29. The quarry half of Phase 3 is written against their proposed routes.
dagnats: no change required, verified 2026-08-29 against both sources:
- step execution runs in notify's process — notify registers handlers with
worker.Handle(dagnatsworker/worker.go:420) and the provider HTTP call happens in notify'sinternal/delivery/*; awebpushprovider is a notifyPushProvider(internal/providers/push.go:26), thentfyprovider being the pattern; - idempotency is dagnats
WithIdempotencyKey: per-workflow KV scope, 24 h TTL, a duplicate returns the existing run rather than an error. Two consequences for quarry: TxnIDs must be KV-key-safe (no spaces or wildcards — the address-based keys in RFC 0011 §4 are), and a replay older than 24 h would notify again — quarry never re-raises on replay (RFC 0005 §2.3.4), so this is a property to pin in a test, not a risk; digestis notify's ownDigester/DigestKey, not a dagnats primitive;delayis dagnats' sleep timers. Immediate-vs-digest routing is therefore a notify workflow decision (Phase 3 workflow definitions), no engine work;- throughput at hundreds of triggers a minute is within dagnats'
work-queue shape; the default
MaxAckPendingon the task consumer was not confirmed and should be read once before Phase 2 turns the robot on for a busy repository.
Risks
- Connection budget on HTTP/1.1 — Phase 2 gates the stream to repo
pages unless the deployment multiplexes;
/tmp/qdeploy.sh's target is plain HTTP and will take the gated form. - Window vs unread — a read pointer older than seven days is fine; a
chat.promoteof a range that has aged out is refused with a clear error rather than a partial thread. - Fake-notify drift — the harness fake must be regenerated from notify's OpenAPI once the issues land; note the notify commit it was built against in the test file header.
- Concurrent sessions — other agents commit to the shared store; each phase lives in its own worktree, and trunk is merged in before accept.