Skip to content

Commit c1fe1d8

Browse files
committed
design the review-notifications initiative
Brief, research, alignment, frame, and task breakdown for initiative 02: per-session review notifications pushed into the agent's session over the Claude Code channels contract. An agent subscribes to changes it pushed; a poller turns Gerrit activity into self-sufficient llmxml payloads, filtered by ownership, account exclusions, and content regexes; terminal states end subscriptions automatically. Tasks map to issues #49-#63 under the Notifications 1-4 milestones. Two ADRs record the load-bearing decisions: delivery rides the claude/channel experimental capability with a raw-notification transport seam (the pinned Go SDK has no generic sender), and scope is explicit per-session subscriptions rather than account-wide watching, which failed twice in the field. Glossary gains Review notifications, Subscription, and Channel entries.
1 parent 9166fc7 commit c1fe1d8

9 files changed

Lines changed: 692 additions & 0 deletions
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Alignment: Review notifications
2+
3+
- **Date:** 2026-07-13
4+
- **Brief:** design-docs/02-review-notifications.brief.md
5+
- **Research:** design-docs/02-review-notifications.research.md
6+
- **Glossary:** docs/glossary.md (entries added: Review notifications, Subscription, Channel)
7+
8+
## Patterns Adopted
9+
10+
- **behaviorFlag flag/mirror resolution** — every new option (enable, account exclusions, content-pattern
11+
exclusions, include-own, poll interval) joins `config.Load` as a `behaviorFlag` with a `GERRIT_MCP_*` mirror.
12+
Found in internal/config/config.go:88-117, universal (5/5 existing flags). Errors aggregate via `errors.Join`;
13+
invalid exclusion regexes fail startup loudly through the same path.
14+
- **Disabled = never constructed** — subscribe/unsubscribe tools are registered only when the enable flag is on,
15+
mirroring how group-gated tools are simply never registered (cmd/go-gerrit-mcp/main.go:77-81). Gating is by the
16+
dedicated flag, not by `registry.Resolve`: this is not a capability group.
17+
- **llmxml rendering reuse** — notification payloads reuse the comment-thread pipeline and shared helpers
18+
(`accountLabel`, `timestamp`; internal/tools/get-change-comments.go, get-change.go) so pushed activity reads
19+
exactly like `get_change_comments` output the model already knows.
20+
- **golib/e sentinels with structured fields** — for the subscription store, poller, and emission path, matching
21+
internal/gerritclient conventions.
22+
- **Model-facing prompts drive behavior** — server instructions and tool descriptions teach the agent when to
23+
subscribe (after pushing a reviewable change, when awaiting a review outcome) and what auto-unsubscription means,
24+
following the first-contact prompt work.
25+
26+
## Deviations
27+
28+
- **Instructions become composed** — the static `const instructions` (cmd/go-gerrit-mcp/main.go:23) grows a
29+
conditional review-notifications section emitted only when the feature is enabled. Zero-config instructions stay
30+
byte-identical to today.
31+
- **First background goroutine** — the poller is the codebase's first concurrent component. It inherits the signal
32+
context from `run`, logs to stderr only (stdout belongs to the stdio transport), and terminates with the session.
33+
At its boundary it logs-and-retries poll failures instead of returning them — a background loop has no caller;
34+
"handle once" is satisfied by logging.
35+
- **Raw notification emission via wrapping transport** — the pinned Go SDK has no public generic notification
36+
sender, so the channel event is written as an ID-less `jsonrpc.Request` through a transport wrapper that captures
37+
the `mcp.Connection` (`Write` is documented concurrency-safe). Replace with the SDK's native API when one ships.
38+
39+
## Current State
40+
41+
The server is entirely request-driven: tools fetch from Gerrit on demand, render llmxml, return. No background
42+
work, no per-session state beyond the shared client, no server-initiated messages. An agent that pushed a change
43+
for review learns about review activity only by re-reading the change.
44+
45+
## Desired End State
46+
47+
With the feature enabled, the server declares the `claude/channel` capability, registers subscribe/unsubscribe
48+
tools, and appends channel guidance to its instructions. The agent subscribes to changes it pushed or awaits; a
49+
poller (60s default interval) batches a query over subscribed changes, cursors per change, and turns new activity —
50+
human comments and replies, votes (bare votes included), status transitions — into self-sufficient llmxml payloads
51+
pushed as `notifications/claude/channel` events. Filters drop the caller's own activity (default; flag to include),
52+
excluded accounts, and messages matching operator-configured regexes. Merged or abandoned changes produce a final
53+
notification announcing automatic unsubscription and leave the subscription set. Zero configuration keeps the
54+
server byte-identical to today: no tools, no capability, no instructions, no polling.
55+
56+
## Resolved Questions
57+
58+
- Delivery mechanism → the Claude Code channels contract; self-sufficient payloads; no consumption tool (ADR 2.1).
59+
- Notification scope → explicit per-session subscriptions; no account-wide mode (ADR 2.2).
60+
- Feature gating → dedicated flag family with env mirrors; deliberately not a capability group.
61+
- Bot noise → operator-configured account exclusion list plus content-pattern regexes; no tag-based heuristics.
62+
Invalid regex → aggregated startup failure.
63+
- Own activity → skipped by default, included via flag.
64+
- Bare human votes → included; often the awaited review outcome.
65+
- Poll interval → 60s default, flag-configurable.
66+
- Terminal states → automatic unsubscription with a final announcing notification.
67+
- Persistence → none; session lifetime; resuming agents re-subscribe.
68+
69+
## Not Yet Specified
70+
71+
- Exact flag, mirror, and tool names — frame-stage, against the glossary.
72+
- Notification payload element design (tag names, meta attributes, how threads/votes/transitions render) —
73+
frame-stage, reusing the comment-render vocabulary.
74+
- Poller mechanics detail: one batched `change:X OR change:Y` query vs per-change fetches, cursor granularity,
75+
dedup keys — frame-stage; research confirms both endpoints and per-vote dates exist.
76+
- `ServerOptions.Capabilities` override vs inferred tools capability — needs a learning test during implementation
77+
(research flags the interaction).
78+
- Research-preview operational caveats surface (README wording for `--dangerously-load-development-channels`) —
79+
frame-stage.
80+
81+
## Recorded ADRs
82+
83+
- [ADR 2.1: Review notifications ride the Claude Code channels contract](./adr/2.1-channels-contract-delivery.md)
84+
- [ADR 2.2: Session-scoped explicit subscriptions over account-wide watching](./adr/2.2-session-scoped-subscriptions.md)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Brief: Review notifications
2+
3+
- **Date:** 2026-07-13
4+
- **Initiative:** 02-review-notifications
5+
6+
## Motivation
7+
8+
An agent that pushes a change for review has no way to learn about review activity except manually re-reading the
9+
change, and an earlier account-wide poller (outside this project) proved the naive approach wrong twice: it notified
10+
about every change the account owned — so concurrent agent sessions woke each other up — and it had no activity
11+
filtering, so CI votes produced the same wake-ups as human review. The server should track review activity for
12+
exactly the changes a session subscribed to and push it to that session alone.
13+
14+
## Goals
15+
16+
- Two tools — subscribe and unsubscribe by change identifier — registered only when the review-notifications flag is
17+
enabled; model-facing prompts (server instructions, tool descriptions) teach the agent to subscribe after pushing a
18+
reviewable change or whenever it awaits a review outcome.
19+
- Self-sufficient notifications: after subscribing, the agent starts receiving review activity for that change as
20+
pushed notifications whose payload carries the new activity inline (rendered llmxml — comment threads, review
21+
messages, votes, status transitions). No consumption tool; nothing to fetch afterwards.
22+
- Per-instance, in-memory subscription set and cursor. Session scoping falls out of the stdio process model: one
23+
server per agent session, so concurrent sessions never see each other's subscriptions. No persistence; a restarted
24+
session re-subscribes.
25+
- Polling-based detection: the server polls Gerrit for subscribed changes on an interval; an empty subscription set
26+
polls nothing.
27+
- Event filtering:
28+
- Events authored by the authenticated account are skipped by default; a flag includes them.
29+
- A configurable account exclusion list (usernames/IDs) silences unwanted accounts, e.g. noisy bots. No tag-based
30+
bot heuristics: whether a bot is noise or signal (a CI verdict) is the operator's call, and per-user defaults
31+
belong in the operator's shared client configuration, not in server heuristics.
32+
- A configurable content-pattern exclusion list drops individual messages and comments by their text, regardless
33+
of author. Account exclusion is too coarse for accounts that mix signal and noise: an AI reviewer's verdict
34+
matters while its "review started" announcement does not, and a human comment that merely summons a bot is
35+
noise from an account that otherwise matters.
36+
- Human votes without comment text are included — a bare vote is often the awaited review outcome.
37+
- Automatic unsubscription on terminal states: when a subscribed change reaches merged or abandoned, the review is
38+
almost certainly over — the final notification carries the transition, tells the model the subscription ended
39+
automatically and no further notifications will arrive, and the poller drops the change.
40+
- Dedicated flags with environment mirrors, following the existing flag/mirror pattern: enable flag, account
41+
exclusion list, content-pattern exclusion list, include-own toggle, poll interval. This is deliberately not a
42+
capability group.
43+
44+
## Non-goals
45+
46+
- Notification channel configuration (multiple channels, per-channel settings) — dedicated follow-up work.
47+
- Persistence of subscriptions or cursors across sessions.
48+
- Attention-set integration, SSH stream-events, webhooks — alternative sources deliberately rejected for now.
49+
- Notifying about changes the account owns but the session never subscribed to.
50+
51+
## Constraints
52+
53+
- Zero configuration means the feature does not exist: no tools registered, no instructions emitted (ADR 1.2 safety
54+
posture: defaults are contractual).
55+
- Subscribing is trail-free — pure local state, nothing visible on the Gerrit instance.
56+
- Project scoping continues to confine every operation, subscriptions included.
57+
- Notification payloads are llmxml (ADR 1.3).
58+
59+
## Key risks
60+
61+
- Gerrit's `updated` timestamp moves on any mutation, so detail fetches may be triggered by events the filters then
62+
discard — a cost concern, not a correctness one.
63+
- A dead session cannot unsubscribe; the poller must terminate subscriptions meaningfully when a change reaches a
64+
terminal state (merged/abandoned) instead of polling forever.
65+
- Push delivery depends on what MCP clients actually surface to the model mid-session; payload design must not
66+
assume more than the protocol guarantees.
67+
68+
## Flagged term ambiguities
69+
70+
- "Review notification channel" — the user-facing name of the feature and its flag family; needs a canonical
71+
glossary entry distinguishing it from capability groups.
72+
- "Subscription" — the per-session tracked set; needs a definition that pins its session lifetime and trail-free
73+
nature.
74+
75+
## Questions for research
76+
77+
- How MCP server-to-client push works in the Go SDK (notification types available to a stdio server) and what
78+
mainstream clients surface to the model mid-session.
79+
- How to detect new votes and distinguish vote-only updates: `ChangeMessageInfo` tags/epochs vs detailed labels.
80+
- Whether comment deltas are derivable from change messages alone or require comment-list diffing per poll.
81+
- Batched change query behavior and limits for `change:X OR change:Y` polling.
82+
- What exactly moves a change's `updated` field, and its timestamp granularity.
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# Frame: Review notifications
2+
3+
- **Date:** 2026-07-13
4+
- **Alignment:** design-docs/02-review-notifications.alignment.md
5+
6+
Naming fixed by this frame (per glossary): tools `subscribe_change` / `unsubscribe_change`; flag family
7+
`--review-notifications` (enable), `--review-notifications-poll-interval`, `--review-notifications-include-own`,
8+
`--review-notifications-exclude-accounts`, `--review-notifications-exclude-patterns`, each with the matching
9+
`GERRIT_MCP_REVIEW_NOTIFICATIONS*` mirror. The `--notify-*` prefix is deliberately avoided — `notify` already means
10+
email-notify levels on review posting. New package: `internal/notifications` (subscription store, poller, filters,
11+
payload rendering); transport wrapper lives beside the server assembly.
12+
13+
## Phase 1: Tracer Bullet — subscribe, poll, push
14+
15+
The thinnest wire through every layer: one flag, one tool, a poller that detects only `updated` movement, a minimal
16+
payload, and a channel notification that reaches a real client.
17+
18+
**Components:**
19+
20+
- `internal/config``--review-notifications` behaviorFlag (+ mirror); interval flag with 60s fallback.
21+
- `internal/notifications` — subscription store (mutex-guarded map keyed by change number, per-change cursor =
22+
last-seen `updated`); poller goroutine: batched `change:A OR change:B` query per tick, emits a bare event when
23+
`updated` moved.
24+
- `internal/tools``subscribe_change` tool (validates the change exists and is in project scope via
25+
`GetChange`, stores it, returns llmxml ack naming current status and patch set).
26+
- `cmd/go-gerrit-mcp` — capability declaration (`Experimental["claude/channel"]`) and conditional instructions
27+
sentence when enabled; wrapping transport capturing the `mcp.Connection`; poller lifecycle bound to the signal
28+
context.
29+
- Emission — ID-less `jsonrpc.Request`, method `notifications/claude/channel`, params `{content, meta}`; content is
30+
a minimal `<review_activity change="..." status="...">` element for now.
31+
32+
**Testing strategy:**
33+
34+
- Learning tests first (see Learning Tests) — SDK capability override and raw-write behavior are assumptions until
35+
executed.
36+
- Unit: store add/duplicate/remove; poller tick against `httptest` Gerrit stub (updated moved / not moved / query
37+
error → logged and retried).
38+
- Manual probe: JSON-RPC pipe with a subscription, mutate the change live, observe the notification line on stdout.
39+
40+
**Verification gate:**
41+
42+
- Live end-to-end against Claude Code: `claude --dangerously-load-development-channels server:gerrit`, subscribe to
43+
a sandbox change, post a comment on it from the UI, the `<channel source="gerrit">` block appears in the session.
44+
This gate validates the entire contract stack before any depth is built.
45+
46+
**Acceptance criteria:**
47+
48+
- [ ] Zero-config server is byte-identical to today: no tool, no capability, no instructions change, no goroutine.
49+
- [ ] With the flag on, subscribing then mutating the change produces exactly one notification per poll tick with
50+
movement; quiet ticks produce nothing; empty subscription set skips the query entirely.
51+
52+
## Phase 2: Real activity deltas and terminal states
53+
54+
**Components:**
55+
56+
- `internal/notifications` — cursor grows per-kind high-water marks; event extraction from `GetChange` detail +
57+
`ListChangeComments`: new change messages (author, date, tag, revision), new votes (`ApprovalInfo.Date` newer
58+
than cursor, value, label), new/updated comment threads (reuse the thread pipeline), status transitions.
59+
- `internal/tools``unsubscribe_change` tool; payload rendering: `<review_activity>` composing existing
60+
vocabulary — `<message>`, `<vote>`, thread/`<comment>` elements exactly as `get_change_comments` renders them;
61+
`meta` carries `change`, `kind`, `project`.
62+
- Terminal handling — merged/abandoned detected from status: final notification carries the transition plus
63+
`subscription="ended"` semantics in prose, change leaves the store.
64+
65+
**Testing strategy:**
66+
67+
- Golden tests for payload rendering (existing golden harness in `internal/tools`).
68+
- Unit: cursor semantics — replay the same poll twice, second emits nothing; vote-only update emits a vote event;
69+
comment burst groups into one payload.
70+
- Live probe against the production incident change for thread-render parity.
71+
72+
**Verification gate:**
73+
74+
- A subscribed change taken through comment, reply, vote, and submit on a sandbox produces the expected
75+
notification sequence ending in the auto-unsubscribe notice, and the store is empty afterwards.
76+
77+
**Acceptance criteria:**
78+
79+
- [ ] Every activity kind (message, vote, thread, transition) renders and pushes; nothing repeats across ticks.
80+
- [ ] Terminal states always end the subscription with the announcing notification, including when the terminal
81+
transition and other activity arrive in the same tick.
82+
83+
## Phase 3: Filters and model-facing prompts
84+
85+
**Components:**
86+
87+
- `internal/config``include-own`, `exclude-accounts`, `exclude-patterns` flags; regex compilation at load with
88+
aggregated fail-loud errors.
89+
- `internal/notifications` — filter chain applied per extracted event: self-authorship (default drop, flag keeps),
90+
excluded accounts (username or numeric ID), content regexes against message/comment text. Filtered-to-empty
91+
ticks emit nothing.
92+
- `internal/tools` / `cmd` — full instructions section: when to subscribe, what arrives, auto-unsubscribe meaning,
93+
re-subscribe-after-restart guidance; tool descriptions final.
94+
95+
**Testing strategy:**
96+
97+
- Unit: table tests per filter and their composition; config tests for invalid regex (startup fails naming the
98+
pattern) and mirror resolution.
99+
- Golden: instructions output with the feature on/off.
100+
101+
**Verification gate:**
102+
103+
- Live: a bot-authored comment matching an exclusion pattern produces no notification while a human reply in the
104+
same tick does.
105+
106+
**Acceptance criteria:**
107+
108+
- [ ] Own activity silent by default, delivered with the include flag.
109+
- [ ] Account and pattern exclusions drop matching events only; invalid regex aborts startup with an aggregated
110+
error naming the pattern.
111+
112+
## Phase 4: Hardening and operator docs
113+
114+
**Components:**
115+
116+
- `internal/notifications` — poll failure policy (log, keep subscription, retry next tick; repeated failures do not
117+
kill the poller), change-became-inaccessible handling (scope loss / deletion → end subscription with notice),
118+
clean shutdown ordering on context cancellation.
119+
- `README.md` — feature section: flags table rows, channel enablement (`--channels` / research-preview
120+
`--dangerously-load-development-channels server:<name>`), org-policy caveat, per-project configuration synergy.
121+
- `docs/` — glossary already updated; verify vocabulary consistency across tool descriptions and README.
122+
123+
**Testing strategy:**
124+
125+
- Unit: error-path tests (Gerrit 5xx, 404 on subscribed change, ctx cancellation mid-tick).
126+
- `go test -race` across the package (first concurrent component in the codebase).
127+
128+
**Verification gate:**
129+
130+
- Kill/restart and failure-injection probes leave no goroutine leaks (`-race` clean, poller exits with the session).
131+
132+
**Acceptance criteria:**
133+
134+
- [ ] Poller survives transient Gerrit failures and shuts down cleanly with the session.
135+
- [ ] README documents the feature end-to-end including research-preview caveats.
136+
137+
## Learning Tests
138+
139+
- **SDK capabilities override** — setting `ServerOptions.Capabilities` with `Experimental["claude/channel"]`
140+
preserves the inferred tools capability (research flags the interaction; verify before Phase 1 builds on it).
141+
- **Raw notification write** — an ID-less `jsonrpc.Request` written via the wrapped `mcp.Connection` concurrently
142+
with SDK traffic is framed correctly and received by a client (probe with a stub client before trusting it).
143+
- **Channel injection** — Claude Code with the development flag renders our notification as a `<channel>` block
144+
(Phase 1 gate doubles as this test).
145+
- **Gerrit vote messages** — a bare vote on the production instance produces a `ChangeMessageInfo` and a dated
146+
`ApprovalInfo`; default query results include `updated` without `o=` options.
147+
148+
## Phase Sequence
149+
150+
Phase 1 (tracer bullet, no deps)
151+
152+
Phase 2 (depends on Phase 1)
153+
Phase 3 (config/filter work can start against Phase 1; filter-chain integration depends on Phase 2's event model)
154+
155+
Phase 4 (depends on Phases 2–3)
156+
157+
## Scope Boundaries
158+
159+
**In scope:** subscription tools, poller, filters, payload rendering, channel emission, operator docs.
160+
**Out of scope:** multiple notification channels and per-channel configuration; subscription persistence;
161+
attention-set/stream-events/webhook sources; permission relay; official channel-plugin packaging.

0 commit comments

Comments
 (0)