- 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
[summary]: #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
[stack]: #how-this-plan-uses-the-stack
- **Ops first.** Every phase begins by registering its ops; `make docs`
regenerates `docs/api.md`; the harness enumerates the registry. A phase
whose API is not in `docs/api.md` is 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/web` tests for rendering
and attribute shape (no copy pins — assert data hooks, never display
text), and `internal/harness` end-to-end through real ops with a real
NATS.
- **NATS is invisible to callers** (Principle 6). New subjects
(`attn.<account>`) live behind `internal/events` and `internal/chat`;
nothing in `internal/api` or `internal/web` imports `nats.go`.
- **Branch and landing per phase**, stacked; `zit landing accept`
bottom-up; trunk merged into each segment before accept; `go vet`,
`go build ./...`, `go test ./...`, `gofmt -l` green at every accept.
## Phase 0 — what exists reaches people
[phase-0]: #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 source `docs/running.md`
currently spells out in `curl`.
- `internal/api/register.go` — `mentions()` takes a resolver; only handles
with any cap on the repo survive. `Deps` gains `Provision` (interface,
nil-safe). `account.add/grant/revoke`, `forum.subscribe` call 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.post` becomes `Auth: Token`,
`Caps: "C"`.
- `cmd/quarry/main.go` — provisioning adapter; `EnsureWorkflows` at
startup after the notify client exists; register `chat.quarry`.
- `internal/oltp` — `mention` table (RFC 0011 §4).
- `docs/running.md` — delete the `curl` ritual; 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;
- `@unknown` in a reply triggers nothing; `@alice` with a cap triggers
once with an address-based TxnID; the same post replayed triggers
nothing new;
- an anonymous `POST /api/v1/r/{repo}/chat` is refused.
## Phase 1 — a chat that cannot lose a message
[phase-1]: #phase-1--a-chat-that-cannot-lose-a-message
**Touches**
- `internal/chat/chat.go` — `Message` gains `Seq, Kind, Ref, Origin`;
`Post` returns the seq (`PubAck.Sequence`); `History(code, before, limit)`
pages by seq; `Tail(code, from) (<-chan Message, cancel)` is an ordered
consumer from `from+1` that never drops; `Subscribe` stays 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 as `forum_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 serves
`text/event-stream` with `id:` and reads `Last-Event-ID`), `chat.edit`,
`chat.delete`, `chat.read`, `chat.unread`, `people.mention`.
- `internal/web/web.go` — chat page: grouped rendering, the divider at
`chat_read.seq`, markdown through `markup`, mention links, Datastar
composer (`@post` to 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.sse` rewritten
on `Tail` with `id:`; nav unread count and mention mark from
`chat.unread`; `@` autocomplete fragment from `people.mention`.
- `cmd/quarry/main.go` — `chatAdapter` exposes `Tail`.
- `internal/markup` — the resolver learns `#seq`, `repo:` prefixes and
quarry URLs (RFC 0011 §14); one `Resolve(token) Ref` used by chat,
forum, tickets and landings.
- `internal/api` — `ref.resolve`, `chat.get`; `chat.post` takes `ref`.
- `internal/oltp/chatref.go` — `chat_ref`, pruned with the window.
- `internal/web` — `/r/{repo}/ref.frag` rendering the per-kind card; one
shared `<div popover id="refpop">` in the base layout; reference links
carry `data-on:mouseenter__debounce.300ms` / `data-on:focus` (hover
media only) and a tap handler for touch; reply-quote block; "N replies"
link; `chat.get` permalink 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 its `data-kind` and an open link; the popover is
the single shared element; a reply renders its quote above the text.
- `internal/oltp`: `chat_ref` insert at post, prune at window, count/last
per target.
- `internal/chat`: seq monotonic; `History(before)` pages exactly; `Tail`
from `N` yields `N+1…` then live; a slow reader loses nothing (compare
against the drop-on-backpressure behaviour of `Subscribe`).
- `internal/oltp`: read pointer advances only forward; unread arithmetic;
per-account isolation.
- `internal/web`: the stream emits `id:` on every event; reconnecting with
`Last-Event-ID: N` replays from `N+1` (drive `liveChat` with a fake
`ChatLive`); the divider sits at the right row; the composer attribute
targets the op route and carries no reload (`syncurl_test.go` is the
pattern for asserting attribute shape).
- `internal/harness`: post, hide (close the stream), post twice, reopen
with `Last-Event-ID` → both arrive in order; edit within window replaces;
delete tombstones; `chat.read` clears 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
[phase-2]: #phase-2--attention-in-the-open-tab
**Touches**
- `internal/events/attn.go` — `QUARRYATTN` stream (`attn.>`, 30 d / 1 000
per subject); `Attend(account, AttentionEvent) (seq, error)`;
`TailAttention(account, from)`.
- `internal/api/register.go` — `fireNotify` publishes to `attn.<who>`
before triggering notify, for every person-addressed event; `me.attention`
SSE op; `inbox.count` (local tail count when notify is absent).
- `internal/web/web.go` — `/me.sse` in the base layout (gated to repo pages
unless `--h2` or a proxy flag says the deployment multiplexes);
`#toasts` region with `aria-live="polite"`; `#inbox-dot` from 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.robot` op and `internal/chat/robot.go`: a
`QUARRYEVT` consumer per opted-in repo writing `Kind: robot` lines.
- `docs/running.md` — the h2 note.
**Proves it**
- `internal/events`: attention seq per account; tail from `N`.
- `internal/web`: `/me.sse` emits patches for `#inbox-dot` and `#toasts`
with the link; the toast carries `role="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 (`--notify` unset) — 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
[phase-3]: #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` (adds `reason` from 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 (calls `forum.subscribe` with `unsub`),
`before=` pagination, `j/k` keys 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 on `quarry-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
[phase-4]: #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 a
`Kind: robot` line in the channel pointing at it), `mirror.chatbridge`.
- `internal/oltp` — bridge config beside `mirror`.
- `internal/chat/bridge.go` — the poller/sender per RFC 0011 §9; the
scheduler in `cmd/quarry/main.go` starts 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
spins `fossil` for mirror tests): inbound messages arrive with
`Origin: fossil`; an `mdel` becomes a tombstone; outbound posts land with
the name prefix; the bridge user's own messages are not re-imported;
`chat-timeline-user` lines 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
[done]: #definition-of-done-every-phase
- ops registered, `docs/api.md` regenerated, 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 -l` clean;
- 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
[upstream]: #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` (dagnats `worker/worker.go:420`) and the provider HTTP
call happens in notify's `internal/delivery/*`; a `webpush` provider is
a notify `PushProvider` (`internal/providers/push.go:26`), the `ntfy`
provider 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;
- `digest` is notify's own `Digester`/`DigestKey`, not a dagnats
primitive; `delay` is 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 `MaxAckPending` on the task consumer was
not confirmed and should be read once before Phase 2 turns the robot on
for a busy repository.
## Risks
[risks]: #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.promote` of 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.