Compass forge agent-notification (RIG-2732 Pieces 2 + 3) — webhook-only
Status: Active Lane: compass-server Tracker: RIG
This record designs the DELIVERY half of DL-053 — the pipeline that turns an
upstream forge change (new comment / state flip / title-body-label edit /
CI-check flip / review / newly-opened artifact) on an agent-subscribed forge
coordinate into a ForgeNotification pushed to that agent’s live session,
coalesced at turn-end — on a webhook-only transport. It REWORKS the prior
draft of this record (PR #634), which designed a DL-053 conditional-GET POLL
driver, after Matt’s 2026-08-25 transport rulings: (1) fold the GitHub App
design into THIS record; (2) remove poll entirely — App-install-to-subscribe
is the only transport, with a lightweight reconciliation catch-up as the
reliability mitigation; (3) fold in container-level (GitHub repo / Linear
project) subscription paths. Matt’s 2026-08-26 rulings on the four carried
forks (W1-W4) are folded in as decided outcomes — see Resolved decisions.
Scope boundary, stated up front: this record flips the transport of the
agent-notification lane only. The board-ingestion repo-LIST poll (DL-161,
compass-forge-poll-driver/design.md) is a different lane feeding the issue
board, not agent notifications; it is untouched by this rework.
Problem / Intent
Section titled “Problem / Intent”An agent can subscribe to a forge artifact (RIG-2732 Piece 1, PR #631 —
open, pending merge:
EnsureAgentForgeSubscription / DeleteAgentForgeSubscription), but nothing
watches the subscribed coordinate and nothing delivers: the shared
per-artifact cursor table forge_artifact_cursors is writer-less (“The poll
driver owns the cursor WRITER (Piece 2); this file never inserts a cursor
row”, go/internal/store/forge_subscriptions.go package doc, PR #631,
pending merge), and
no forge_notification reaches an agent — the agent-side control dispatch
switch counts an unknown variant as “unrecognized control variant” and acks
it (packages/compass-agent/src/transport/control-source.ts:432-438), i.e. a
forge notification arriving today is silently dropped. Subscription is also
per-artifact only: SubscribeForgeRequest {repo, kind, number}
(proto/compass/v1/agent_gateway.proto:340-344) cannot express “any new PR
opened on this repo”. This record completes DL-053’s delivery half on
webhooks: a GitHub App ingress + the existing Linear webhook ingress feed an
event router that notifies subscribers over the built control rail, with a
bounded reconciliation sweep as the missed-delivery backstop.
Approach
Section titled “Approach”The transport pivot: DL-053’s poll premise, and why the App dissolves it
Section titled “The transport pivot: DL-053’s poll premise, and why the App dissolves it”DL-053 rejected webhooks on adoption friction, not on merit. Its own
table concedes webhooks win on latency (“seconds” vs “one tick”) and budget
(“~0” vs 2-3 conditional GETs per artifact per tick), and rejects them for
operational cost: “a public HTTPS ingress the Server must expose, a
shared secret per repo, HMAC verification, replay/dedupe handling, and
per-repo webhook registration (admin rights on every adopted repo)” —
“many adopters cannot or will not expose the Server, and many cannot install
a webhook on a repo they do not admin”
(compass-server-ownership-layer/design.md:1090-1107). Every leg of that
premise has since dissolved:
- The public ingress SHAPE is ruled, and its implementation is in
flight. RIG-2717’s merged design record (#625, DL-254) rules the first
production webhook ingress: a plain
POST /webhookshttp.Handleron the compass-server network TLS door (mounted beside the Connect mounts inbuildNetworkServer,go/server/network_door.go:233-333), raw-body HMAC-SHA256 fail-closed, ack-200-fast / work-async, inheriting the G112/SEA-1298 body-read guards. The CODE is unlanded: the RIG-2717 implementation stack (PRs #637-639 = its T1/T2/T3/T3a/T5) ships the verifiers and the--public-url/$COMPASS_PUBLIC_URLconfig (RIG-2717 T5,compass-linear-agent-responder/design.md:728-734) but does NOT yet mount/webhooks— the mount is a later RIG-2717 task. This record’s webhook-mount tasks (T2’s mount surface, T7’s wiring) SEQUENCE AFTER that handler/mount task lands (Global Constraints, sequencing prerequisites); a deployment that runs the Linear responder will then expose exactly the surface this record needs. - “A shared secret per repo” and “per-repo webhook registration” were repo-webhook facts, not webhook facts. A GitHub App has ONE webhook URL and ONE webhook secret at the App level; installing the App on an org or repo subscribes every selected repo to the App’s event set with zero per-repo webhook configuration and no repo-admin webhook rights — the install consent screen IS the registration. “User just installs App at setup like they do for many other products” (Matt, 2026-08-25).
- DL-053 itself designed for this reversal. The webhook path was framed
as feeding “the same change-detection function the poller feeds”
(
compass-server-ownership-layer/design.md:1104-1107), and the poller skeleton pins it: “a future webhook ingress feeds the SAME function — that is what makes the webhook path a latency change and nothing else” (compass-server-ownership-layer/design.md:2259-2263). This record keeps that function (DetectChanges) — repositioned onto the reconciliation path (below) rather than the hot path, because a webhook event already names its change.
What does NOT dissolve: webhook delivery is not perfectly reliable (Matt: GitHub webhooks have been “slightly unreliable lately”), GitHub retains delivery logs only 3 days and “does not automatically redeliver failed deliveries” (docs.github.com §Redelivering webhooks; changelog 2023-10-17), and a Server that is down misses deliveries outright. The mitigation is a reconciliation catch-up (below): a conditional-GET reconcile sweep on startup plus a bounded periodic backstop that self-heals a missed delivery — NOT a poll transport (its cadence is tens of minutes, not seconds; it emits only what webhooks missed; webhooks remain the sole primary transport).
The GitHub App (folded per Matt’s ruling 1)
Section titled “The GitHub App (folded per Matt’s ruling 1)”GitHub auth today is a static token: forge.TokenSource yields “the current
forge token”, the client calls Invalidate() on auth failure
(go/internal/forge/github.go:24-31, DL-052) — no App JWT, no installation
id, no event handling. The App design:
Registration — each deployment registers its OWN App (Renovate-style).
There is no central Compass-owned App that self-hosted deployments share: a
webhook URL is baked into the App registration, so each deployment (managed
compass.rigel.build included — it is simply the first such deployment)
registers an App pointing at its own <public-url>/webhooks/github. Setup is
documented as a runbook (T7): create the App (org- or user-owned), set the
webhook URL + a generated webhook secret, select permissions + events
(below), generate a private key, install it on the org/repos the deployment’s
agents work. GitHub’s App manifest flow (“a way to share a preconfigured
GitHub App registration … the manifest flow creates the App registration and
generates the app’s webhook secret, private key (PEM file), client secret,
and GitHub App ID”, docs.github.com §Registering a GitHub App from a
manifest) is the documented one-click path the runbook links; v1 does not
build a manifest-serving endpoint (non-load-bearing deferral — the manual
registration is ~10 fields).
Permissions + events. Repository permissions: Issues: read, Pull requests: read, Checks: read, Commit statuses: read, Metadata: read (mandatory baseline). Subscribed events (the notification alphabet — mapping in T2):
| GitHub event | Actions consumed | → ForgeNotificationKind |
|---|---|---|
issues |
opened |
OPENED (container-scope subs) |
issues |
closed / reopened |
STATE |
issues |
edited / labeled / unlabeled |
UPDATE |
issue_comment |
created |
COMMENT (GitHub serves PR conversation comments on this event too, so ONE event covers both kinds) |
pull_request |
opened |
OPENED (container-scope subs) |
pull_request |
closed (incl. merged) / reopened |
STATE |
pull_request |
edited / labeled / unlabeled |
UPDATE |
pull_request_review |
submitted |
REVIEW (new kind, T3) |
pull_request_review_comment |
created |
COMMENT |
check_suite |
completed |
CHECKS (triggers a combined-checks fetch for the head SHA — see below) |
check_suite.completed (not per-check_run) is the roll-up TRIGGER, not
the roll-up TRUTH: a check suite is per-App, so a head with CI + a linter
App + CodeQL completes three-plus suites, each firing its own completed
event — building ChecksSummary from ONE suite’s conclusion would present
per-suite truth as roll-up truth and flap (“checks: failure” while CI still
runs). On check_suite.completed the webhook arm therefore FETCHES the
combined checks state for the head SHA — one API call, reusing the same
combined check-runs + statuses roll-up the reads already compute
(github.go:713-731; T5’s ChecksConditional is its conditional sibling)
— and builds the true roll-up, matching the wire’s ChecksSummary and the
poll design’s semantics (T2 parses, T4’s router fetches). Ignored actions
(assigned, milestoned, …) are counted-and-dropped, never an error.
Token minting. A new forge.AppTokenSource implements the existing
forge.TokenSource interface (github.go:28-31) — the seam was built for
exactly this substitution:
- Mints a short-lived App JWT (RS256 over the App’s private key, ~10 min
expiry) →
POST /app/installations/{installation_id}/access_tokens→ an installation access token (1 h expiry), cached until ~5 min before expiry. Invalidate()drops the cached installation token (the client already calls it on 401/bad-creds-403,github.go:24-31); the JWT is re-minted per refresh, never cached across refreshes.- The installation id is config (read off the App’s installations list once
at setup; the runbook shows the one
curl). Multi-installation (one App installed on several orgs) is a non-load-bearing deferral — v1 binds one installation id, matching the one-forge-host binding the client already has (GitHubConfig.Host,github.go:34-38). - Private key + webhook secret enter through the existing declared-secrets
resolver exactly like the current forge token (
server_only, DL-052 — never container-injected).
Rate-limit consequence (dissolves the old OQ-5 pressure). An
installation token gets its own bucket: minimum 5,000 req/h, scaling +50/h
per repo past 20 to a 12,500/h ceiling (docs.github.com §Rate limits for
GitHub Apps). With poll gone, steady-state read traffic is ~zero — only
reconciliation sweeps (bounded, mostly-304, and 304s are primary-limit-exempt
per the client’s own verified note, github.go:60-63) and agent writes
consume it. The DL-053 “background starving foreground” cliff
(compass-server-ownership-layer/design.md:1135-1146) loses its cause; the
reserve floor machinery the prior draft carried (its F2) is dropped to a
non-load-bearing deferral.
The App token is THE GitHub credential (W1, decided (a) — unified).
GitHub App authentication uses an installation token and consumes NO org
seat; a deployment that wants two distinct GitHub identities — an
authoring/writing identity and a reviewing identity — registers TWO Apps,
clean identity separation at zero seat cost. AppTokenSource backs the
ONE shared forge.GitHub client for reads AND writes when App config is
present (T7 wiring); the “notification-lane-only” split is dropped. This
touches DL-052 only in WHICH secret backs the TokenSource — the
server_only posture is unchanged — and the App dissolves DL-053’s
per-repo-secret / repo-admin friction premise (transport pivot, above).
See Resolved decisions (W1).
The webhook ingress (extends the RIG-2717 door)
Section titled “The webhook ingress (extends the RIG-2717 door)”Two mounts on the same network TLS door, sharing the RIG-2717 shape (fail-closed raw-body HMAC, ack-200-fast / work-async, bounded body, G112/SEA-1298 inherited):
POST /webhooks/github— NEW sibling mount beside the Connect mounts (network_door.go:270-277). GitHub signs withX-Hub-Signature-256(sha256=<hex>HMAC-SHA256 over the raw body with the App webhook secret); the event name ridesX-GitHub-Event, the delivery UUIDX-GitHub-Delivery. Bad/missing signature → 400, body never parsed; unknown/ignored event or action → 200-with-drop (counted). A separate path (not multiplexed onto/webhooks) because the signature scheme, secret, and header contract differ per provider — discriminating inside one handler would interleave two fail-closed verifiers on one code path.POST /webhooks(Linear — the RIG-2717 mount, in flight; Global Constraints) — the RIG-2717 handler dispatches on the payload’stype; as designed it consumesAgentSessionEvent(compass-linear-agent-responder/design.md:92-96). This record adds the Linear data-change resource types to the same webhook + handler:IssueandComment(Linear webhooks “support data change events for Issues, Comments, … Projects, …” with actions create/update/remove and anupdatedFromobject of previous values, linear.app/developers/webhooks). Same signature check, same timestamp guard, newtypearms handing a normalized event to the forge router (T2). Mapping:Issue create→ OPENED (container-scope subs — Linear’s container is a PROJECT, and the payload’s project id is what container rows match on, W2);Issue updatewith a state change inupdatedFrom→ STATE, else UPDATE;Comment create→ COMMENT.repois the Linear team key, exactly as the write client already models it (“repois the Linear TEAM KEY (e.g. "SEA")”,go/internal/forge/linear.go:10-12); the Linear webhookIssuepayload additionally carries the issue’s project, so container matching needs no extra read. The Linear app is already installed with actor=app (RIG-2682); enabling the two resource types on its webhook is a Linear-side settings step in the T7 runbook.
Ingress dedup + at-least-once. Delivery is at-least-once end to end. The
ingress keeps a best-effort in-memory LRU of recent delivery ids
(X-GitHub-Delivery / Linear-Delivery UUIDs) to collapse routine retries;
it is deliberately NOT durable — a duplicate that survives it is delivered
downstream, and the contract absorbs it: a ForgeNotification is
informational (the agent re-reads the artifact), and agent-side control-rail
dedup is SEQ-based — a redelivered control op is dropped by
acks.isApplied(seq) (control-source.ts:363-367), which covers
rail-level redelivery; a re-DISPATCHED duplicate gets a fresh seq and is
delivered again, which at-least-once permits. No content-tuple idempotency
key exists or is added on this lane (this corrects the prior draft’s
implication that agents dedup by (subscription_id, change, …) content —
they dedup by seq, and duplicate content is acceptable by contract).
The subscription model (folded per Matt’s rulings 3 + W2)
Section titled “The subscription model (folded per Matt’s rulings 3 + W2)”Today SubscribeForgeRequest {repo, kind, number}
(agent_gateway.proto:340-344) with ForgeArtifactKind ISSUE/PULL_REQUEST
(forge.proto:69-73) — per-artifact only. Additions (all wire-additive):
-
Container-scope subscription: an EXPLICIT scope discriminator (W2, decided (b)), with provider-native containers. The
number = 0sentinel the prior revision recommended is dropped: proto3 cannot distinguish “field unset” fromnumber = 0, so under the sentinel a caller that merely FORGETSnumber— today a CAUGHT bug (“zero number → ErrInvalidArgument”, per #631’s own test list) — silently becomes a whole-repo subscription, and the callers here are LLM-driven tool invocations, exactly the population that omits fields. Instead the request gains an explicit scope enum (T3), andnumberis meaningful ONLY under ARTIFACT scope — zero number under ARTIFACT staysErrInvalidArgument:enum ForgeSubscriptionScope {FORGE_SUBSCRIPTION_SCOPE_UNSPECIFIED = 0; // treated as ARTIFACT (pre-scope callers)FORGE_SUBSCRIPTION_SCOPE_ARTIFACT = 1; // one issue/PR; number REQUIRED (> 0)FORGE_SUBSCRIPTION_SCOPE_CONTAINER = 2; // GitHub: the whole repo; Linear: a PROJECT}The container is PROVIDER-NATIVE: GitHub’s container is the REPO (identified by the existing
repo= owner/name); Linear’s container is a PROJECT — NOT the team key that ridesrepo(linear.go:10-12) — so container scope carries the Linear project identifier in a slot of its own, never overloaded ontorepo(the encoding is OQ-1, ruled (i): a dedicatedprojectcolumn). An agent wanting both issues and PRs on a GitHub repo subscribes twice (two rows, independently unsubscribable). -
What a container-scope subscription delivers: exactly the OPENED event for its kind (
issues.opened/pull_request.opened/ LinearIssue createcarrying the subscribed project), as aForgeNotification{change: OPENED, number: <new artifact's number>, url: …}— the agent then subscribes per-artifact if it wants to follow the new artifact. It does NOT fan in every event on every artifact in the container (that would make one busy repo a notification firehose and duplicate per-artifact subscriptions’ job). -
New notification kinds (
forge.proto:96-102, additive enum values):FORGE_NOTIFICATION_KIND_REVIEW = 5(a submitted PR review — detectable for free on webhooks where the poll design had no priced endpoint for it; payload: the existingCommentRef commentfield carries the review body + URL, andstatecarries the verdict string) andFORGE_NOTIFICATION_KIND_OPENED = 6(container-scope: a new artifact; no new payload field —number/urlon the envelope already address it). -
Agent-facing surface: per Matt’s ruling, the container subscribe is a DISTINCT tool call on the agent side (or the same tool with container-specific params — the SDK tool shape is settled at T6), never a magic zero on the artifact path: a GitHub container subscribe names the repo; a Linear container subscribe names the project.
-
Linear PROJECT-container mechanics (promoted from the prior revision’s non-load-bearing deferral — in scope per W2): the subscription row keeps the team key in
repo(the namespace Linear issue numbers live in,linear.go:10-12) and carries the project identifier beside it; the router matches a Linear event to container rows by the event’s project id. Linear webhookIssuepayloads carry the project, so the hot path needs no extra read. A project spanning several teams takes one subscription per team in v1 (the coordinate model and the reconcile LIST walk are team-keyed,linear.go:232-275) — a non-load-bearing deferral.
The pipeline, end to end
Section titled “The pipeline, end to end”flowchart LR GH[GitHub App webhook] --> IG[/POST /webhooks/github/] LN[Linear webhook] --> IL[/POST /webhooks/] IG --> N[normalize -> ForgeEvent] IL --> N N --> R[router: match subscribers] REC[reconcile sweep] --> D[DetectChanges] D --> R R --> DC[DispatchControl -> agent turn-end queue] DC --> ACK[agent turn-end flush -> ForgeNotificationAck] ACK --> ADV[advance delivered_revision]- Ingress (T2): verify, ack 200, enqueue the raw event on the async dispatcher (the RIG-2717 drain-loop shape).
- Normalize (T2): provider payload → one
ForgeEvent(coordinate + kind + payload), the single currency both providers and the reconciler emit. - Route (T4): match the event’s coordinate against subscribers —
exact artifact rows, plus container-scope rows for OPENED events
(GitHub: same repo + kind; Linear: the event’s project — W2) — one
indexed store query
(
agent_forge_subscriptions_artifact_idx,0001_init.sql:643-644). - Notify per subscriber (T4): resolve account → live session
(
SessionResolver.SessionForAccount,go/internal/runnerhub/relay_comms.go:176-180), build theForgeNotification(stampingsubscription_idper subscriber), wrap asAgentControl_ForgeNotification,DispatchControlit (go/internal/runnerhub/dispatch_control.go:38-54). The dispatch NEVER advances the delivery cursor (W3, decided beta):delivered_revision/delivered_atadvance only when the agent’s turn-endForgeNotificationAckarrives (Approach, advance signal). A refusal — synchronous or async — and any other pre-ack loss leave the cursor unadvanced; the reconciliation sweep re-notifies from the durable gap. - Maintain the snapshot (T4): apply the event to the coordinate’s
forge_artifact_cursors.snapshot/revision(comment-key added, state flipped, …) so the reconciler diffs against post-webhook truth, not stale truth. TWO producers now write the same snapshot, so the cross-producer canonicalization invariant (Global Constraints) is load-bearing here: anApplyEvent-mutated snapshot MUST be byte-identical, post-canonicalization, to what the sweep’s full fetch would rebuild for the same artifact state. Webhook payloads carry no ETags, so the apply leaves the stored ETags stale by design: the next sweep GET for that artifact pays one charged 200, re-diffs (empty, by the invariant), and re-stores fresh ETags. The cursor row is created on first subscribe-then-event or by the first sweep. - Reconcile (T5): on startup and every backstop interval, sweep
subscribed coordinates with conditional GETs,
DetectChangesagainst the stored snapshot, and feed any diff into the SAME router as syntheticForgeEvents. A subscriber whosedelivered_revisionlags the current revision with an empty diff gets ONE synthesized payload-free UPDATE (kind + url; the agent re-reads the artifact) — the prior draft’s restart-safe catch-up, unchanged.
Delivered-revision semantics carry over from the prior draft intact:
revision is a digest of the whole-artifact snapshot; delivered_revision
is the per-subscriber high-water mark, advanced only on that subscriber’s
own in-band ForgeNotificationAck (W3; DL-053’s two-cursor split,
compass-server-ownership-layer/design.md:965-972, survives verbatim — only
what ADVANCES the fetch side changes: webhook-applied snapshot updates and
sweep 200s instead of poll 200s). delivered_revision != revision durably
marks the lagging subscriber for the sweep. Fresh-subscription catch-up:
a subscriber with delivered_revision = '' is baselined to the current
revision without a notify (first event after subscribing is the first
notify) — same one-observation-window deviation from DL-053’s “at Subscribe
time” wording (:999-1001) the prior draft flagged; decided in W3’s
ratification bundle (below).
Advance signal (W3, decided (beta): advance on the in-band ack). The
delivery cursor advances on the agent’s in-band forge delivery ack —
NEVER on dispatch success. This is the dispatch arm’s own documented
model (“a control deliver is SEND-ONLY … success rides a later
AgentFrame.delivery_ack”, dispatch_control.go:3-11; “The cursor is
never advanced on send — it advances only later on the recipient’s
delivery_ack”, dispatch_control.go:31-33), and it makes the sweep’s
boundedness claim true WITHOUT subclass qualification for every pre-ack
loss: a SYNCHRONOUS refusal (no live session — DispatchControl returns
an error), an ASYNC refusal (a RunnerError observed later by
router.complete, dispatch_control.go:26-32), and
agent-death-before-flush all leave delivered_revision unadvanced, so
the sweep observes the durable gap and re-notifies within one backstop
interval. Only post-ack loss (a turn that flushed and acked, then lost
state) sits outside the signal — true of any ack scheme.
The added work is the forge-delivery correlation: today’s
AgentFrame.delivery_ack correlates by comms message_id and carries no
forge field (DeliveryAck { string message_id = 1; },
agent.proto:232-239), so it cannot carry this lane’s ack. T3 adds an
additive AgentFrame variant, ForgeNotificationAck { subscription_id, revision }, emitted by the agent arm at turn-end FLUSH (T6 — the same
point the control-rail ack retires the op) and applied by a hub ack arm
beside deliverAck that calls the store’s delivered-revision advance
(T7 wiring). A proto + Runner + agent change, accepted by the ruling.
This REOPENS the frozen delivery record’s advance-signal clause
(compass-notification-delivery/design.md:894-897) — aligning this lane
with its in-band-success model rather than deviating from it — so it
rides Matt’s freeze-gate ratification, bundled with the
fresh-subscription baseline move (above). See Resolved decisions (W3).
Turn-end coalescing is the recipient’s contract, unchanged. The
agent-side arm (T6) joins the control-source dispatch switch
(control-source.ts:373-439), queues to turn-end (RT-3 “deliver → queue →
coalesce → ack”, compass-notification-delivery/design.md:39), and acks at
FLUSH, not decode — a decode-ack would discard the Runner’s
retain-until-acked durability (gateway/control.go:74-83) for the
decode→flush window.
The flush is also where the W3 forge delivery ack is emitted (T6).
Where each piece lives
Section titled “Where each piece lives”| Piece | Package | Why there |
|---|---|---|
AppTokenSource (JWT + installation-token mint) |
internal/forge |
Implements forge.TokenSource (github.go:28-31) beside the client that consumes it |
GitHub webhook handler + payload structs + VerifySignature |
internal/forge (new githubapp_webhook.go) |
Mirrors linearagent/webhook.go’s pure-verifier shape (compass-linear-agent-responder/design.md:618-626); forge payload types live with the forge types they reference |
| Linear data-change arms | internal/linearagent |
The type switch already dispatches Linear payloads there (RIG-2717 T1/T6); the new arms normalize and hand off, never route |
ForgeEvent normalize + router + notify step |
internal/ingest (new notify_router.go) |
ingest owns raw→canonical translation and “imports NO store” (ingest.go:7-8); store enters via a structural seam |
DetectChanges + snapshot codec + reconcile sweeper |
internal/ingest (new notify_detect.go, notify_reconcile.go) |
The DL-053 pure diff core; the sweeper is a sibling of the board Driver (driver.go:55-67) |
Conditional single-artifact reads (NotifyReader) |
internal/forge |
Sibling of ListIssuesPage, which owns the conditional-GET idiom (github.go:148-153) |
| Store methods (targets, cursor upsert, delivered-revision advance, container-scope guard + scope/project columns) | internal/store |
Beside Piece 1’s forge_subscriptions.go (pending #631) |
| Boot wiring, mounts, config, runbook | go/server |
Where hub + store + door meet (network_door.go:233-333, serve.go:767-818 patterns) |
| Agent-side arm | packages/compass-agent |
The control-source decode switch + turn-end injection |
Alternatives considered
Section titled “Alternatives considered”- Poll as primary transport (the prior draft of this record, PR #634).
Rejected by Matt’s 2026-08-25 ruling, and on the merits: DL-053’s own
table scores webhooks better on latency and budget and rejected them only
on friction grounds
(
compass-server-ownership-layer/design.md:1093-1107) that the App model dissolves (one App-level secret + URL, install-to-subscribe, no repo-admin webhook rights, ingress shape ruled by DL-254 with the implementation in flight — the RIG-2717 stack, Global Constraints). What survives from that draft: theDetectChangescore and snapshot codec (repositioned to reconciliation), theNotifyReaderconditional-read surface (ditto), the DispatchControl relay decision (DL-265), the two-cursor semantics, the synthesized catch-up UPDATE, and the agent-side turn-end arm. - Hybrid: webhooks + a slow full poll as fallback. Rejected — Matt’s
ruling is webhook-ONLY. Stated honestly: the reconcile sweep REUSES most
of the poll draft’s implementation surface (
DetectChanges, the snapshot codec,NotifyReader’s conditional reads, the cursor table, per-target pacing, theErrBudgetExhaustedabort — roughly 80% of that draft’s machinery ships anyway, repositioned). What this rework actually DELETES is the fast ticker, the reserve-floor machinery, and the poll-as-PRIMARY framing; the practical difference from this rejected hybrid is cadence plus intent, not mechanism. That distinction is still real and worth the rejection: the sweep runs at tens-of-minutes cadence, exists only to heal gaps, and emits only diffs against webhook-maintained snapshots — a gap-healer built from a poll driver’s parts, never a transport. - A dedicated webhook ingress service/port. Rejected — RIG-2717 already
ruled the shape for this codebase: a plain
http.Handleron the network TLS door, NOT a Connect service (Connect’s bearer interceptors can’t be satisfied by a forge, and signature verification needs the raw body that Connect’s decode consumes,compass-linear-agent-responder/design.md:483-487), and NOT a second ingress (DL-254). Same reasoning transfers verbatim to GitHub. - Per-repo webhooks instead of an App. Rejected — reinstates two legs of DL-053’s friction premise (per-repo secret, per-repo registration needing repo admin) and forfeits App-scoped rate limits and the install-consent UX. The App IS the dissolution.
- GitHub’s redelivery API as the catch-up (instead of reconcile). Rejected — delivery logs are retained only 3 days, “GitHub does not automatically redeliver failed deliveries” (docs.github.com §Redelivering webhooks), redelivery needs App-manager credentials doing delivery-log list+replay bookkeeping, and it cannot cover the acked-then-lost-in-house window (we acked 200; GitHub considers it delivered). The conditional-GET sweep covers every gap class with machinery we already designed.
- Riding the comms bus / delivery consumer for the notify step.
Rejected, unchanged from the prior draft: the delivery consumer is
comms-semantic (message settle gates, seq cursors); the frozen delivery
record already ruled the composition point — “the poller’s notify step
calls the same dispatch the comms consumer calls … and advances its own
per-subscriber cursor” (
compass-notification-delivery/design.md:574-577).
Global Constraints
Section titled “Global Constraints”- Toolchain: module
github.com/RigelBuild/compass/go(go/go.mod); proto underproto/compass/v1/, generated Go undergo/internal/gen/viamoon run compass-proto:gen. This rework HAS wire changes (T3): a newForgeSubscriptionScopeenum + twoSubscribeForgeRequestfields, two additiveForgeNotificationKindvalues, and theForgeNotificationAckAgentFrame variant (W3) — all additive, buf-breaking-safe, one regen. - Webhooks are the ONLY primary transport. No task builds a standing per-artifact poll loop. The reconcile sweep (T5) is bounded (startup + a default 30 min backstop), conditional (mostly-304), and emits only diffs; any design drift that turns it into a fast poll is mis-scoped — stop and re-read.
- Sequencing prerequisites — two UNLANDED dependencies. (1) The
/webhooksingress: RIG-2717’s implementation stack is in flight (PRs #637-639 = its T1/T2/T3/T3a/T5) and does NOT yet mount/webhooks— the mount is a later RIG-2717 task. This record’s T2 mount surface and T7 wiring sequence AFTER that handler/mount task lands. (2) The subscription store writer: PR #631 (RIG-2732 Piece 1) is OPEN (merge-state blocked at last check), so every citation in this record intoforge_subscriptions.go,validSubscriptionCoordinate, and the Piece-1 guard/tests is pending #631 merge — T3’s guard change has no target on main until it lands. - Cross-producer snapshot canonicalization (correctness invariant).
The snapshot has TWO writers — T4’s webhook
ApplyEventand T5’s sweep full-fetch rebuild — andrevisionis a digest of canonical JSON, so a webhook-applied snapshot MUST be byte-identical (post-canonicalization) to what the sweep would rebuild for the same artifact state: identical state-string mapping (e.g. closed+merged → “merged” in BOTH arms), identical comment-key format (URL-keyed; a webhook payload’s comment URL and the API read’s comment URL must normalize to the same key), identical label ordering, identical field truncation. Any divergence makes every sweep detect a phantom diff on every webhook-touched artifact — a chronic 30-minute spurious-notification heartbeat that also poisons T8’s diff-count alert signal. T4 and T5 carry the shared meeting-point test: apply-event-then-full-fetch of the same state → empty diff, for EVERY event kind. Webhook payloads carry no ETags, so the webhook arm cannot refresh them; the accepted cost is one charged 200 per webhook-touched artifact on the next sweep, which re-stores fresh ETags (Approach, pipeline step 5). - The ingress is fail-closed and ack-fast (DL-254 shape): raw-body HMAC
before any parse (bad/missing signature → 400), bounded body
(
http.MaxBytesReader, 1 MiB), ack 200 before any work, all work async on the serve errgroup. Constant-time compare (crypto/hmac.Equal). internal/ingestimports no store (“it imports NO store”,go/internal/ingest/ingest.go:7-8): durable state enters through package-local structural interfaces;*store.Storeadapters live ingo/server(theforgePollStorepattern,serve.go:1082-1090).- Coordinate types:
store.ForgeProvider(SMALLINT 1-4) +store.ForgeArtifactKind(1=issue, 2=PR), matching the tables’ CHECKs (0001_init.sql:632-635).numberis meaningful ONLY under ARTIFACT scope after T3 (container rows store number=0 behind an explicitscopediscriminator — W2); zeronumberunder ARTIFACT and zero provider/kind remain caller bugs →ErrInvalidArgument. - Secrets: App private key + App webhook secret + Linear webhook signing
secret are
server_onlydeclared secrets through the existing resolver (DL-052 posture; the RIG-2717 lazy-resolve idiom,compass-linear-agent-responder/design.md:786-787). - Linear is issues-only and check-less (
linear.go:10-12): its event alphabet is Issue/Comment; no CHECKS/REVIEW arms for Linear. - Bodies on the wire are header-stripped + attributed: a COMMENT/REVIEW
notification’s body runs
forge.StripOwner(owner.go:138) in the normalize step — webhook payloads carry bodies RAW, same as provider reads (provider.go:66). - Red-green: every task lands tests-first per
rule://red-green-testing; pgtests follow the isolated-schema pattern (serve_forge_pgtest_test.go:8-10); webhook handlers get vector-based signature tests (known secret + body → expected hex, the RIG-2717 T1 pattern).
T1 — internal/forge: GitHub App credential (AppTokenSource)
Section titled “T1 — internal/forge: GitHub App credential (AppTokenSource)”The App JWT → installation-token mint behind the existing TokenSource
seam. No client changes: forge.NewGitHub already takes any TokenSource
(github.go:34-38).
-
Interfaces (in
go/internal/forge, newgithubapp.go):// GitHubAppConfig identifies one GitHub App installation this deployment// registered (each deployment registers its OWN App — see the T7 runbook).type GitHubAppConfig struct {AppID int64InstallationID int64PrivateKey func(ctx context.Context) ([]byte, error) // PEM, lazily resolved (server_only secret)Host string // "github.com" or GHES; API base derives as in GitHubConfigClient *http.Client // nil -> defaultClock func() time.Time // nil -> time.Now (test seam, the github.go idiom)}// NewAppTokenSource returns a TokenSource minting installation access// tokens: RS256 App JWT (~10 min) -> POST /app/installations/{id}/access_tokens// -> cached until ~5 min before the 1 h expiry. Invalidate drops the cache// (the client calls it on 401/bad-creds-403, github.go:24-31). Safe for// concurrent use; mint is singleflighted.func NewAppTokenSource(cfg GitHubAppConfig) (TokenSource, error) -
JWT: stdlib
crypto/rsa+crypto/x509+ hand-rolled RS256 JWS (three base64url segments over{"iat","exp","iss"}) — no JWT dependency, matching the client’s no-dependency posture (github.go:53-58). -
Per W1 (decided: unified), server wiring passes this
TokenSourceto the ONE sharedforge.GitHubclient — reads AND writes — when App config is present; the static-PAT path remains only for deployments that have not yet registered an App (they keep writes, and get NO GitHub notifications — W4’s decided hard-off posture, stated by the T7 boot warning; the runbook’s tunnel step is the supported path to an App). -
Test cycle (red first, httptest): JWT header/claims/signature verify against a test key; mint caches (second Token() = no HTTP); refresh before expiry boundary (injected clock); Invalidate forces re-mint; 401 on mint surfaces as error (not a panic); singleflight under concurrent Token().
T2 — ingress: POST /webhooks/github + Linear data-change arms + normalize
Section titled “T2 — ingress: POST /webhooks/github + Linear data-change arms + normalize”The event front door, both providers, producing the pipeline’s single currency.
-
Interfaces:
// internal/forge (new githubapp_webhook.go) — pure, unit-tested:// VerifyGitHubSignature checks X-Hub-Signature-256 ("sha256=<hex>") over// the raw body with the App webhook secret (constant-time).func VerifyGitHubSignature(secret, rawBody []byte, headerValue string) bool// ParseGitHubEvent maps (X-GitHub-Event, body) -> a normalized event, or// ok=false for ignored events/actions (counted-and-dropped by the caller).func ParseGitHubEvent(event string, body []byte) (ev ForgeEvent, ok bool, err error)// internal/forge (new notify_event.go) — the normalized currency:type ForgeEvent struct {Provider compassv1.Forge // GITHUB / LINEARHost stringRepo string // owner/name, or Linear team keyKind ArtifactKind // 1 issue, 2 PRNumber uint64 // the artifact's number (always set; on// OPENED it is the NEW artifact's number)Project string // Linear: the issue's project id (container// matching, W2); "" on GitHub eventsURL stringChange compassv1.ForgeNotificationKindComment *compassv1.CommentRef // COMMENT / REVIEWChecks *compassv1.ChecksSummary // CHECKS (filled by the router's// roll-up fetch, never at parse)HeadSHA string // CHECKS: the completed suite's head SHAState string // STATE / REVIEW verdictDeliveryID string // X-GitHub-Delivery / Linear-Delivery UUID}// internal/linearagent — new type arms in the existing payload dispatch// (RIG-2717 T1 types file): parse Linear `Issue`/`Comment` data-change// payloads (action create/update/remove + updatedFrom) into ForgeEvent;// AgentSessionEvent handling is untouched.func ParseLinearDataEvent(raw []byte) (ev forge.ForgeEvent, ok bool, err error)// go/server — the mount (T7 wires it): a plain http.Handler, the DL-254// shape (verify -> ack 200 -> enqueue), with an in-memory delivery-id LRU. -
GitHub mapping per the Approach event table;
issue_commenton a PR maps Kind=PULL_REQUEST via the payload’sissue.pull_requestmarker;check_suite.completedparses to a CHECKS event carrying HeadSHA and NOChecksSummary— a suite is per-App, never roll-up truth; T4’s router fetches the combined roll-up (Approach, event table). Bodies runforge.StripOwnerhere (normalize is the one strip point). -
Linear mapping:
Issue create→ OPENED;Issue update→ STATE iffupdatedFromshows a workflow-state change (mapped through the client’s existing state-truth mapping,linear.go:62-70), else UPDATE;Comment create→ COMMENT. TheIssuepayload’s project id lands inForgeEvent.Project(Linear container matching, W2).removeactions are counted-and-dropped in v1 (no notification kind models deletion; non-load-bearing deferral). -
Test cycle (red first): signature vectors (valid/tampered/missing → 400-path booleans); every table row parses to the right kind/coordinate/payload from recorded fixture payloads; ignored actions → ok=false; PR-vs-issue comment discrimination; Linear state-vs-update discrimination via
updatedFrom; a LinearIssuefixture’s project id lands inProject;check_suite.completed→ HeadSHA set, Checks nil; strip applied; oversized body rejected by the mount.
T3 — wire + store: container-scope subscriptions + new kinds + forge ack
Section titled “T3 — wire + store: container-scope subscriptions + new kinds + forge ack”The SubscribeForgeRequest scope extension (W2), the two enum values, the
W3 ack frame, and the store surface (the prior draft’s T1 store methods,
extended for scope). The container-id encoding is OQ-1, ruled (i); the
DDL + proto below are the (i) encoding.
-
Wire (additive; one regen):
// agent_gateway.proto — SubscribeForgeRequest gains an explicit scope// (W2, decided (b): the number=0 sentinel is dropped) plus the Linear// PROJECT container identifier (OQ-1, ruled (i)):enum ForgeSubscriptionScope {FORGE_SUBSCRIPTION_SCOPE_UNSPECIFIED = 0; // treated as ARTIFACT (pre-scope callers)FORGE_SUBSCRIPTION_SCOPE_ARTIFACT = 1; // one issue/PR; number REQUIRED (> 0)FORGE_SUBSCRIPTION_SCOPE_CONTAINER = 2; // GitHub: the whole repo; Linear: a PROJECT}message SubscribeForgeRequest {string repo = 1; // GitHub owner/name; Linear team keyForgeArtifactKind kind = 2;uint64 number = 3; // ARTIFACT only; MUST be 0 under CONTAINERForgeSubscriptionScope scope = 4; // additive; UNSPECIFIED = ARTIFACTstring project = 5; // CONTAINER on LINEAR only: the project id}// SubscribeForgeResponse unchanged. Zero/absent number under ARTIFACT// remains CodeInvalidArgument — the W2 silent-misfire class stays caught.// forge.proto — two additive enum values:// FORGE_NOTIFICATION_KIND_REVIEW = 5; // a submitted PR review; comment// // carries body+url, state the verdict// FORGE_NOTIFICATION_KIND_OPENED = 6; // container-scope: a new artifact; the// // envelope's number/url address it// agent.proto — one additive AgentFrame oneof variant (W3), the forge// sibling of DeliveryAck (which correlates by comms message_id only,// agent.proto:232-239): emitted at turn-end flush (T6), applied by a// hub ack arm beside deliverAck (T7).message ForgeNotificationAck {string subscription_id = 1;string revision = 2; // the notified revision; the advance target} -
Store DDL (in
0001_init.sql—agent_forge_subscriptions,0001_init.sql:629-641— per OQ-1, ruled (i)):-- two additive columns:scope SMALLINT NOT NULL DEFAULT 1 CHECK (scope IN (1, 2)), -- 1 artifact, 2 containerproject TEXT NOT NULL DEFAULT '', -- Linear CONTAINER rows: project id; else ''-- and the UNIQUE widens to-- (agent_account_id, forge_provider, forge_host, repo, kind, number, project)-- so two project containers on one team do not collide. -
Store guard + surface (in
go/internal/store, extending Piece 1’s file + the prior draft’s surface):// validSubscriptionCoordinate (Piece 1's guard, pending #631 merge —// Global Constraints) learns scope: ARTIFACT -> number > 0 AND project// empty; CONTAINER -> number == 0 (stored as 0), project REQUIRED on// LINEAR / forbidden on GITHUB. Zero provider/kind/empty repo remain// ErrInvalidArgument on every arm.type ForgeNotifySubscriber struct {SubscriptionID stringAgentAccountID AccountIDDeliveredRevision string}// SubscribersForArtifact returns the exact-artifact subscribers plus —// when openedEvent — the container-scope subscribers for the same// container: GitHub (repo, kind); Linear (repo, kind, project). One// indexed query over agent_forge_subscriptions_artifact_idx// (0001_init.sql:643-644).func (s *Store) SubscribersForArtifact(ctx context.Context, provider ForgeProvider, host, repo string, kind ForgeArtifactKind, number uint64, project string, openedEvent bool) ([]ForgeNotifySubscriber, error)type ForgeArtifactCursor struct {Provider ForgeProviderHost, Repo stringKind ForgeArtifactKindNumber uint64 // 0 = the container-scope reconcile cursor rowETag, CommentsETag, ChecksETag stringRevision stringSnapshot []byte // raw JSONBPolledAt time.Time}type ForgeNotifyTarget struct {Provider ForgeProviderHost, Repo stringKind ForgeArtifactKindNumber uint64Cursor *ForgeArtifactCursor // nil: never observedSubscribers []ForgeNotifySubscriber}// ListForgeNotifyTargets enumerates distinct subscribed coordinates// (artifact AND container-scope rows) with their cursor + subscribers —// the reconcile sweep's enumeration (LEFT JOIN forge_artifact_cursors).// Container targets collapse per (repo, kind): every Linear project sub// on one team shares one team-keyed LIST walk; each subscriber row// carries its project for the router's match.func (s *Store) ListForgeNotifyTargets(ctx context.Context, provider ForgeProvider, host string) ([]ForgeNotifyTarget, error)func (s *Store) UpsertForgeArtifactCursor(ctx context.Context, cur ForgeArtifactCursor) error// AdvanceForgeDeliveredRevision is called from the hub's// ForgeNotificationAck arm (W3; T7 wiring) — never from the router's// dispatch path.func (s *Store) AdvanceForgeDeliveredRevision(ctx context.Context, agent AccountID, subscriptionID, revision string) error -
forge_artifact_cursorsadmits number=0 rows (its PK includes number,0001_init.sql:649-662; the kind CHECK is untouched) — the container-scope reconcile cursor, ONE per (repo, kind) regardless of how many Linear project subs ride it (Linear issue numbers are per-team, so one team-level high-water covers every project on that team):snapshotholds the high-water artifact number + the LIST page-1 ETag ridesetag. That table needs NO project column (OQ-1, ruled (i)). -
AdvanceForgeDeliveredRevision:UPDATE … SET delivered_revision=$3, delivered_at=now() WHERE id=$2 AND agent_account_id=$1; zero rows →ErrNotFound(unsubscribed mid-flight — log and move on). -
Test cycle (pgtest, red first): container Ensure idempotent on (agent, repo, kind, 0, project); ARTIFACT with number=0 →
ErrInvalidArgument(the W2 misfire regression); LINEAR CONTAINER without project / GITHUB CONTAINER with project →ErrInvalidArgument; two Linear project containers on one team → two rows; artifact event matches exact + container rows correctly (openedEvent on/off; Linear project match vs mismatch); targets grouping (2 agents on 1 artifact → 1 target, 2 subscribers; cursor nil before first upsert; N project subs on one team → 1 container target); advance happy/unknown-id/ foreign-agent →ErrNotFound; the unsubscribe GC invariant (PR #631, pending merge) still holds with container rows.
T4 — internal/ingest: router + notify step + snapshot maintenance
Section titled “T4 — internal/ingest: router + notify step + snapshot maintenance”The hot path: ForgeEvent in, notifications out, snapshot current.
-
Interfaces (in
go/internal/ingest, newnotify_router.go; store enters via a structural seam per the no-store rule):// NotifyStore is the durable surface the router + reconciler share — the// server wiring adapts *store.Store and binds (provider, host), the// forgePollStore pattern (serve.go:1082-1090).type NotifyStore interface {SubscribersForArtifact(ctx context.Context, repo string, kind forge.ArtifactKind, number uint64, project string, opened bool) ([]NotifySubscriber, error)ListNotifyTargets(ctx context.Context) ([]NotifyTarget, error)UpsertArtifactCursor(ctx context.Context, cur ArtifactCursor) error// No delivered-revision advance here: the advance rides the hub's// ForgeNotificationAck arm in go/server (W3), never the router.}// NotifyDispatcher is the notify seam: resolve account -> live session ->// DispatchControl, satisfied in go/server by a hub-backed adapter (T7).// The dispatch never advances the delivery cursor (W3): success and// failure alike leave it to the agent's ack; the reconcile sweep// re-notifies from any durable gap.type NotifyDispatcher interface {Notify(ctx context.Context, account string, n *compassv1.ForgeNotification) error}// ChecksRoller resolves the combined checks roll-up for a CHECKS event's// head SHA (a check_suite is per-App, never roll-up truth — Approach,// event table). Satisfied in go/server by NotifyReader's// ChecksConditional (T5), passing the cursor's checks_etag.type ChecksRoller interface {RollUp(ctx context.Context, repo string, number uint64, headSHA, etag string) (forge.ConditionalResult[forge.Checks], error)}// NotifyRouter routes one normalized event: load the coordinate's// snapshot, apply the event (snapshot mutation + new revision digest),// upsert the cursor, then notify each matched subscriber. It never// advances delivered_revision (W3 — the hub's ack arm does).type NotifyRouter struct{ /* store, dispatcher, checksRoller, forgeRef, log */ }func NewNotifyRouter(st NotifyStore, disp NotifyDispatcher, checks ChecksRoller, forgeRef *compassv1.ForgeRef, log *slog.Logger) *NotifyRouterfunc (r *NotifyRouter) Route(ctx context.Context, ev forge.ForgeEvent) error -
Snapshot apply (in
notify_detect.go, besideDetectChanges): a pureApplyEvent(prev *ArtifactSnapshot, ev forge.ForgeEvent) (next ArtifactSnapshot)— COMMENT adds the comment key (URL-keyed: Linear comment ids are UUIDs andforge.Comment.IDis always zero there,linear.go:748-756;Comment.URLis populated by both providers,provider.go:61-70), STATE/UPDATE/CHECKS overwrite their snapshot halves, OPENED bumps the container-scope high-water number. Revision =SnapshotRevision(sha256 of canonical JSON), shared with T5.ApplyEventis bound by the cross-producer canonicalization invariant (Global Constraints): its output must match, byte-for-byte after canonicalization, the snapshot T5’s full fetch would build for the same state — comment-URL keys, state-string mapping (closed+merged → “merged” in both arms), label ordering, truncation. The apply leaves stored ETags stale by design (webhooks carry none); the next sweep GET pays one 200, re-diffs (empty, by the invariant), re-stores fresh ETags. CHECKS events arrive as a head SHA: the router resolves the combined roll-up via theChecksRollerseam BEFORE snapshot apply + notify. -
Ordering: cursor upsert BEFORE notify (the fetch-side truth advances unconditionally; DL-053’s split, surviving); the per-subscriber delivery advance is ack-driven (W3) and happens outside the router.
-
An OPENED event notifies container-scope subscribers only (GitHub: the repo; Linear: rows whose project matches the event’s — W2); per-artifact events notify exact-coordinate subscribers only (no fan-in; Approach).
-
Test cycle (red first, fakes for both seams): each kind routes + notifies, and delivered_revision is NEVER touched by the router (W3 — advance is ack-driven, tested in T6/T7); an OPENED event reaches the matching Linear project subscriber and not a mismatched one; snapshot mutation per kind (comment-key growth, state flip, high-water bump); duplicate COMMENT event (same URL) → snapshot unchanged + still notified (at-least-once, dedup is NOT content-based — see Approach); CHECKS event → roll-up fetched via the ChecksRoller seam, snapshot’s checks half holds the COMBINED truth, never one suite’s conclusion; the meeting-point invariant test (shared with T5): for EVERY event kind, ApplyEvent then DetectChanges against a full fetch of the same resulting state → empty diff + identical revision (Global Constraints, cross-producer canonicalization); an event on a coordinate whose subscription vanished mid-flight → logged, no crash.
T5 — internal/ingest: reconciliation sweep (startup + bounded backstop)
Section titled “T5 — internal/ingest: reconciliation sweep (startup + bounded backstop)”The reliability mitigation: heal missed webhooks without reintroducing poll. Reuses the prior draft’s conditional-read + diff design wholesale, at backstop cadence.
-
forge.NotifyReader(ininternal/forge) — the per-endpoint conditional reads, unchanged in shape from the prior draft (its F1(c) resolution: a capability interface, NOT aProviderwidening —Providercarries a//nolint:interfacebloatwaiver and deliberately unconditional reads,provider.go:223-243; precedent: the board driver’s structuralpageListerseam,driver.go:33-37):type ConditionalResult[V any] struct {V VETag stringNotModified bool}type NotifyReader interface {GetIssueConditional(ctx context.Context, repo string, number uint64, etag string) (ConditionalResult[Issue], error)GetPullRequestConditional(ctx context.Context, repo string, number uint64, etag string) (ConditionalResult[PullRequest], error)// Page-1 conditioned, NEWEST-first (sort=created&direction=desc), so a// new comment always changes page-1: 304 = no new comments in one// request; a miss walks remaining pages (getAllPages idiom, github.go:723).ListComments(ctx context.Context, repo string, kind ArtifactKind, number uint64, etag string) (ConditionalResult[[]Comment], error)ChecksConditional(ctx context.Context, repo string, number uint64, headSHA, etag string) (ConditionalResult[Checks], error)// Container-scope: newest-first conditional LIST for artifacts of// kind opened above sinceNumber. Two contract points an implementer MUST// honor: (1) GitHub's /repos/{repo}/issues — the endpoint// ListIssuesPage wraps (github.go:169) — returns PRs INTERLEAVED// with issues (each PR row carries a pull_request marker); a// kind=ISSUE sweep MUST filter those out, and kind=PULL_REQUEST uses// /repos/{repo}/pulls (a DIFFERENT endpoint with different filter// params — sort=created&direction=desc, no issue filters), NOT the// ListIssuesPage idiom. (2) The ETag conditions page 1 only// (sort=created&direction=desc, so any new artifact changes page 1);// on a miss, walk successive pages until a page's OLDEST number is// <= sinceNumber (the getAllPages Link-chain walk, github.go:683-689)// so a >1-page burst of new artifacts between sweeps is never// truncated to page 1.ListNewArtifacts(ctx context.Context, repo string, kind ArtifactKind, sinceNumber uint64, etag string) (ConditionalResult[[]Issue], error)}GitHub impl: a conditional sibling of
getJSONcarryingIf-None-Match/304 (theListIssuesPageidiom,github.go:176-191, includingrecordBudgeton 304). Linear impl: GraphQL has no ETags, so its reads return 200-equivalents with empty ETag (documented limitation — acceptable at backstop cadence against Linear’s separate rate bucket); PR/checks arms →ErrUnsupported(linear.go:277-302pattern). Linear container targets walk the team-keyedListIssues(linear.go:232-275); the read selection gains the issue’s project id so routed OPENED events carryForgeEvent.Projectfor the project match (W2). -
DetectChanges+ snapshot codec (notify_detect.go) — the prior draft’s pure diff, verbatim: previousArtifactSnapshot+ fetched state → zero or more changes + next snapshot + revision.prev == nil→ baseline, no changes. A 304’d half carries prev’s values forward. -
NotifyReconciler(newnotify_reconcile.go):Run(ctx)on the serve errgroup — one immediate sweep at startup (heals the downtime window), then atime.TickeratBackstop(default 30 min; config). Per sweep: enumerate targets (ListNotifyTargets), conditionally fetch each, diff, feed changes into the SAMENotifyRouter.Routeas synthetic events, and for each subscriber withdelivered_revision != cursor.Revisionand an empty diff, synthesize ONE payload-free UPDATE (kind + url; the agent’s ack advances the cursor — W3) — the restart-safe lagging-subscriber recovery, unchanged from the prior draft. Requests are paced within the sweep (anti-burst;ErrBudgetExhaustedaborts the sweep, resumed next interval — the board driver’s treatment,driver.go:134-136). Per-target error isolation (log and continue). -
Cost arithmetic, restated for the backstop cadence: 150 distinct artifacts × 2-3 conditional GETs per 30 min ≈ 600-900 issued/h, mostly-304 and thus mostly-uncharged — 30× under the prior poll design’s 18k-30k/h, inside a 5,000/h installation bucket without floor machinery. The 30 min default is a chosen point, not a floor: the same arithmetic admits much faster (10 min ≈ 1,800-2,700 issued/h, still comfortably inside the bucket), and the backstop interval is also the worst-case latency for any missed webhook. 30 min keeps the sweep unmistakably a backstop (tens-of-minutes cadence, per the webhook-only constraint); a deployment that weights missed-webhook latency higher turns
ReconcileBackstopdown to 10 min without budget consequence. -
Test cycle (red first, fakes): startup sweep heals a seeded gap (snapshot behind live → notification); backstop tick 304s → zero dispatches,
polled_atonly; lagging subscriber + empty diff → exactly one synthesized UPDATE (advance rides the ack — W3); lagging + real diff → the real change set, nothing synthesized; container-scope target →ListNewArtifacts+ OPENED per new artifact above high-water; budget abort resumes next sweep; ctx cancel mid-sweep → prompt clean return; the meeting-point invariant’s T5 half: a webhook-applied (T4ApplyEvent) snapshot full-fetched by the sweep → empty diff, zero dispatches, fresh ETags re-stored.
T6 — receive side: relay regression + agent-side turn-end arm
Section titled “T6 — receive side: relay regression + agent-side turn-end arm”Near-unchanged from the prior draft (it was transport-independent); W3
adds the forge delivery ack. Server-side dispatch (the SEND) needs NO new
hub method — DispatchControl takes any
*AgentControl (dispatch_control.go:38) and the gateway’s representable
already admits every variant except Replay/Config/nil
(go/internal/runner/gateway/control.go:196-203). The bare
SessionsResponse.forge_notification = 7 variant stays reserved with a
superseded-by-DispatchControl doc comment (DL-265) — zero new Runner code;
the Runner half is a regression test proving a forge op relayed via
DispatchControl reaches host.Deliver and the gateway seam accepts it.
W3 adds one RECEIVE arm: a hub handler for the additive
AgentFrame.forge_notification_ack frame (T3), beside the existing
deliverAck arm, calling AdvanceForgeDeliveredRevision (T7 wires it) —
so “zero new Runner code” softens to zero new Runner DISPATCH code; the
Runner’s frame spine relays the new additive AgentFrame variant.
- Agent-side arm (
packages/compass-agent/src): aforgeNotificationcase in the control-source dispatch switch (control-source.ts:373-439) — decode, enqueue on the turn-end queue (RT-3 coalescing), render at flush as a structured system message (forge, repo#number, kind, per-kind payload; REVIEW renders verdict + body, OPENED renders “new<kind>repo#number”). Ack at turn-end FLUSH, not decode: steer/deliver ack at decode only because they dispatch at decode (control-source.ts:415-417); a decode-ack here would discard the Runner’s retain-until-acked durability (go/internal/runner/gateway/control.go:74-83) for the decode→flush window. The flush ALSO emits the W3 forge delivery ack — oneForgeNotificationAck{subscription_id, revision}per flushed notification (T3’s frame) — which is what advancesdelivered_revisionserver-side. Rail-level redelivery dedup is the existing SEQ-basedacks.isAppliedpath (control-source.ts:363-367) — the delivery idempotency mechanism on this lane (see Approach; no content-tuple key). - Test cycle (red first): gateway seam accepts
AgentControl_ForgeNotification(gateway/control_test.gotable pattern); DispatchControl e2e carries it tohost.Deliver; agent decode → queued, not dropped; pre-barrier refusal; turn-end flush renders each kind (incl. REVIEW/OPENED); ack at flush, not decode (death-before-flush → Runner redelivers); flush emits oneForgeNotificationAckper notification and the hub arm advancesdelivered_revision(no flush → no ack → cursor unadvanced, the sweep re-notifies); redelivered seq deduped byacks.isApplied.
T7 — server boot wiring + config + setup runbook
Section titled “T7 — server boot wiring + config + setup runbook”Assemble T1-T5 in go/server; document the per-deployment App setup.
-
Interfaces (in
go/server):// ForgeAppConfig extends the forge config block (serve.go:108-135):// AppID int64; InstallationID int64// AppPrivateKeySecret string // declared-secret NAME (PEM value)// AppWebhookSecretName string // declared-secret NAME// ReconcileBackstop time.Duration // default 30 * time.Minute// Gate: the GitHub notification lane runs iff AppID != 0 AND the two// secrets are declared (validateForgeSecret's fail-fast pattern,// serve.go:188-192). Linear data-change arms run iff the Linear webhook// secret is declared (the RIG-2717 gate). Boot Warn (not error) when// agent_forge_subscriptions has rows but the lane is off — the// warnDisabledForgePolling idiom (serve.go:840-843).// The AppID==0 arm IS W4's decided posture (a): no App -> NO GitHub// notifications, hard-off. Ingress-less adopters reach an App via the// runbook's tunnel step — never a second in-server transport.// forgeNotifyStore adapts *store.Store to ingest.NotifyStore, binding// (provider, host) — the forgePollStore pattern (serve.go:1082-1090).// forgeNotifyDispatcher adapts the hub: SessionForAccount// (relay_comms.go:176-180) then DispatchControl (dispatch_control.go:38),// wrapping n as AgentControl_ForgeNotification.func buildForgeNotifyLane(ctx context.Context, cfg ServeConfig, st *store.Store, hub *runnerhub.Hub, resolver secrets.Resolver, log *slog.Logger) (*forgeNotifyLane, error)// forgeNotifyLane carries: the /webhooks/github http.Handler (mounted in// buildNetworkServer beside the Connect mounts, network_door.go:270-277),// the Linear data-change hook registration into the RIG-2717 handler's// type dispatch, the router, and the reconciler's Run for the errgroup. -
Per W1 (decided: unified): when App config is present,
NewAppTokenSourcebecomes theTokenSourcefor the ONE sharedforge.GitHubclient (writes + reconcile reads, one budget); otherwise the static-PAT source stays and the GitHub notification lane is off (W4). The hub’sForgeNotificationAckarm (T6) is wired here tostore.AdvanceForgeDeliveredRevision(W3). -
Setup runbook (
docs/beside the record, shipped with T7): the Renovate-style self-hosted App registration — create App, set webhook URL<public-url>/webhooks/github+ secret, permissions/events table (Approach), generate + declare the private key, install on org/repos, read the installation id, boot flags. An ingress-less deployment (homelab / NAT / tailnet) fronts<public-url>with a TUNNEL (Cloudflare Tunnel / ngrok) — the supported path to a public webhook URL (W4); a deployment wanting distinct authoring vs reviewing GitHub identities registers TWO Apps (installation tokens consume no org seat — W1) and points this lane at one of them. Plus the Linear step: enable Issue/Comment resource types on the existing app webhook. The managed deployment follows the same runbook with its own App. -
Test cycle: pgtest e2e — real store, fake reader, fake dispatcher: seed a subscription, POST a signed webhook fixture → notification content + fetch-cursor advance, then a
ForgeNotificationAck→ delivery advance (W3); tampered signature → 400, nothing enqueued; container-scope fixture (issues.opened) → OPENED to the container subscriber; a LinearIssue createfixture with a project → OPENED to the matching project subscriber only; disabled-gate → nil lane; boot Warn on rows-but-disabled.
T8 — observability
Section titled “T8 — observability”The webhook lane’s health surface (replacing the prior draft’s budget-floor task, which the transport pivot dissolved — see Approach, rate-limit consequence).
- Per-event structured log at ingress (event, action, delivery id, verify/parse outcome) and at route (matched subscribers, dispatched, refused); per-sweep reconcile log (targets, requests, 304 ratio, diffs found, synthesized catch-ups — a non-zero diff count is the “webhooks missed something” signal worth alerting on later); counters for dropped events (bad signature, ignored action, LRU-deduped).
- No rate-floor machinery: the reconcile keeps the pace gate + the client’s
existing
ErrBudgetExhaustedabort (github.go:19-22) only. - Test cycle: log fields present on each path; drop counters increment; sweep log carries the diff count.
- T1 —
forge.AppTokenSource: App JWT + installation-token mint behind the existingTokenSourceseam (go/internal/forge/githubapp.go) - T2 — ingress:
VerifyGitHubSignature+ParseGitHubEvent+ForgeEventnormalize + LinearIssue/Commentdata-change arms - T3 — wire + store: container-scope subscribe (explicit
scope+ provider-native container: GitHub repo, Linear project), REVIEW/OPENED enum values,ForgeNotificationAckframe, scope/project columns + guard,SubscribersForArtifact/ListForgeNotifyTargets/ cursor upsert / delivered-revision advance (container-id encoding per OQ-1, ruled (i)) - T4 —
ingest.NotifyRouter: route + notify + snapshot apply (fakes for both seams; no delivery advance — W3) - T5 —
ingest.NotifyReconciler+forge.NotifyReader+DetectChanges/snapshot codec: startup sweep + 30 min backstop + synthesized catch-up - T6 — receive side: DispatchControl relay regression + agent-side
forgeNotificationturn-end arm + flush-timeForgeNotificationAckemission (packages/compass-agent) - T7 — boot wiring: App config + gates,
/webhooks/githubmount, Linear arm registration, the hub’s forge-ack arm, reconciler on the errgroup, setup runbook (incl. the W4 tunnel step) - T8 — observability: ingress/route/sweep logs + drop counters
Sequencing: T1/T2/T3 are independent; T4 needs T2+T3; T5 needs T3+T4 (it
routes through T4); T6 is
independent (wire types exist after T3’s regen); T7 needs T1+T2+T4+T5;
T8 rides
T2/T4/T5. External prerequisites (Global Constraints): T3 targets Piece
1’s writer, so it lands after PR #631 merges; T2’s mount surface + T7’s
wiring land after RIG-2717’s /webhooks handler/mount task lands
(PRs #637-639 are in flight and do not yet mount it).
Ledger-impact
Section titled “Ledger-impact”New rows for docs/designs/DECISIONS.md (single root ledger; next free
after DL-263 → DL-264..DL-267 in file order) plus one scoped
supersession:
| # | Decision |
|---|---|
| DL-264 | The DL-053 agent-notification transport is WEBHOOK-ONLY: a per-deployment GitHub App (one App-level webhook URL + secret, install-to-subscribe, X-Hub-Signature-256 fail-closed) posting to POST /webhooks/github on the network TLS door, and Linear Issue/Comment data-change events on the RIG-2717 POST /webhooks handler (implementation in flight — Global Constraints) — no standing poll loop exists on this lane. Reliability is a bounded reconciliation catch-up: one conditional-GET sweep at startup plus a 30 min backstop that diffs snapshots via DetectChanges and re-notifies from the durable delivered_revision gap (synthesizing one payload-free UPDATE when the missed set is no longer derivable) — a gap-healer at tens-of-minutes cadence, never a primary transport. Supersedes DL-053’s transport premise; its two-cursor split and delivery semantics survive. |
| DL-265 | Forge notifications ride the generic DispatchControl relay (SessionsResponse.deliver_control envelope wrapping AgentControl.forge_notification), NOT the bare SessionsResponse.forge_notification = 7 variant — that variant predates the generic relay (compass-notification-delivery/design.md:199-202) and is superseded: it stays reserved on the wire with a doc comment, and no Runner dispatch arm is built for it. Zero new Runner code on the relay path. Agent-side delivery idempotency is control-rail SEQ-based (acks.isApplied, packages/compass-agent/src/transport/control-source.ts:363-367); no content-tuple key exists on this lane, and duplicate content is within the at-least-once contract. |
| DL-266 | The forge delivery cursor (delivered_revision) advances on the agent’s in-band forge delivery ack — ForgeNotificationAck{subscription_id, revision}, an additive AgentFrame variant emitted at turn-end flush — NEVER on dispatch success (Matt’s W3 ruling, option beta), aligning this lane with the dispatch arm’s own model (“The cursor is never advanced on send — it advances only later on the recipient’s delivery_ack”, go/internal/runnerhub/dispatch_control.go:31-33). Every pre-ack loss (synchronous refusal, async RunnerError via router.complete, agent death before flush) leaves the cursor unadvanced and is healed by the reconcile sweep within one backstop interval. The correlation is new work: today’s delivery_ack carries only a comms message_id (agent.proto:232-239). This reopens the frozen delivery record’s advance-signal clause (compass-notification-delivery/design.md:894-897) and rides the freeze-gate ratification, bundled with the fresh-subscription catch-up baseline moving from DL-053’s “at Subscribe time” to first-observed-event/sweep (bounded by ≤1 backstop interval) — also decided in W3’s ruling. |
| DL-267 | Forge subscriptions gain CONTAINER-SCOPE granularity via an explicit ForgeSubscriptionScope enum on SubscribeForgeRequest — Matt’s W2 ruling, option (b); the number = 0 sentinel is REJECTED (proto3 absent-vs-0 blindness would convert a forgotten number from an LLM tool caller into a silent whole-repo subscription). Containers are PROVIDER-NATIVE: GitHub’s container is the REPO (the existing repo slot); Linear’s is a PROJECT, carried in a slot of its own and never overloaded onto the team key in repo — Linear project scope is thereby IN scope (promoted from deferral). The project identifier lives in a dedicated column (OQ-1, ruled (i): additive scope + project columns, UNIQUE widened). A container subscription delivers exactly FORGE_NOTIFICATION_KIND_OPENED with the new artifact’s number/url — never a fan-in of every event on every artifact. Two additive ForgeNotificationKind values land with it: REVIEW = 5 (submitted PR review; free on webhooks where the poll design priced no review endpoint) and OPENED = 6. |
Scoped supersession of DL-053 (transport only). DL-053’s row
(DECISIONS.md:81) is a MULTI-CLAUSE decision: (a) the two-cursor
FETCH/DELIVERY split, (b) the account-addressed Sessions →
AgentGateway.Control push path, (c) the delivery semantics, and (d) the
conditional-poll transport premise. This rework supersedes ONLY clause
(d); (a)-(c) remain live current truth — the board lane (DL-161)
instantiates DL-053’s FETCH-cursor model, and this lane’s own DL-264/266
reuse the two-cursor split and delivery semantics (the comms
delivery-cursor model DL-071/DL-072 is a parallel sibling, not a
dependency). The ledger row-status grammar has no
partial-supersession cell, and flipping DL-053 to a bare Superseded
would falsely retire its live clauses — so DL-053 STAYS Active and
DL-264’s prose carries the scope (“Supersedes DL-053’s transport
premise; its two-cursor split and delivery semantics survive”), the
house partial-overturn pattern (DL-236/DL-183 “Refines … which stays
Active”). The same-PR citation sweep (per DL-053) updates code
comments citing the poll premise — notably
go/internal/store/forge_cursors.go,
go/internal/store/forge_subscriptions.go (PR #631), and
go/internal/ingest/driver.go’s DL-053 references, which describe the
BOARD lane and mostly survive with a scope-clarifying touch. DL-161
(board repo-LIST poll) is NOT flipped — different lane, out of scope.
The prior draft’s proposed poll-specific rows (its own “per-artifact loop” and the poll halves) are NOT applied — they never froze; PR #634 is being updated in place with this rework.
Deferrals
Section titled “Deferrals”W1-W4 and OQ-1 are all RULED (Matt, 2026-08-26) — folded into the sections above and the Resolved decisions below. What remains here is only explicit non-load-bearing deferrals, ratified on merge:
- App manifest-flow endpoint (one-click registration serving): the documented manual registration (~10 fields, T7 runbook) suffices; additive later.
- Multi-installation support (one App on several orgs): v1 binds one
installation id, matching the client’s one-host binding
(
github.go:34-38); additive config later. - Cross-team Linear projects: a project spanning several teams takes
one container subscription per team in v1 (the coordinate model and the
reconcile LIST walk are team-keyed,
linear.go:232-275); a team-spanning project match is additive router work later. - Reconcile-only degraded mode for ingress-less adopters: W4 is decided hard-off + tunnel; a PAT-only reconcile-only mode remains additive-later IF ingress-less adopters materialize — as its own ratified decision, never drift.
remove/deleted-artifact events: no notification kind models deletion; counted-and-dropped in v1.- Rate-reserve floor (the prior draft’s F2 / DL-053’s OQ-5): with poll
gone, background reads are ~600-900 issued/h mostly-304 at backstop
cadence inside a ≥5,000/h installation bucket — the starvation cliff has
no cause. The reconcile keeps the pace gate +
ErrBudgetExhaustedabort; a floor is additive if telemetry (T8) ever shows pressure. - GHES: the App model works on GitHub Enterprise Server (App
registration is per-instance); the runbook notes it, no code fork —
GitHubConfig.Hostalready derives the API base (github.go:34-38).
Resolved decisions (Matt, 2026-08-26)
Section titled “Resolved decisions (Matt, 2026-08-26)”The four forks the prior revision carried as W1-W4 are ruled; each is folded into the sections above and kept here as the settled record.
- W1 — GitHub App token scope: (a) UNIFIED. The App is THE GitHub
credential — reads AND writes — whenever App config is present:
AppTokenSourcebacks the one sharedforge.GitHubclient (T1/T7); the static PAT survives only as the no-App fallback. GitHub App authentication uses an installation token and consumes NO org seat, so a deployment wanting separate authoring and reviewing identities registers TWO Apps — clean identity separation at zero seat cost. Touches DL-052 only in WHICH secret backs theTokenSource(theserver_onlyposture is unchanged); the App also dissolves DL-053’s per-repo-secret / repo-admin friction premise. - W2 — container-scope encoding: (b) EXPLICIT SCOPE FIELD, with
provider-native containers. The
number = 0sentinel is dropped: proto3 cannot distinguish “unset” from 0, so the sentinel converts a forgottennumber— from LLM tool callers, exactly the population that omits fields — into a silent whole-repo subscription. Container scope rides theForgeSubscriptionScopeenum (T3), and the container is PROVIDER-NATIVE: GitHub’s is the REPO (the existingreposlot), Linear’s is a PROJECT (“it also needs to carry the linear terms like PROJECT, not repo”) — so Linear project scope is IN scope, promoted from the prior revision’s non-load-bearing deferral. The agent-facing subscribe is a distinct tool call (or distinct params), never a magic zero on the artifact path. The one remaining sub-fork — where the project identifier lives — was OQ-1, ruled (i); see below. - W3 — delivered-revision advance signal: (beta) ADVANCE ON THE
IN-BAND ACK. Not dispatch success (alpha), not a
router.completeun-advance hook (beta’). This is the dispatch arm’s own documented model (“The cursor is never advanced on send — it advances only later on the recipient’s delivery_ack”,dispatch_control.go:31-33) and makes the sweep’s boundedness claim true for EVERY pre-ack loss subclass. The added work is the forge-delivery correlation — today’sdelivery_ackcorrelates by commsmessage_idand carries no forge field (agent.proto:232-239) — landed as the additiveForgeNotificationAckframe (T3), emitted at turn-end flush (T6), applied by a hub ack arm (T7). This reopens the frozen delivery record’s advance-signal clause (compass-notification-delivery/design.md:894-897), so it rides Matt’s freeze-gate ratification — bundled with the fresh-subscription catch-up baseline moving from DL-053’s “at Subscribe time” (compass-server-ownership-layer/design.md:999-1001) to first-observed-event/sweep (bounded by ≤1 backstop interval), also decided in this ruling. - W4 — ingress-less deployments: (a) HARD-OFF + TUNNEL. No App configured → NO GitHub notifications; boot Warn when subscriptions exist (T7’s gate). Matt’s reasoning: ONE webhook path only — he will not support a dual in-server transport. The adopter class DL-053’s poll rejection protected (homelab / NAT / tailnet) is served honestly: it exposes the webhook URL via a TUNNEL (Cloudflare Tunnel / ngrok), a documented T7 runbook step — not a degraded second in-server transport. A reconcile-only mode stays additive-later (deferrals) if that class materializes.
- OQ-1 — Linear PROJECT container-id encoding: (i)
scopeENUM + STABLErepo+ DEDICATEDprojectCOLUMN (Matt, 2026-08-26: “634 Q1 - (i) lgtm”). The Linear project identifier lives in its own slot —scope = 4+project = 5onSubscribeForgeRequest, and two additive table columns (scope SMALLINT NOT NULL DEFAULT 1 CHECK (scope IN (1, 2)),project TEXT NOT NULL DEFAULT '') with the UNIQUE widened to includeproject— never overloadingrepo, which keeps its one meaning (GitHub owner/name; Linear team key). The genericcontainer_kind/container_ref(ii) and therepo-overload sentinel (iii) are rejected: (ii) forks the(repo, kind, number)coordinate shape the artifact index, cursor PK, and reconcile walk are built on, and (iii) reintroduces the exact silent-reinterpretation class W2’s explicit enum was chosen to kill. This was the last load-bearing fork gating T3; the T3 DDL/proto/store surface above is the (i) encoding.