Compass forge-poll driver (SEA-1810)
Status: Active Lane: compass-server Tracker: SEA
OQ-A is RESOLVED — Matt ruled Option 2 (2026-08-07): this driver is built
ON the DL-053 forge-subscription machinery from day one, with the board as a
distinguished subscriber. OQ-C and OQ-D are RESOLVED (Matt, 2026-08-08): the
board’s poll targets are TABLE rows — a new forge_repo_subscriptions table,
dynamically add/removable, with DL-053’s spec’d forge_subscriptions landing
RENAMED agent_forge_subscriptions — all four forge tables land
unconditionally in migration 0015, and every provider CHECK admits the full
declared enum IN (1, 2, 3, 4) (see Open Questions). Freeze now waits only
on the human design review + the review-agent pass. This record designs the
running driver that periodically drives the existing greenfield
go/internal/ingest library against a real GitHub forge, so board issues
ingested from the tracker appear live in the Compass server projection — and
it instantiates the DL-053 fetch-cursor model (migration + store + poll
driver) as it does so. Design layer only — no implementation code ships with
this record.
Problem / Intent
Section titled “Problem / Intent”Board issues ingested from the external tracker (GitHub) must appear live in
the Compass server projection. Today nothing drives ingestion: server/serve.go
wires no poller and imports neither internal/ingest nor internal/forge
(verified this run — the only mentions of a poller in go/server are forward
references, e.g. server/board.go:40 “the PR-B poll driver … add producers
without changing the executor signature”, and the pgtest comment
server/service_board_pgtest_test.go:14 “exactly as part 3’s poller would”).
The internal/ingest library (ingest.go:35-65) is complete and tested but is
exercised only by its own tests and the board pgtest suite.
This slice adds the running driver as the first DL-053 forge subscriber:
the DL-053 conditional-poll loop (durable Postgres FETCH cursor → conditional
GET → sink → advance), started at serve boot under the serve errgroup, with
the board as its distinguished, always-on local subscriber and the first real
(non-fake) forge read client behind it. The DL-053 forge-subscription tables
do not exist yet (migrations in go/internal/store/migrations/ stop at
0014_channel_policy.sql; a repo-wide search for forge_subscriptions /
forge_artifact_cursors under go/ returns nothing — the existing
0006_delivery_cursors.sql table is agent_delivery_cursors, the comms
agent-notification fan-out, “how far an agent has confirmed delivery on a
channel”, a different subsystem entirely). This slice therefore builds the
fetch half of that machinery: the four-table migration (including the
board’s own target table, forge_repo_subscriptions), the target + cursor
store layer, and the poll driver that PR-C (tracker-status ingestion,
DL-129) and the later agent-notification slice both ride — one fetch path,
no convergence refactor ever needed.
Global Constraints
Section titled “Global Constraints”- Toolchain: module
github.com/sealedsecurity/compass/go, go 1.25.0 (go/go.mod). - DL-053 (FROZEN, Matt 2026-07-27,
DECISIONS.md:76) — the target model, not an option: “Forge subscriptions are Server-side Postgres rows with a per-artifact FETCH cursor (advanced on any 200) split from a per-subscriber DELIVERY cursor (advanced only on that subscriber’s own successful notify), change-detected by conditional polling in v1 (webhooks are an additive accelerator), delivered by account on the existingSessions→AgentGateway.Controlpush path.” Full text + DDL:compass-server-ownership-layer/design.md:942-1019(Decision 5). This record consumes that model — it never amends it. The two genuine forks in applying it to a repo-LIST board subscriber are RESOLVED (Matt, 2026-08-08): the board’s subscription set is a TABLE — the newforge_repo_subscriptions(OQ-C) — and the spec’d tables land NOW with the full provider domain (OQ-D). What was never a fork: the repo-LIST fetch-cursor CLASS (forge_list_cursors, Approach (a)) is author-adopted as the only coherent shape for a LIST-granularity ETag, and the coordinate alignment to the tree’s 0013 convention (SMALLINT provider +forge_host) is the mechanical application of an existing standard. The two spec’d tables are labeled0004_forgein that (older) record; the tree’s migrations reach0014, so they land here as part of0015_forge_subscriptions.sql. - The DL-053
forge_subscriptionstable lands RENAMEDagent_forge_subscriptions(Matt’s explicit option under OQ-C): shape and columns unchanged from the spec’d DDL (compass-server-ownership-layer/design.md:978-999) — a NAMING clarification, not a shape amendment — disambiguating the two subscription concepts:agent_forge_subscriptionsis the per-ARTIFACT, agent-owned subscription (agent_account_id TEXT NOT NULL REFERENCES agent_accounts,:980);forge_repo_subscriptionsis the board’s per-REPO poll target (new, this record). A future reader mapsagent_forge_subscriptionsback to DL-053’sforge_subscriptions. The rename is recorded in the ledger as DL-163 (rides THIS PR); the paired one-line forward annotation at the frozen ownership-layer DDL is deferred to SEA-1883 (editing a frozen record needs Matt’s call — see Open Questions → OQ-C). - Provider domain
IN (1, 2, 3, 4)on every 0015 table (OQ-D2, Matt 2026-08-08): the proto enum already declares all four providers —FORGE_PROVIDER_GITHUB = 1 … FORGE_PROVIDER_LINEAR = 4(proto/compass/v1/compass.proto:699-706;gen/compass/v1/compass.pb.go:448-453). The CHECK’s job is “never UNSPECIFIED(0)”, NOT gating rollout — rollout is gated by whichforge.Providerhas a real client (GitHub only, this slice). The pre-existingissuesCHECKIN (1, 2, 3)(0013_issues.sql:32) excludes Linear and is a SEPARATE prerequisite for actually ingesting Linear issues — out of scope here, documented as OQ-E. - DL-129 (FROZEN, Matt 2026-08-04,
DECISIONS.md:176): tracker native status “is ingested into the DL-070 server projection through the reverseTrackerStatusMapping(DL-053 poll; echo-suppressed in tracker-status space, tracker-sourced transitions never mirror back, stale polls dropped by a recency guard)”. That names THIS driver as the poll PR-C rides: tracker status is a field on the same/issuesrows this loop fetches (forge.Issue.State,provider.go:45-46— “the forge’s truth (“open” | “closed”)”), so PR-C MUST NOT re-fetch the endpoint (see Approach → the one-fetch-path guarantee). - OQ-6 (FROZEN, Matt 2026-07-27): the GitHub client is hand-rolled over
net/http, no new dependency (~300 LOC); go-github is REJECTED — it lacks a conditional-request + budget mechanism hook. Conditional requests (If-None-Match/ETag → treat304 Not Modifiedas no-change) and honoringx-ratelimit-*response headers as a budget are mandatory, not optional hardening. - DL-052 (Active,
DECISIONS.md:75): only the Server holds forge credentials, as aserver_onlydeclared secret filtered out of container injection. The driver reads its GitHub token via the Server’s secret path (thesecrets.SpecResolverinstance built atserver/serve.go:287) — never from an agent/container, never from a flag that would land inpsoutput. - Error mapping: every non-2xx forge response maps to
*forge.StatusError{Status, Message}(forge/provider.go:216-227), so the Service layer can flatten statuses without inspecting the wire. - Idempotency (load-bearing, now expressed over the DURABLE cursor): a
failed or partial poll is safe to retry — “partial progress is fine — a
re-poll is idempotent on the coordinate” (
ingest.go:48-52; the coordinate is the store upsert key,store/migrations/0013_issues.sql:57-59). The invariant the durable FETCH cursor must preserve: a stored list-page ETag attests that every row of that page’s content was durably sunk when the ETag was stored. Concretely: the driver advances a page’s cursor row ONLY AFTER every issue on that page has sunk throughIssueProjection.PublishIssueUpdate(a durable upsert,issue_projection.go:68-74). A mid-page sink failure leaves the old ETag in place, so the next tick’s conditional GET carries the OLD ETag, the changed content returns200, and the page re-sinks — no issue is ever stranded behind an advanced cursor. This is the same invariant DL-053’s “advances unconditionally on any 200” expresses for per-artifact rows, where the fetched state persists assnapshotin the same row as the ETag (compass-server-ownership-layer/design.md:1016): never advance a fetch cursor past state you have not durably captured. Here the durable capture is the projection upsert itself, so the advance follows the sink; the advance is still NEVER conditioned on any subscriber’s wire delivery (the board has none — see Approach), which is the coupling DL-053 forbids. - No projection change:
board.IssueProjection.PublishIssueUpdate(ctx, *compassv1.Issue) error(board/issue_projection.go:68) ALREADY satisfiesingest.issueSink(ingest.go:31-33). This slice adds zero code tointernal/board. ingestimports no store: the package doc holds that “it imports NO store. The proto->store mapping edge is part 4 (the sink’s real implementation)” (ingest.go:7-8). The driver’s durable-cursor dependency therefore entersingestas a package-local structural interface (PollStore, T3), with the*store.Storeadapter living inserver/(T4) — the same narrow-seam pattern asforgeReader/issueSink(ingest.go:20-33).- Non-goals: the tracker-status ingestion itself (PR-C — the reverse
TrackerStatusMapping, echo-suppression, recency guard; a separate slice that consumes the seam this record makes explicit); the tracker-status WRITE-back / outbound mirror (T3-b); the agent-notification DELIVERY half of DL-053 (per-agentagent_forge_subscriptionsrows,DetectChanges, per-artifact comment/check ETags, theSessions→AgentGateway.Controlpush) — that slice adds subscribers and wire delivery ON the tables and driver built here, it does not rebuild the fetch path; and the dynamic add/remove MUTATION surface (RPC or admin UI) forforge_repo_subscriptions— this slice builds the table + the T4 boot seed reconcile as its only writer; the mutation surface rides a later slice. This record is the BOARD-ingestion read poll plus the DL-053 fetch machinery it stands on.
Approach
Section titled “Approach”Four pieces, one per task: a concrete GitHub read client in internal/forge
(T1, carried over from the pre-ruling cut nearly intact), the DL-053 cursor
migration + store layer (T2), the conditional-poll driver in internal/ingest
(T3), and boot wiring + config surface in server/serve.go +
cmd/compass-server/main.go (T4).
The impedance Option 2 must design around: per-artifact vs repo-LIST
Section titled “The impedance Option 2 must design around: per-artifact vs repo-LIST”DL-053’s subscription model is per-artifact: “An agent subscribes to a
forge artifact: an issue or a PR, named by (provider, repo, kind, number)”
(compass-server-ownership-layer/design.md:944-945), and the FETCH cursor
table is keyed the same way (:1007-1019, PRIMARY KEY (provider, repo, kind, number)). SEA-1810’s board ingestion is per-repo LIST: fetch every issue
in a repo in one paginated /issues?state=all walk (Ingester.Ingest
“fetches every issue for repo”, ingest.go:48) and sink each. A list-page
ETag is a caching fact about a URL (repo + filter + page), not about any one
artifact — it cannot be stored per-artifact without inventing data. Three
framings were weighed:
- (a) The board is a distinguished repo-scoped subscriber — ADOPTED for the
fetch cursor. The repo LIST is its own fetch-cursor class: a new
forge_list_cursorstable keyed(provider, host, repo, page)holding the per-page ETag + Link-chain fact (has_next), sibling to (not a widening of) the DL-053 per-artifactforge_artifact_cursors. It is the same fetch-cursor IDEA — a durable conditional-GET cache, advanced with the durably captured state, shared by every consumer of the fetch — applied at the LIST granularity. The board’s DELIVERY cursor is degenerate: the board is one always-on, in-process consumer whose “notify” is the synchronous projection sink inside the poll pass, so sunk-ness is already encoded by the page-cursor advance (Global Constraints → Idempotency) and nodelivered_revisionrow is needed — exactly the per-subscriber cursor DL-053 splits out for subscribers whose notify can fail independently of the fetch, which the board’s cannot (OQ-C resolved this reading: the board holds no delivery cursor and no per-artifact subscription row; its repo-level poll targets areforge_repo_subscriptionsrows — the table model, Approach → the four forge tables). - (b) The board subscribes to each issue individually, seeded by a repo
enumeration — REJECTED. N per-artifact
agent_forge_subscriptionsrows per repo, and the enumeration that seeds them IS a repo LIST fetch — so the LIST (and its paging/ETag problem) remains, plus a per-issue row-churn loop on top. It removes nothing and adds a reconcile. - (c) The LIST fetch feeds per-artifact state as it enumerates — ADOPTED as
the sharing seam. Every
/issuesLIST row carries the full issue payload including its forge state (forge.Issue.State,provider.go:45-46), so the one LIST fetch can feed N consumers: the board sink today; PR-C’s reverse-status mapping tomorrow (same pass, second sink — it consumes the rows this fetch already returns, never a second request, though it reads fields beyondState: DL-129’s recency guard needs the row’supdated_at, which T1 therefore parses intoforge.Issuefrom day one, see the one-fetch-path guarantee); and, when the agent-notification slice lands, LIST-fed upserts offorge_artifact_cursors.revision/snapshot/polled_atfor subscribed issues (their per-issueetagcolumn stays''— it belongs to the single-issue endpoint the LIST never hits; comments/checks ETags likewise arrive only with that slice’s own endpoints). The driver exposes this as a seam (T3: the per-pass raw-issue batch flows through oneIngester.IngestIssuescall a second sink can be composed onto), and this record keeps every downstream writer OUT of its build.
The one-fetch-path guarantee (the property Matt’s ruling bought), stated
explicitly: PR-C never issues a request against /repos/{owner}/{repo}/issues.
The endpoint has exactly one caller — this driver’s T1 client — and exactly one
cursor state — forge_list_cursors. PR-C lands as an additional consumer of
the same pass (the reverse TrackerStatusMapping applied to the same
[]forge.Issue batch the board sink receives), so DL-129’s “DL-053 poll” IS
this driver, the shared rate budget is spent once, and no convergence refactor
is ever needed. What PR-C adds on the fetch side: no request, no endpoint, no
cursor state — only, at most, additional fields parsed off rows this fetch
already returns (the updated_at its recency guard needs is parsed here in
T1; state_reason similarly, if the reopen mapping wants it). “One fetch
path” is exact; “adds literally nothing” would not be, so the claim is stated
as the former.
The four forge tables (0015_forge_subscriptions.sql)
Section titled “The four forge tables (0015_forge_subscriptions.sql)”Migration 0015 lands FOUR tables unconditionally (OQ-D1, RESOLVED land-now,
Matt 2026-08-08): the board’s repo-level target set
(forge_repo_subscriptions, new — OQ-C’s table model), the repo-LIST fetch
cursor (forge_list_cursors), and the two DL-053-spec’d tables
(agent_forge_subscriptions — DL-053’s forge_subscriptions, renamed — and
forge_artifact_cursors), the latter two adapted from the ownership-layer
DDL (compass-server-ownership-layer/design.md:976-1019, there labeled
“0004_forge”; the label predates the current migration count, so it lands
as 0015). The division of labor:
forge_repo_subscriptions— the board’s per-REPO poll targets, dynamically add/removable: an operator adds a repo → a row; removes → the row is deleted (orenabledflipped false — soft-disable that keeps cursor history), and itsforge_list_cursorsrows go inert. This REPLACES the pre-ruling config-declaredForgeConfig.Repos-IS-the-subscription-set model throughout this record: the driver enumeratesWHERE enabledat the top of every pass (T3), and the--forge-reposflag becomes a declarative SEED reconciled into the table at boot (T4). Keyed by the forge coordinate MINUS artifact:(forge_provider, forge_host, repo).forge_list_cursors— the per-page fetch cursor of a target repo’s LIST walk (DDL below; the driver’s own working table).agent_forge_subscriptions+forge_artifact_cursors— the DL-053 per-artifact agent-notification machinery, writer-less this slice: nothing this slice EXECUTES touches them; they land as anticipatory schema, shape frozen + pgtest-covered (T2 test 7) before their writers (PR-C / the agent-notification slice) exist.
Adaptations, now all settled (the two that were OQ-D’s sub-forks are ruled; the rest are the mechanical application of existing tree standards):
- Coordinate alignment (author-decided): the spec’d DDL keys on
provider TEXTand omits the forge host; the tree’s frozen issue coordinate is(forge_provider SMALLINT, forge_host, repo, number)(0013_issues.sql:29-35— “forge coordinate: the idempotency key”). Every 0015 key adopts the 0013 convention (SMALLINT provider enum +forge_host TEXT NOT NULL) so cursor and subscription rows join theissuescoordinate without casts and GHES hosts never collide onrepoalone. - Provider domain (OQ-D2, RESOLVED, Matt 2026-08-08): every provider
CHECK in 0015 admits the FULL declared proto enum —
CHECK (forge_provider IN (1, 2, 3, 4)), GitHub/GitLab/Forgejo/Linear (proto/compass/v1/compass.proto:699-706). The CHECK’s job is “never UNSPECIFIED(0)”, NOT gating rollout: rollout is gated by whichforge.Providerhas a real client (GitHub only, this slice; Matt: “we need linear for dogfood, linear and github. gitlab/forgejo can come later in beta etc.”). Admitting the whole declared domain — never a non-contiguous subset — means the future Linear-ingestion slice needs no CHECK-widening migration on any 0015 table. (An earlier draft of this record recommended narrowing toIN (1, 2, 3)to match 0013; that was WRONG — it dropped Linear, which dogfood needs, and mistook the CHECK for a rollout gate. The 0013issuesCHECK itself is the separate OQ-E prerequisite.) - The rename (OQ-C):
forge_subscriptions→agent_forge_subscriptions(Global Constraints; columns unchanged fromcompass-server-ownership-layer/design.md:978-999).
The three tables beyond the driver’s own working table:
-- The board's repo-level poll targets (OQ-C, the table model): one row per-- (provider, host, repo) the poll driver walks. enabled=FALSE soft-disables-- a target without deleting its cursor history. Populated in v1 by the T4-- boot seed reconcile (--forge-repos, bootstrap-only insert: ON CONFLICT DO-- NOTHING — the table is authoritative after the first insert); a mutation-- RPC/admin surface is a named non-goal of this slice. For GITHUB the repo-- string is lowercased at the seed/upsert boundary (GitHub owner/name is-- case-insensitive-but-case-preserving, so Owner/Name and owner/name must-- NOT mint two PK rows -> two poll targets -> two issues rows under the-- 0013 coordinate). updated_at is touched on every upsert/enable-flip,-- giving an operator a timestamp to correlate a state change against-- (the audit posture of advanced_at on forge_list_cursors).CREATE TABLE forge_repo_subscriptions ( forge_provider SMALLINT NOT NULL CHECK (forge_provider IN (1, 2, 3, 4)), forge_host TEXT NOT NULL, repo TEXT NOT NULL, enabled BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (forge_provider, forge_host, repo));
-- DL-053's forge_subscriptions, renamed agent_forge_subscriptions (OQ-C) and-- coordinate-aligned; columns otherwise per the spec'd DDL-- (compass-server-ownership-layer/design.md:978-999). Writer-less this-- slice — the agent-notification slice brings its writers.CREATE TABLE agent_forge_subscriptions ( id TEXT PRIMARY KEY, agent_account_id TEXT NOT NULL REFERENCES agent_accounts (account_id) ON DELETE RESTRICT, forge_provider SMALLINT NOT NULL CHECK (forge_provider IN (1, 2, 3, 4)), forge_host TEXT NOT NULL, repo TEXT NOT NULL, kind SMALLINT NOT NULL CHECK (kind IN (1, 2)), number BIGINT NOT NULL, delivered_revision TEXT NOT NULL DEFAULT '', delivered_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (agent_account_id, forge_provider, forge_host, repo, kind, number));CREATE INDEX agent_forge_subscriptions_artifact_idx ON agent_forge_subscriptions (forge_provider, forge_host, repo, kind, number);
-- DL-053's per-artifact FETCH cursor, coordinate-aligned; columns otherwise-- per the spec'd DDL (compass-server-ownership-layer/design.md:1007-1019).-- Writer-less this slice — PR-C / the agent-notification slice bring its-- writers; its DL-053-spec'd garbage-collection ("collected when the-- artifact's last subscription is deleted") is likewise a writer-slice-- concern, deferred with the writers.CREATE TABLE forge_artifact_cursors ( forge_provider SMALLINT NOT NULL CHECK (forge_provider IN (1, 2, 3, 4)), forge_host TEXT NOT NULL, repo TEXT NOT NULL, kind SMALLINT NOT NULL CHECK (kind IN (1, 2)), number BIGINT NOT NULL, etag TEXT NOT NULL DEFAULT '', -- issue/PR endpoint comments_etag TEXT NOT NULL DEFAULT '', -- comments endpoint checks_etag TEXT NOT NULL DEFAULT '', -- check-runs endpoint (PRs only) revision TEXT NOT NULL DEFAULT '', snapshot JSONB, -- last observed state, for DetectChanges polled_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (forge_provider, forge_host, repo, kind, number));The driver’s own working table:
-- FETCH cursor for a repo-level issue LIST walk: one row per (coordinate,-- page). The repo-LIST analogue of forge_artifact_cursors — a durable-- conditional-GET cache, one writer (the poll driver), N read-side consumers.-- etag advances ONLY after every row of that page's content is durably sunk-- (the projection upsert is the snapshot store — see the record's Idempotency-- constraint). has_next persists the Link-chain fact so a 304 (which need not-- re-send Link) can keep walking a multi-page repo. NOTE the one gap of the-- stored-Link walk: content that enters the repo sorted BEYOND the last stored-- page (an issue transferred/imported with an old created_at, under GitHub's-- default created-desc order) perturbs no stored page — every page 304s and-- the last row's has_next=false stops the walk, so the new content is missed-- until unrelated page-1 activity forces a 200. The driver closes this with a-- bounded unconditional probe of last_page+1 every Nth all-304 walk (see the-- poll driver) — no schema change. advanced_at records the last content-- advance (an etag-storing 200+sink), NOT the last poll: an all-304 tick reads-- the page but rewrites no row, so the column deliberately names "last change".CREATE TABLE forge_list_cursors ( forge_provider SMALLINT NOT NULL CHECK (forge_provider IN (1, 2, 3, 4)), forge_host TEXT NOT NULL, repo TEXT NOT NULL, page INTEGER NOT NULL CHECK (page >= 1), etag TEXT NOT NULL DEFAULT '', has_next BOOLEAN NOT NULL DEFAULT FALSE, advanced_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (forge_provider, forge_host, repo, page));What the durable cursor buys over the pre-ruling in-memory ETag map: no full
state=all re-fetch storm on server restart (an unchanged repo is an all-304
pass from the first post-boot tick — the projection is already durable and
Rehydrate-seeded, serve.go:259-263, so a 304 page needs no re-sink);
a durable substrate a future multi-replica server COULD share (a precondition,
not a sufficiency — the projection sink assumes single-threaded ingestion per
coordinate, issue_projection.go:62-67, so multi-replica would still need
per-coordinate serialization / leader election this slice does not design);
and the “shared fetch primitive” PR-C rides is a real table, not an aspiration
inside one process’s heap.
The GitHub read client (forge.GitHub, new file go/internal/forge/github.go) — T1, survives the ruling
Section titled “The GitHub read client (forge.GitHub, new file go/internal/forge/github.go) — T1, survives the ruling”The pre-ruling T1 survives nearly intact — the client is the shared fetch half
either way. The ONE structural change the ruling forces: the client becomes
stateless about cursors. The pre-ruling cut kept a per-URL in-memory
ETag + parsed-response + Link-chain cache inside the client; under Option 2
the ETag lives in forge_list_cursors and the DRIVER owns it (it must — only
the driver can see the sink outcome the advance is gated on, which is exactly
why an in-client cursor was already flagged as structurally unable to be
correct: “the client’s sole seam is ListIssues([]Issue, error)”,
provider.go:37-53 carries no timestamp and no sink signal). So the client
gains a page-level conditional-fetch primitive that takes the caller’s ETag
and reports NotModified without caching anything, and the whole-walk
ListIssues remains as an unconditional convenience built on it (it is also
the read half of the eventual forge.Provider, provider.go:195-207; no
var _ Provider assertion — read half only).
A hand-rolled net/http client (OQ-6). Behavior, in order of the request
lifecycle:
- Auth. Every request carries
Authorization: Bearer <token>where the token comes fromTokenSource.Token(ctx)— resolved per request batch, not captured at construction, so a rotatedserver_onlysecret takes effect without a restart (composes with DL-052; see T4 for the resolve wiring). - Conditional requests (ETag/304) — the steady-state reducer. The caller
passes the page’s stored ETag; the client sends
If-None-Matchand maps a304 Not ModifiedtoListPage{NotModified: true}— no body, no re-parse, nothing cached client-side. Per current GitHub docs a 304 is not charged against the core rate limit. That fact is load-bearing for the budget math and MUST be reverified against current GitHub REST docs at T1 (the rate-limit docs have been revised before). On a no-change tick every page of a quiet repo costs zero rate budget and sinks nothing (the projection already holds the rows durably). - Rate budget. After every response the client records
x-ratelimit-remainingandx-ratelimit-reset. Before issuing a request, ifremaining <= reserve(default reserve 10), it fails fast with a typed budget error (forge.ErrBudgetExhausted, exact shape in T1 Interfaces) rather than burning the tail of the budget; the driver skips that cycle and retries next tick (safe: re-poll is idempotent,ingest.go:51-52). Budget exhaustion is an EXPECTED skip, not a failure — the driver detectserrors.Is(err, forge.ErrBudgetExhausted)and logs it atslog.Warn, NOT theslog.Errorpath a real provider/sink error takes, so a rate-limited window is not per-tick Error noise. 403 disambiguation (a single rule — GitHub overloads 403 across rate-limit, bad-credentials, and repo-permission causes): a403/429carryingretry-afterOR a zeroedx-ratelimit-remaining→forge.ErrBudgetExhausted(Warn, budget-skip, NO token re-resolve); a401, or a403whose bodymessageis a bad-credentials/permission error with NO rate-limit headers →*forge.StatusErrorAND, before returning, the client callsInvalidate()on itsTokenSource(Interfaces below) so the NEXT batch re-resolves — the T4 TTL cache drops its value onInvalidate; the seam is the client’s, since the client is the only component that observes the auth failure (the T4 secret-resolve bullet wires it); any other non-2xx →*forge.StatusErroronly (step 7). The discriminator is the presence ofretry-after/ a zeroedx-ratelimit-remaining, nothing else — so a dead token is never mistaken for a rate-limit skip (which would retry forever with the dead token, never re-resolving) and a rate-limit 403 never becomes per-tick Error spam. The client never sleeps holding the caller’s poll slot — backoff is the driver’s ticker, not an in-clienttime.Sleep. (Under sustained pressure a skipped tick can defer ingestion up to a full reset window (~60 min); acceptable given idempotency, and ETag/304 keeps steady-state spend near zero.) - Pagination. GitHub’s list-issues endpoint pages; the client requests
per_page=100.ListIssuesPagereports whether an RFC-5988Link: rel="next"exists (HasNext) — the DRIVER walks pages so it can sink and advance the cursor per page;ListIssuesfollows the chain itself and concatenates (theIngestercontract is “fetches every issue for repo”,ingest.go:48). - Filter mapping.
forge.IssueFilter{State, Labels}(provider.go:182-188) maps tostate=/labels=query params. An emptyState— which is what the board path always sends — maps tostate=all, this provider’s documented default (provider.go:183-184delegates the empty case to “the provider’s default”). Rationale: the board needs closed tracker issues to flow so theirForgeStatestays fresh on the row; an open-only default would silently freeze closed issues at their last-seen forge state. (The reopen→un-archive LIFECYCLE transition rides tracker-STATUS ingestion — PR-C, DL-129 — not this slice, which writesForgeStatebut neverState;state=allhere only keepsForgeStatecurrent on closed rows. Under Option 2 PR-C applies that mapping to THIS driver’s rows — the samestate=allfetch is its input.) - PR exclusion. GitHub’s issues API returns pull requests as issues
(rows carrying a
pull_requestkey); the client drops them — the board’s PR surface is a separate lane, andforge.Issue(provider.go:37-53) is issue-shaped only. - Error mapping. Any non-2xx/non-304 response that step 3’s 403
disambiguation does NOT classify as a budget skip becomes
&forge.StatusError{Status: resp.StatusCode, Message: <GitHub message field>}(provider.go:216-227), wrapped soerrors.Asfinds it. Bodies are returned RAW — the owner-header strip belongs to ingestion (provider.go:42-44,ingest.go:67-74), never the provider.
The conditional-poll driver (ingest.Driver, new file go/internal/ingest/driver.go) — the DL-053 poll
Section titled “The conditional-poll driver (ingest.Driver, new file go/internal/ingest/driver.go) — the DL-053 poll”The DL-053 poll loop, specialized to its first artifact class (the repo LIST)
and its first subscriber (the board). It lives in the ingest package (it
composes the package’s own Ingester; a new package would be a second
convention for no gain) and consumes the durable cursor through the
package-local PollStore structural interface (Global Constraints → ingest
imports no store).
-
Run(ctx)performs one immediate pass at start (so a freshly booted demo server shows the board without waiting a full interval), then ticks ontime.Ticker(interval). -
Each pass first enumerates its targets —
PollStore.ListEnabledRepos, theforge_repo_subscriptions WHERE enabledread (OQ-C’s table model) — then iterates them sequentially: one forge, one budget; parallel per-repo fetches would just race the same rate limit. A row added between ticks is polled on the next pass; a disabled/deleted row simply stops being polled (itsforge_list_cursorsrows are inert and its issues remain in the projection). No restart either way. -
Per-repo pass = the DL-053 read-modify-advance cycle, page-wise:
- Read the repo’s stored page cursors (
PollStore.ListCursor→ page →{etag, hasNext}). - From page 1:
ListIssuesPage(ctx, repo, forge.IssueFilter{}, page, storedETag).- 304 → nothing changed on that page and its content is already
durably sunk (the Idempotency invariant: a stored ETag attests the
sink); continue to the next page if the STORED row says
has_next(a 304 need not re-sendLink, so the chain fact is read from the cursor row, not the response). - 200 →
Ingester.IngestIssues(ctx, repo, page.Issues)(translate + sink each, stop at first sink error — the existing per-issue pipeline,ingest.go:58-63, factored to take a caller-fetched batch); ONLY on full-page sink success,UpsertListCursorPagewith the new ETag +HasNext; continue whileHasNext. - Budget error (
errors.Is(err, forge.ErrBudgetExhausted)) → Warn, abandon this repo’s pass; already-advanced pages stay advanced (their content is sunk — safe). - Any other error (provider or sink) →
slog.Error, abandon this repo’s pass WITHOUT advancing the failing page; next tick re-fetches it (the old ETag no longer matches the changed content → 200 → re-sink; idempotent on the coordinate).
- 304 → nothing changed on that page and its content is already
durably sunk (the Idempotency invariant: a stored ETag attests the
sink); continue to the next page if the STORED row says
- After a clean full walk,
PruneListCursorPages(repo, lastPage)— a repo that shrank drops its stale tail rows. - Boundary probe (closes the stored-
has_nextgap, M1): the stored-Link walk in step 2 stops at the last stored page, so content that enters sorted BEYOND that boundary without perturbing any stored page (a transfer/import with an oldcreated_at, under GitHub’s defaultcreated-desc order) is invisible to a pure 304 walk until unrelated page-1 activity forces a 200. To bound the staleness, when a walk served ALL 304s (no page advanced) the driver unconditionally fetcheslastPage+1once every Nth such consecutive all-304 walk (default N = 60, ~hourly at the 1m interval; the counter is per-repo, reset by any 200). A never-stored page is already anetag=''unconditional GET, so the mechanism exists — aHasNext/non-empty result there resumes the normal walk and re-anchors the tail; an empty 200 costs one request per hour on a quiet repo. An empty probe page (HasNext=false, zero issues) writes NO cursor row — the per-page upsert is skipped when the page has zero issues, so the probe never persists an empty tail row that would then 304 forever and sit outsidePruneListCursorPages’ post-walkpage > lastPagedrop. This is a documented probe rule, not a fork.
- Read the repo’s stored page cursors (
-
A failed poll is logged, never fatal.
Runnever returns a non-nil error — its sole exit is ctx cancellation, returningnilso the errgroup drain (see wiring) treats shutdown as clean. -
Graceful shutdown:
<-ctx.Done()between passes and via the ctx threaded through fetch and sink mid-pass (bothListIssuesPageandPublishIssueUpdatetake ctx). -
The board’s delivery half is degenerate, by design: DL-053 splits the DELIVERY cursor per subscriber because “advancement conditional on a successful notify” over a shared cursor produces deterministic duplicates across subscribers with different liveness (
compass-server-ownership-layer/design.md:949-961). The board is one in-process subscriber whose notify IS the synchronous sink inside the pass — there is no wire delivery to fail independently of the fetch, so its delivery state is fully encoded by the page-cursor advance and it holds noagent_forge_subscriptionsrow — its membership is repo-level, aforge_repo_subscriptionsrow (OQ-C, resolved). Wire subscribers (agents) arrive with the notification slice and bring their owndelivered_revisionrows, exactly per spec. -
The PR-C seam (downstream consumer, OUT of this build): the per-pass raw batch (
[]forge.Issueper 200 page) flows through oneIngester.IngestIssuescall. PR-C composes its reverseTrackerStatusMappingsink onto that same batch (and its per-artifactforge_artifact_cursors.revision/snapshot/polled_atupserts, LIST-fed — no per-issue GET), in its own slice. Nothing in this record’s build writesagent_forge_subscriptionsorforge_artifact_cursors. -
Observability: one
slog.Infoper completed repo pass with fieldsrepo,issues(count sunk),pages,not_modified(bool: whole walk served 304s),dur,ratelimit_remaining; oneslog.Warnper budget-skipped pass (an expected condition, neverError); oneslog.Erroronly per genuinely failed pass (provider/sink error) withrepo,err. Boot logs oneslog.Infowithseed_repos(the reconciled seed count) andinterval; the live target count is per-pass (reposon the pass Info line would mislead — targets are table rows, not boot state). No per-issue logging — a 500-issue repo must not emit 500 lines per minute.
Boot wiring + config (server/serve.go, cmd/compass-server/main.go)
Section titled “Boot wiring + config (server/serve.go, cmd/compass-server/main.go)”Mirrors the two established precedents exactly:
- Config surface:
server.ServeConfig(serve.go:56-89) gains an optionalForge ForgeConfigfield, all-optional exactly likeS3(serve.go:71-75): forge polling disabled → no driver, today’s behavior, zero new requirements on existing deployments (the 0015 tables exist but sit empty — a migration is not a behavior change). The CLI (main.go) maps flags with env fallback mirroring theCOMPASS_S3_*precedence (main.go:127-138):--forge-repos/$COMPASS_FORGE_REPOS(comma-separatedowner/name) — now a declarative SEED forforge_repo_subscriptions, NOT the live subscription set;--forge-poll-interval/$COMPASS_FORGE_POLL_INTERVAL(default1m);--forge-secret/$COMPASS_FORGE_SECRET(the declared secret NAME, defaultGITHUB_FORGE_TOKEN; the VALUE never crosses a flag);--forge-host/$COMPASS_FORGE_HOST(defaultgithub.com, for GHES later; the API base URL derives from it); plus--forge-poll/$COMPASS_FORGE_POLL(bool, default false) to run the driver with an EMPTY seed (targets already in the table). Polling is enabled iff--forge-pollis set OR the seed list is non-empty; only then are the driver, the startup secret resolve, and the seed reconcile built. - Polling-disabled boot Warn: when polling is DISABLED (
--forge-pollunset AND an empty seed) butforge_repo_subscriptionsholdsenabledrows for the bound(provider, host), boot emits ONEslog.Warn(“forge polling disabled but N enabled targets exist; set —forge-poll”) — one query at boot (the store is already open at the gate decision), a warning and never fail-fast (a deployment that lands 0015 plus a manual row insert without the flag keeps today’s no-driver behavior). The count covers ONLY the bound(provider, host)’s enabled rows, so it gives no false comfort about rows abandoned under a prior--forge-host(see the seed reconcile). - Seed reconcile (v1 population of
forge_repo_subscriptions): at boot, before the driver’s first pass, each seed repo is INSERTED intoforge_repo_subscriptionswithenabled = TRUEunderON CONFLICT … DO NOTHING— a bootstrap-only insert: the flag creates rows that do not yet exist and leaves an existing row entirely untouched; the TABLE is authoritative after the first insert (Matt’s OQ-C ruling intent — “needs to be easily able to add/remove repos” — read to its end: the table, not the flag, is the source of truth, dynamically). The seed is ADDITIVE, never destructive — doubly so: a repo present in the table but absent from the flag is NOT deleted and NOT disabled, AND a row an operator soft-disabled STAYS disabled across restarts regardless of the flag (the flag never flips an existing row’senabled— this eliminates the footgun where a repo soft-disabled during an incident, still listed in the deploy’s--forge-repos, is silently re-enabled by a routine restart). Re-enabling, like removal, is a table operation (SetForgeRepoSubscriptionEnabled/ SQL, or the future mutation surface). For GITHUB each seed repo string is lowercased before insert (the normalization rule in the DDL comment); renamed/transferred-repo drift (GitHub serves a 301 thatnet/httpfollows silently while the coordinate keeps the stale name) is OUT OF SCOPE for this slice. Seed rows are keyed under the configured--forge-host: changing--forge-hostbetween boots ABANDONS (does not migrate) the prior host’s rows — they stay present and enabled but are never polled (the adapter binds one(provider, host)), and the polling-disabled boot Warn above counts only the BOUND(provider, host)’s enabled rows. The live target set is ALWAYS the table (WHERE enabled, read per pass, T3); the flag only feeds it new rows. This keeps a zero-UI operational path for the MVP while the table supports dynamic add/remove; the mutation RPC/admin surface is a named non-goal (Global Constraints). - Secret resolve: the driver’s
TokenSourcecloses over the onesecrets.SpecResolverbuilt atserve.go:287, callsResolve(ctx, "forge poll")(secrets/resolver.go:135), and selects the configured name from the returned[]ResolvedSecret— the same single-resolve-surface DL-052 mandates; the row is declaredserver_onlyso it never reaches a container (mechanism per the ownership-layer T5,compass-server-ownership-layer/design.md:1991-1995). Resolve is not cheap —resolver.go:135-165reads the entire declared-secret registry from the store, writes a manifest temp file, and drives a full secretspec providerLoad(potentially an external provider call) per invocation — so theTokenSourceimplementation caches the resolved value behind a short TTL (default5m); itsInvalidate()(the T1 seam) drops the cached value so the nextTokencall re-resolves, and the client calls it on a401/bad-creds-403(Approach step 3). So a resolve happens on TTL expiry OR on an auth failure, NOT once per poll pass: a token that rotates rarely does not tax the store and secrets provider every minute, while a rotation still takes effect within the TTL (or immediately on the next auth failure). A startup resolve is attempted once when forge config is present, and its two failure modes get distinct error text so a crash-loop is diagnosable: an UNDECLARED/misnamed secret (the configured name is absent from the resolved set) is a permanent misconfiguration —"forge secret %q not declared"; a resolve that ERRORS at boot (secrets provider unreachable, store read failing) is transient —"forge secret resolve failed at startup: %w". Both fail fast, but atserve.go:287the resolver is built AFTER the UDS listener binds, so the validation follows the listener-cleanup path theRehydratefailure already uses (serve.go:260-263), returning the error fromServerather than a baremain.goexit. A resolve that breaks at RUNTIME (provider outage, a declaration deleted post-boot) surfaces per-pass as an auth error →slog.Error+ retry next tick (idempotent); to keep a permanently broken secret from degrading to silent forever-Error, the driver emits a distinctslog.Error“forge token unresolvable” the first time a resolve fails and on recovery — a minimal health surface without per-tick spam. - Poll-store adapter:
serve.gobuilds the small adapter satisfyingingest.PollStoreover the already-open*store.Store(st), binding the driver’s(provider, host)so the ingest-side interface stays repo-keyed. This is the store seam the ingest package’s no-store rule requires (Global Constraints), following the established break-the-cycle/adapter convention ofwireHubServiceCycles(serve.go:298-301). - Lifecycle: the driver runs as one more
g.Gomember of the existing serve errgroup (serve.go:327-339) —g.Go(func() error { return driver.Run(gctx) })— so it inherits exactly the scoped lifecycle the doors have: cancelled on SIGINT/SIGTERM via thesignal.NotifyContextctx (main.go:146-162), first-error-wins, drained with everything else. No bespoke goroutine + done-channel machinery. - Ingester assembly:
ingest.NewIngester(gh, issueBrd, &compassv1.ForgeRef{Provider: compassv1.ForgeProvider_FORGE_PROVIDER_GITHUB, Host: cfg.Forge.Host})(ingest.go:44; enum atgen/compass/v1/compass.pb.go:449), whereissueBrdis the existing projection instance built atserve.go:259— shared writer/reader exactly as the board pgtest drives it (service_board_pgtest_test.go:13-15).
Alternatives considered
Section titled “Alternatives considered”- (a) go-github library — REJECTED (OQ-6, frozen Matt 2026-07-27). It
would add a dependency for what is ~300 LOC of
net/http, and its high-level API lacks the hook this driver’s core mechanism needs: first-class conditional-request (If-None-Match/304) handling fused with anx-ratelimit-*budget gate ahead of each call. Hand-rolling makes the budget/ETag path the PRIMARY path, not a retrofit around a library’s transport. Not re-opened here; recorded as a Global Constraint. - (b) A distinct, simpler board-ingestion loop with an in-memory ETag cache
(the pre-ruling Option 1 / Option 1.5) — CLOSED by Matt’s OQ-A ruling,
2026-08-07. Option 1 left the DL-053 convergence asserted-not-designed
(a per-LIST-PAGE in-memory ETag is not a per-artifact cursor behind a store
seam — a later merge would be a fetch-layer rewrite) and left the PR-C
double-fetch of
/issuesfor a future decision; Option 1.5 made the cursor durable but still deferred the subscriber model. Matt ruled Option 2: build on the DL-053 machinery from day one so PR-C lands on one poll path and no convergence refactor is ever needed. This record is that build. See Open Questions → OQ-A (resolved). - (c) Webhooks instead of polling — deferred by DL-053 itself
(“change-detected by conditional polling in v1 (webhooks are an additive
accelerator)”,
DECISIONS.md:76). Same posture applies here: a webhook receiver is an additive accelerator that sinks through the sameIngester, never a replacement for the poll. - (d) Where the loop lives: a bespoke goroutine in
cmd/compass-servervs. the serve errgroup — chose the errgroup.main.goowns flags and signal wiring only (main.go:41-173); everything with a lifecycle runs underServe’s scoped group (serve.go:321-339). A cmd-level goroutine would duplicate drain/first-error semantics the group already audits. - (e) Per-repo goroutines in the driver — rejected. One forge, one rate
budget; sequential per-repo fetches serialize naturally against
x-ratelimit-*and keep the driver a trivially testable single loop. - (f)
since/updatedincremental sync — still deferred, but its correct home now EXISTS. Asincewatermark would shrink a CHANGED tick (ETag/304 only makes the no-change tick free; a changed page pays a full paid refetch because GitHub’s defaultcreated-desc order shifts page boundaries on insert). The pre-ruling record could not adopt it: an in-client watermark cannot observe the sink outcome, so advancing on fetch success strands un-sunk issues on a mid-pass sink failure (Global Constraints → Idempotency). Under Option 2 the driver owns a DURABLE, sink-gated cursor — asincewatermark advanced only on full-walk success is now a small additive column onforge_list_cursors, correct by the same invariant. It is still NOT in this slice (v1 cost is bounded and budget-gated without it); it is recorded as the first cheap optimization the durable cursor unlocks, adoptable without design change. - (g) Widening the DL-053 subscription table to hold the board as a row
(nullable
agent_account_id+ a subscriber-kind discriminator) — REJECTED; OQ-C resolved to a separate repo-level table instead. It would make “the board is a subscriber” literal at the price of amending DL-053’s spec’d shape (agent_account_id TEXT NOT NULL REFERENCES agent_accounts,compass-server-ownership-layer/design.md:980) and forcing every future subscription query through a kind filter — for a subscriber that needs neitherdelivered_revision(its delivery is the in-pass sink) nor per-artifact granularity (its targets are whole repos). Matt’s OQ-C ruling keeps the two concepts in two tables:agent_forge_subscriptions(the DL-053 per-artifact row, renamed) andforge_repo_subscriptions(the board’s repo-level targets). See OQ-C. - (h) Seed conflict semantic
ON CONFLICT … DO UPDATE SET enabled = TRUE(flag-wins-for-its-members) — REJECTED in favor ofDO NOTHING(bootstrap-only insert). Under DO-UPDATE the flag re-enables any soft-disabled row still listed in--forge-reposat every boot, so KEEPING a repo disabled requires a config edit AND a redeploy — re-coupling the disable path to the deploy cycle, undoing exactly the runtime mutability the OQ-C table ruling bought — and a repo an operator soft-disabled during an incident is silently re-triggered by a routine restart. Under DO NOTHING the flag only bootstraps rows that do not exist; every subsequent state change is a table operation. Weighed here explicitly so the DO NOTHING choice is ratified knowingly at design-PR review (OQ-C).
T1 — forge.GitHub: the hand-rolled net/http GitHub read client
Section titled “T1 — forge.GitHub: the hand-rolled net/http GitHub read client”New file go/internal/forge/github.go (+ github_test.go). Carried over from
the pre-ruling cut with ONE structural change: the client is stateless about
cursors — the page-level primitive takes the caller’s ETag and reports
NotModified; no in-memory ETag/response/Link cache exists. Implements the
request lifecycle from the Approach: bearer auth via TokenSource,
conditional GET per page, x-ratelimit-* budget gate with a typed budget
error + the 403 disambiguation rule, per_page=100 + Link-header HasNext
detection, IssueFilter → query-param mapping (empty State → state=all),
pull_request-row exclusion, non-2xx → *forge.StatusError. Raw bodies pass
through untouched (provider.go:42-44). No Provider interface assertion —
read half only. One additive field: T1 widens forge.Issue
(provider.go:37-53) with UpdatedAt time.Time, parsed from the LIST row’s
updated_at, so PR-C’s DL-129 recency guard reads it off this same fetch
(Approach → one-fetch-path guarantee); the board sink ignores it.
Interfaces:
// TokenSource yields the current forge token and lets the client drop a cached// value when it observes an auth failure, so the next batch re-resolves. Token// is called per fetch batch (not per request) so a rotated server_only secret// takes effect without restart; the client calls Invalidate on a 401 /// bad-creds-403 (Approach step 3). The T4 implementation is a TTL cache over// the SpecResolver whose Invalidate drops the cached value (a bare func would// have no seam to observe the auth failure through).type TokenSource interface { Token(ctx context.Context) (string, error) Invalidate()}
// GitHubConfig configures a GitHub read client.type GitHubConfig struct { Host string // "github.com" or a GHES host; API base derives from it Token TokenSource // required Client *http.Client // nil -> a default client with a sane timeout}
func NewGitHub(cfg GitHubConfig) *GitHub
// ListPage is one conditional page fetch result. On NotModified, Issues is// nil and ETag/HasNext are zero — the caller's stored cursor row remains the// truth for both.type ListPage struct { Issues []Issue ETag string // the response ETag to store on sink success HasNext bool // an RFC-5988 Link rel="next" was present NotModified bool // 304: content unchanged vs the etag argument}
// ListIssuesPage fetches one page (1-based) conditionally: etag == "" is an// unconditional fetch. The client holds NO cursor state — the caller owns it.func (g *GitHub) ListIssuesPage(ctx context.Context, repo string, f IssueFilter, page int, etag string) (ListPage, error)
// ListIssues is the unconditional full walk (concatenated pages), built on// ListIssuesPage. Satisfies ingest.forgeReader (ingest.go:23-25) structurally// and is the read half of the eventual forge.Provider (provider.go:200).func (g *GitHub) ListIssues(ctx context.Context, repo string, f IssueFilter) ([]Issue, error)
// ErrBudgetExhausted is returned (wrapped) when x-ratelimit-remaining is at or// under the reserve; the caller skips the cycle and retries next tick.var ErrBudgetExhausted = errors.New("forge: rate budget exhausted")Consumes: forge.IssueFilter (provider.go:182-188), forge.Issue
(provider.go:37-53, widened here with UpdatedAt), forge.StatusError
(provider.go:216-227),
net/http, encoding/json (stdlib only — OQ-6).
Test cycle (unit, stubbed http.RoundTripper; red-first per repo convention):
- 200 with issues JSON → parsed
[]forge.Issuefield-by-field (incl. raw body untouched, labels, state, ForgeAccount, andUpdatedAtparsed from the row’supdated_at). ListIssuesPagewith a non-emptyetagsendsIf-None-Match; a scripted 304 returnsNotModified: truewith nil Issues and no body parse; an emptyetagsends NOIf-None-Matchheader.x-ratelimit-remaining: 0on response N → call N+1 failserrors.Is(..., ErrBudgetExhausted)WITHOUT issuing a request (assert via RoundTripper call count); after scripted reset, calls resume.HasNextis true exactly when aLink: rel="next"header is present;per_page=100and the requestedpageland on the wire;ListIssuesconcatenates a scripted two-page walk in order.- Empty
IssueFilter.State→state=allon the wire; labels join correctly. - A
pull_request-keyed row is dropped. - 404/500, and a 403 WITHOUT rate-limit headers (a bad-credentials/permission
message) →errors.As(..., *forge.StatusError)with matching Status and the GitHubmessagefield, AND the client callsInvalidate()on a fakeTokenSourceexactly once (the 401/bad-creds-403 discriminator of Approach step 3 — asserted on the ABSENCE ofretry-after/zeroedx-ratelimit-remaining, so a rate-limit 403 does NOT callInvalidate). - Token from
TokenSourcelands asAuthorization: Bearer …; a TokenSource error propagates without a request. 403/429withretry-after(or a zeroedx-ratelimit-remainingon the prior response) → next call failserrors.Is(..., ErrBudgetExhausted)without issuing a request (RoundTripper call count asserted).- Absent/malformed
x-ratelimit-*headers do not wedge the gate — the client proceeds (treats unknown budget as available) rather than permanently skipping. - ctx cancellation mid-
ListIssues-walk (cancel after page 1) stops promptly and returnsctx.Err(), not a partial concatenation. - Reverify against current GitHub REST docs that a 304 is not charged against the core rate limit; record the doc URL + date in the client’s doc comment (the budget math depends on it).
T2 — migration 0015_forge_subscriptions.sql + the target/cursor store layer
Section titled “T2 — migration 0015_forge_subscriptions.sql + the target/cursor store layer”New migration go/internal/store/migrations/0015_forge_subscriptions.sql and
new file go/internal/store/forge_cursors.go (+
forge_cursors_pgtest_test.go, build-tag gated like the sibling
*_pgtest_test.go suites). The migration creates all FOUR tables
unconditionally (OQ-D1 resolved land-now): forge_repo_subscriptions +
forge_list_cursors (DDL in the Approach) + the two DL-053-spec’d tables
(agent_forge_subscriptions — renamed from the spec’s forge_subscriptions
— and forge_artifact_cursors, adapted from
compass-server-ownership-layer/design.md:976-1019, renumbered from that
record’s stale 0004_forge label, keys aligned to the 0013 issue-coordinate
convention: SMALLINT provider enum + forge_host in every key; every
provider CHECK admits IN (1, 2, 3, 4) per OQ-D2). Store methods cover the
list cursor AND forge_repo_subscriptions in this slice — the driver reads
targets from it and the T4 boot reconcile inserts new rows into it (ON CONFLICT DO NOTHING);
agent_forge_subscriptions / forge_artifact_cursors get their store
surface with their writers (the agent-notification slice / PR-C).
One enum-constant note: store.ForgeProvider today stops at Forgejo —
ForgeProviderUnspecified=0, GitHub=1, GitLab=2, Forgejo=3
(store/issues.go:30-35), and its mirror comment (issues.go:26-27) omits
Linear even though the proto declares FORGE_PROVIDER_LINEAR = 4
(gen/compass/v1/compass.pb.go:452). T2 adds ForgeProviderLinear ForgeProvider = 4 (and updates the mirror comment) so the Go domain matches
the 0015 CHECK domain; no behavior change — no producer passes 4 this slice
(the T4 wiring is GITHUB-only) — but a store-level constant must exist for a
domain the schema admits.
Interfaces:
// package store
// ForgeListPageCursor is one durable page row of a repo's issue-LIST fetch// cursor (the DL-053 FETCH-cursor model at repo-LIST granularity). ETag ""// means never fetched (an unconditional GET).type ForgeListPageCursor struct { Provider ForgeProvider // GITHUB(1)/GITLAB(2)/FORGEJO(3)/LINEAR(4); never 0 Host string Repo string Page int32 // 1-based ETag string HasNext bool}
// ForgeListCursor reads every stored page row for the repo, ascending page.// No rows is a nil slice, not an error (a never-polled repo).func (s *Store) ForgeListCursor(ctx context.Context, provider ForgeProvider, host, repo string) ([]ForgeListPageCursor, error)
// UpsertForgeListCursorPage inserts-or-updates one page row (touching// advanced_at — the last CONTENT advance, since this is called only after a// 200+sink; an all-304 tick rewrites no row). Called by the driver ONLY after// the page's content durably sank// — the advance-attests-sink invariant lives in the caller; the store method// is a plain upsert. Zero/empty coordinate fields -> ErrInvalidArgument.func (s *Store) UpsertForgeListCursorPage(ctx context.Context, cur ForgeListPageCursor) error
// PruneForgeListCursorPages deletes the repo's page rows with page > maxPage// (a repo whose walk shrank). maxPage < 1 -> ErrInvalidArgument.func (s *Store) PruneForgeListCursorPages(ctx context.Context, provider ForgeProvider, host, repo string, maxPage int32) error
// ForgeRepoSubscription is one board poll target: a repo the poll driver// walks (OQ-C's table model). Enabled=false soft-disables the target without// deleting its cursor history.type ForgeRepoSubscription struct { Provider ForgeProvider Host string Repo string Enabled bool}
// EnsureForgeRepoSubscription inserts the target if absent; on conflict it// DOES NOTHING — the T4 seed reconcile is a bootstrap-only insert and the// table is authoritative after the first insert (the seed never deletes,// disables, or re-enables an existing row).// Zero/empty coordinate fields -> ErrInvalidArgument.func (s *Store) EnsureForgeRepoSubscription(ctx context.Context, sub ForgeRepoSubscription) error
// ListEnabledForgeRepoSubscriptions reads the enabled targets for one// (provider, host), ascending repo — the driver's per-pass target// enumeration. No rows is a nil slice, not an error.func (s *Store) ListEnabledForgeRepoSubscriptions(ctx context.Context, provider ForgeProvider, host string) ([]ForgeRepoSubscription, error)
// SetForgeRepoSubscriptionEnabled flips one target's enabled bit, touching// updated_at (the soft-disable path; the admin mutation surface is a later// slice's — this method exists for it, for operators via SQL parity, and// for tests).// Unknown coordinate -> ErrNotFound; zero/empty fields -> ErrInvalidArgument.func (s *Store) SetForgeRepoSubscriptionEnabled(ctx context.Context, provider ForgeProvider, host, repo string, enabled bool) errorConsumes: the migration runner + pgtest harness the sibling suites use
(store/issues_pgtest_test.go et al.), ErrInvalidArgument /
ErrNotFound (the store’s existing sentinels — the issues.go:103-108 and
issues.go:151-153 patterns).
Test cycle (pgtest, isolated schema per case like issues_pgtest_test.go):
- Migration applies cleanly from empty AND from a 0014 database (the sequential-migration harness proves both); all four tables exist with their PKs and CHECKs (page CHECK rejects 0; kind CHECKs reject 0/3).
- Provider CHECK domain, proven on
forge_repo_subscriptionsANDforge_list_cursors: providers 1..4 each insert cleanly; 0 and 5 are each rejected — the CHECK admits the full declared enum and nothing else (OQ-D2 at the schema). ForgeListCursoron a never-polled repo → nil, no error.- Upsert page 1 then re-upsert with a new ETag → one row, new ETag,
advanced_atadvanced; rows come back ascending by page. - Two hosts / two providers with the same
repostring do not collide (the coordinate rationale, proven). PruneForgeListCursorPages(maxPage=2)on a 4-page repo leaves pages 1-2; pruning a never-polled repo is a no-op.agent_forge_subscriptionsFK shape: inserting a row with an unknownagent_account_idfails (RESTRICT, per the DL-053 DDL and the 0006-established convention0006_delivery_cursors.sql:6-9) — the table is writer-less this slice but its shape is proven here. Unconditional (OQ-D1 resolved land-now).- Repo-subscription CRUD: ensure-insert creates the row enabled;
re-ensure of the same coordinate is idempotent (one row); ensure-insert
over a soft-disabled row leaves it DISABLED (
ON CONFLICT DO NOTHING— the bootstrap-only semantic; this leg goes RED against a DO-UPDATE regression);ListEnabledForgeRepoSubscriptionsreturns only enabled rows for the asked (provider, host), ascending repo, and nil for none;SetForgeRepoSubscriptionEnabled(false)removes the row from the enabled list WITHOUT deleting it and advancesupdated_at(the audit column); unknown coordinate →ErrNotFound. - Invalid input: zero provider / empty host / empty repo / page 0 →
ErrInvalidArgumenton each method (cursor and subscription both).
T3 — ingest.Driver: the DL-053 conditional-poll driver
Section titled “T3 — ingest.Driver: the DL-053 conditional-poll driver”New file go/internal/ingest/driver.go (+ driver_test.go), plus one
refactor inside ingest.go: extract the translate+sink loop of Ingest
(ingest.go:58-63) into IngestIssues(ctx, repo, raws) so the driver can
feed caller-fetched page batches through the existing per-issue pipeline;
Ingest becomes ListIssues + IngestIssues (behavior unchanged — the
existing ingest_test.go suite is the regression net). Each pass begins
with a fresh target enumeration (PollStore.ListEnabledRepos — the
forge_repo_subscriptions WHERE enabled read, bound to the driver’s
provider+host by the T4 adapter), so a target added or disabled between
ticks takes effect on the next pass with no restart. Pass algorithm,
page-wise cursor advance, budget/error classification, logging per the
Approach; nil return on ctx cancellation.
Interfaces:
// package ingest
// IngestIssues translates + sinks a caller-fetched batch of raw issues for// repo: the loop body of Ingest, exposed so the DL-053 driver (which fetches// page-wise to gate cursor advance on sink success) reuses the one pipeline.// Stops and returns the first sink error (wrapped); partial progress is fine —// a re-poll is idempotent on the coordinate.func (in *Ingester) IngestIssues(ctx context.Context, repo string, raws []forge.Issue) error
// ListPageCursor is the driver's view of one durable page-cursor row.type ListPageCursor struct { Page int ETag string HasNext bool}
// PollStore is the durable target + FETCH-cursor surface the driver needs// — a narrow, repo-keyed structural seam (the server wiring adapts// *store.Store and binds provider+host), keeping this package's no-store// property (ingest.go:7-8). Named for the whole surface the poller uses:// its first method is target enumeration, not cursor state.// ListEnabledRepos is the per-pass target enumeration// (forge_repo_subscriptions WHERE enabled — OQ-C's table model); the three// cursor methods are the page-cursor surface.type PollStore interface { ListEnabledRepos(ctx context.Context) ([]string, error) ListCursor(ctx context.Context, repo string) ([]ListPageCursor, error) UpsertListCursorPage(ctx context.Context, repo string, cur ListPageCursor) error PruneListCursorPages(ctx context.Context, repo string, maxPage int) error}
// pageLister is the fetch seam: the page-level conditional read of the forge// client (satisfied by *forge.GitHub structurally; faked in tests).type pageLister interface { ListIssuesPage(ctx context.Context, repo string, f forge.IssueFilter, page int, etag string) (forge.ListPage, error)}
// DriverConfig configures the board-ingestion poll driver. The target repos// are NOT config — the driver reads them from PollStore.ListEnabledRepos// at the top of every pass.type DriverConfig struct { Interval time.Duration // > 0; the caller defaults it Log *slog.Logger // nil -> slog.Default()}
func NewDriver(client pageLister, ing *Ingester, cursors PollStore, cfg DriverConfig) *Driver
// Run polls until ctx is cancelled, then returns nil (clean shutdown).// Per-repo errors are logged and retried next tick, never returned — a// re-poll is idempotent on the coordinate (ingest.go:48-52).func (d *Driver) Run(ctx context.Context) errorConsumes: Ingester (ingest.go:35-46), forge.ListPage /
forge.ErrBudgetExhausted (T1), time.Ticker, log/slog.
Test cycle (unit, fake pageLister + fake PollStore + the package’s
existing recordingSink pattern, ingest_test.go:13-27):
Runperforms an immediate pass over every enabled target (ListEnabledReposfaked) before the first tick (short interval, assert call order/coverage).- ctx cancel BETWEEN ticks →
Runreturnsnilpromptly, and ctx cancel DURING a pass also returnsnilpromptly — deadline-gated, never a sleep (the pgtest convention,service_board_pgtest_test.go:15). AssertRundoes NOT return while ctx is live (it has no non-cancel exit). - Conditional walk: stored cursors
{1: e1 hasNext, 2: e2}→ page 1 fetched withetag=e1; a scripted 304 on page 1 still walks to page 2 (the storedHasNext, not the response, carries the chain); an all-304 pass sinks nothing and upserts nothing. - Cursor advance gated on sink success (defends the Idempotency
invariant): a 200 page of N issues whose sink fails at issue k →
UpsertListCursorPageis NOT called for that page and the pass aborts for that repo; next tick re-fetches the page with the OLD etag and every issue k..N sinks — none stranded. On full-page sink success the upsert carries the response ETag + HasNext. - Prune: a walk that ends at page 2 after cursors held 4 pages calls
PruneListCursorPages(repo, 2); an aborted (error/budget) pass never prunes. - A scripted failure on repo A does not stop repo B in the same pass, and repo A is retried on the next tick.
- Budget skip:
ErrBudgetExhaustedfrom the fetch → Warn (not Error), pass abandoned, pages already advanced this pass stay advanced. - Interval respected: N ticks in a bounded window drive exactly N+1 passes (the +1 is the immediate pass); no busy-loop.
- Log fields: capture a
slogtest handler and assert a happy pass logs at Info withrepo/issues/pages/dur, a budget-skip logs at Warn, and a genuine error logs at Error witherr. IngestIssuesrefactor regression:Ingeststill fetches-then-sinks identically (existingingest_test.gocases stay green unmodified), andIngestIssueson a batch stops at the first sink error with the same wrapped error shape (ingest.go:60-62).- Per-pass target enumeration is live: a repo added to the fake
ListEnabledReposresult between ticks is polled on the next pass; a repo removed/disabled between ticks is not; zero targets → an idle pass (no fetch, no sink, no Error log). ListEnabledReposreturning an error →slog.Error, the pass is skipped,Runkeeps ticking (never fatal), and the next tick re-enumerates.
T4 — serve boot wiring + config surface + secret resolve
Section titled “T4 — serve boot wiring + config surface + secret resolve”Touches server/serve.go, cmd/compass-server/main.go (edits only, no new
files beyond tests). ServeConfig.Forge all-optional like S3; CLI
flags/env per the Approach; the boot seed reconcile that inserts
--forge-repos into forge_repo_subscriptions (bootstrap-only, ON CONFLICT DO NOTHING, before the driver’s first pass); the polling-disabled
boot Warn (the Approach’s boot-Warn bullet); fail-fast on
forge-polling-enabled but secret-unresolvable; the ingest.PollStore
adapter over the open
*store.Store; driver under the existing errgroup.
Interfaces:
// server.ForgeConfig configures the board-ingestion poll driver (SEA-1810).// All-optional: polling disabled (empty SeedRepos, Poll false) leaves the// driver off (today's behavior).type ForgeConfig struct { Host string // default "github.com" SeedRepos []string // "owner/name" (--forge-repos): boot-reconciled // into forge_repo_subscriptions (bootstrap-only // insert, ON CONFLICT DO NOTHING; lowercased // for GITHUB) — a SEED, not the live target set Poll bool // --forge-poll: run the driver even with an // empty seed (targets already in the table) SecretName string // declared server_only secret name; default "GITHUB_FORGE_TOKEN" PollInterval time.Duration // default time.Minute}// ServeConfig gains: Forge ForgeConfig; polling enabled iff// Poll || len(SeedRepos) > 0
// unexported in server/: the ingest.PollStore adapter binding the driver's// forge coordinate half, so the ingest-side seam stays repo-keyed.type forgePollStore struct { st *store.Store provider store.ForgeProvider host string}// satisfies ingest.PollStore: ListEnabledRepos (→ the T2// ListEnabledForgeRepoSubscriptions, mapped to repo strings) / ListCursor /// UpsertListCursorPage / PruneListCursorPages, each delegating to the T2// store methods with the bound (provider, host).Consumes: secrets.SpecResolver.Resolve(ctx, reason) ([]ResolvedSecret, error)
(secrets/resolver.go:135), secrets.ResolvedSecret{Name, Value, …}
(secrets/secrets.go:132-135; Value redacted under all fmt verbs — safe to
thread), the board.NewIssueProjection instance from serve.go:259,
ingest.NewIngester (ingest.go:44), forge.NewGitHub (T1), the T2 store
methods, ingest.NewDriver/Run (T3), the errgroup at serve.go:327,
compassv1.ForgeProvider_FORGE_PROVIDER_GITHUB
(gen/compass/v1/compass.pb.go:449).
Produces: a TokenSource closure over the resolver that re-resolves +
selects SecretName on TTL expiry or auth failure (not per pass; see the
Secret-resolve bullet); the boot seed reconcile (each SeedRepos entry
lowercased for GITHUB and inserted enabled into forge_repo_subscriptions
via the T2 ensure-insert, ON CONFLICT DO NOTHING, before the driver’s
first pass); the polling-disabled boot Warn; the poll-store adapter;
boot-time validation
error text for missing-secret / bad-repo-format / non-positive interval.
Test cycle:
- Unit: CLI flag/env precedence for the five forge knobs (repos seed, poll
enable, interval, secret name, host) mirrors the S3 pattern (table test
like
resolveNetworkDoor’s,main.go:184-212); repo-format validation (owner/name) rejects garbage at startup; case normalization: a seed ofOwner/Namereconciles to ONE lowercasedowner/namerow, never two PK rows. - Unit: forge polling disabled (empty
SeedRepos,Pollfalse) →Servebuilds no driver and attempts no secret resolve (assert via config plumbing seam, no behavioral change to existing serve tests — the whole existing suite is the regression net; the 0015 migration alone must not change any existing test’s outcome);Polltrue with an empty seed → the driver IS built (table-driven targets, idle passes until rows exist); polling disabled with enabledforge_repo_subscriptionsrows present for the bound (provider, host) → boot emits exactly ONEslog.Warn(“forge polling disabled but N enabled targets exist”; capture a slog test handler), and no Warn when no enabled rows exist. - pgtest (build-tag gated like the board suite): a
Serve-assembled pipeline over a fake pager scripted with one issue page sinks to the real projection, lands inListBoardIssues, AND leaves aforge_list_cursorspage-1 row carrying the scripted ETag — the end-to-end boot-wiring + durable-cursor proof without touching live GitHub. The target repo enters via the seed reconcile, and the ordering assertion is DIRECT: assert the seededforge_repo_subscriptionsrow is visible BEFORE the fake pager’s first call (instrument the fake pager to snapshot the row at call time, or — if the harness can shrink the interval — assert explicit call ordering), not merely inferred from a 1m-interval timeout. - pgtest (restart-resync proof — the durable cursor’s headline buy): after
test 3, rebuild the pipeline over the SAME schema (a “restart”) with the
pager scripting a 304 for the stored ETag; assert the pass issues no
unconditional fetch, sinks nothing, and the projection still serves the
issue (Rehydrate covers the read side,
serve.go:259-263). - pgtest (the no-clobber contract’s runtime proof — the test MUST be able to
go RED): (1) ingest/create the issue via the projection, (2) set a
non-default lifecycle
Statethrough the part-5 write path (store.SetIssueState,issues.go:140), (3) re-ingest a forge UPDATE for the same coordinate (forge_state open→closed) via the fake pager, (4) assertForgeStateis updated ANDStateis unchanged from the human-set value (issue_projection.go:208,protoToForgeFieldswritesForgeStatebut neverState). A regression addingstate = EXCLUDED.stateto theUpsertIssueForgeFieldsSET clause (issues.go:121-125) MUST turn this red — the as-worded “State left untouched” over a zero-value row would stay green vacuously, so the human-set baseline in step 2 is load-bearing. - Unit: the T4
TokenSourcere-resolves after its TTL, and calling itsInvalidate()drops the cached value so the nextTokenre-resolves and the changed resolver value is used on the next fetch — the design’s stated reason for a TTL-cacheTokenSourcewith an invalidation seam rather than a captured token or a bare func. - Startup failure, two distinct texts: (a) forge polling enabled + secret NAME
absent from the resolved set →
Servereturns"forge secret %q not declared"; (b) the resolve call itself ERRORS at boot →Servereturns"forge secret resolve failed at startup: %w". Both follow the listener-cleanup path theRehydratefailure uses (serve.go:260-263), since the resolver is built after the UDS listener binds (serve.go:287); assert the two error strings are distinguishable so a permanent misconfig is not confused with a transient provider outage. - pgtest (the seed-reconcile semantic — bootstrap-only insert,
ON CONFLICT DO NOTHING, never destructive and never mutating; this test MUST be able to go RED against both a destructive-sync and a DO-UPDATE regression): reconcile with seed{a/b, c/d}→ both rows enabled; inserte/fdirectly (the “table-added” repo) and re-run the reconcile with seed{a/b}→c/dande/fBOTH survive, still enabled (a repo dropped from the flag is NOT auto-deleted or disabled); disablec/dviaSetForgeRepoSubscriptionEnabled(false)and re-run the reconcile withc/dSTILL in the seed →c/dSTAYS disabled (the flag never flips an existing row’senabled; this leg goes RED against a DO-UPDATE-SET-enabled regression); disablee/f(absent from the seed) and re-run → stays disabled.
- T1 —
forge.GitHubread client (github.go+ unit tests over a stubbed RoundTripper: 200/parse incl.UpdatedAt, conditionalListIssuesPage/304, budget gate + 403 disambiguation +TokenSource. Invalidateon bad-creds,HasNext/pagination, filter mapping incl.state=alldefault, PR exclusion, StatusError mapping, bearer auth, 304-uncharged doc reverify). - T2 — migration
0015_forge_subscriptions.sql(all four tables unconditionally:forge_repo_subscriptions+forge_list_cursors+agent_forge_subscriptions+forge_artifact_cursors, coordinate-aligned, every provider CHECKIN (1, 2, 3, 4)) +store/forge_cursors.golist-cursor + repo-subscription methods +store.ForgeProviderLinear = 4; pgtest-covered (incl. the CHECK-domain and FK-shape proofs). - T3 —
ingest.Driverconditional-poll driver (driver.go+ theIngestIssuesextraction iningest.go; unit tests: immediate pass, ctx cancel → nil, per-pass target enumeration viaListEnabledRepos, conditional walk over stored cursors, sink-gated cursor advance, prune, per-repo error isolation, budget-skip classification, interval discipline, slog fields). - T4 — serve boot wiring:
ServeConfig.Forge(seed + poll-enable), CLI flags/env, the boot seed reconcile intoforge_repo_subscriptions(bootstrap-only insert,ON CONFLICT DO NOTHING), the polling-disabled boot Warn, DL-052 secret resolve via the existingSpecResolver, theingest.PollStoreadapter over*store.Store, errgroup membership, fail-fast validation; unit + pgtest end-to-end incl. the durable-cursor restart proof, the no-clobber proof, and the seed-reconcile semantic.
Open Questions
Section titled “Open Questions”OQ-A — RESOLVED (Matt, 2026-08-07): Option 2
Section titled “OQ-A — RESOLVED (Matt, 2026-08-07): Option 2”Matt ruled Option 2: this driver is built ON the DL-053 subscription
machinery from day one — the board as a distinguished DL-053 subscriber over
the per-artifact FETCH-cursor model’s durable Postgres cursors — so
tracker-status ingestion (PR-C, per DL-129) lands on one poll path, no
convergence refactor is ever needed, and the PR-C /issues double-fetch never
arises. The T1 GitHub read client survives the ruling nearly intact (it is the
shared fetch half either way; its one structural change — cursor state moves
out of the client into forge_list_cursors — is the Approach’s stateless-client
bullet). The superseded Option 1 / Option 1.5 framings and their tradeoffs are
retained in Alternatives (b) for the reasoning trail; the fork is closed and
is not relitigated here. The follow-on shape calls INSIDE Option 2 were OQ-C
and OQ-D — both since RESOLVED (Matt, 2026-08-08), below.
OQ-C — RESOLVED (Matt, 2026-08-08): the board’s subscription set is a TABLE
Section titled “OQ-C — RESOLVED (Matt, 2026-08-08): the board’s subscription set is a TABLE”Matt: “needs to be easily able to add/remove repos, so I think it needs to be a table? It can be a different one than forge_subscriptions if needed, or that can be renamed to agent_forge_subscriptions, etc.”
Resolution, as applied through this record:
- New table
forge_repo_subscriptions— the board’s repo-level poll targets, keyed(forge_provider, forge_host, repo), withenabled BOOLEANfor soft-disable (DDL in the Approach). The driver enumeratesWHERE enabledat the top of every pass (T3), so add/remove is a row operation, live at the next tick, no restart and no deploy. - DL-053’s
forge_subscriptionslands RENAMEDagent_forge_subscriptions(Matt’s explicit option) — shape unchanged; the rename disambiguates the per-ARTIFACT, agent-owned subscription from the board’s per-REPO target (Global Constraints). The rename lands as a two-half freeze-time delta, SPLIT across this PR and a follow-up: (a) a DECISIONS.md ledger row recording the table model + the rename — this is DL-163, and it RIDES THIS PR; and (b) a one-line forward annotation at the ownership-layer DDL (compass-server-ownership-layer/design.md:978, above theCREATE TABLE forge_subscriptions) — “lands asagent_forge_subscriptions; see compass-forge-poll-driver” — so a reader entering from the frozen ownership-layer record who grepsforge_subscriptionsfinds the mapping instead of nothing. Half (b) is DEFERRED to SEA-1883, not folded here: it edits a frozen Active record, which needs Matt’s explicit call (flagged in OQ-C for his ratification at this design-PR review). This PR’s ledger delta is DL-161/DL-162/DL-163 in DECISIONS.md. - v1 population is the T4 boot seed reconcile —
--forge-reposinserted enabled where absent,ON CONFLICT DO NOTHING: additive, never destructive, never mutating an existing row — and the dynamic mutation RPC/admin surface is a named non-goal of this slice. The ON CONFLICT semantic is author-resolved to DO NOTHING (the DO-UPDATE-SET-enabled alternative is weighed and rejected in Alternatives (h)); flagged for Matt’s ratification at design-PR review.
For the trail: this record previously RECOMMENDED config-declared
(ForgeConfig.Repos as the subscription set, no rows); Matt ruled it out
because the target set must be mutable at runtime without a deploy. The
widened-subscription-row alternative stays rejected (Alternatives (g)). The
one-fetch-path guarantee is unaffected — it hangs on forge_list_cursors +
the shared pass, not on where the target set lives. The board still holds NO
delivery cursor: its notify is the synchronous in-pass sink, so that half of
the original question resolves unchanged (Approach (a)).
OQ-D — RESOLVED (Matt, 2026-08-08): all four tables land now; CHECKs admit the full enum
Section titled “OQ-D — RESOLVED (Matt, 2026-08-08): all four tables land now; CHECKs admit the full enum”D1 — land-now. Migration 0015 unconditionally lands
forge_repo_subscriptions, forge_list_cursors,
agent_forge_subscriptions, and forge_artifact_cursors (Approach → the
four forge tables). The defer alternative (each spec’d table rides its first
writer) is closed; T2 test 7 — the FK-shape proof on the writer-less
agent_forge_subscriptions — is unconditional. The coordinate alignment
(SMALLINT provider + forge_host in every key, per 0013_issues.sql:29-35)
was never the fork and stands as author-decided.
D2 — every provider CHECK admits the FULL declared enum, IN (1, 2, 3, 4). Matt: “why did we drop linear? we need linear for dogfood, linear and
github. gitlab/forgejo can come later in beta etc.” The earlier
recommendation here — narrow to {github, gitlab, forgejo} to match the
0013 coordinate — was WRONG and is corrected: it dropped Linear, which
dogfood needs, and it mistook the CHECK for a rollout gate. The CHECK’s job
is “never UNSPECIFIED(0)”; rollout is gated by which forge.Provider has a
real client (GitHub only, this slice — no producer for gitlab/forgejo/linear
issues exists in the tree). The proto enum already declares all four
providers (FORGE_PROVIDER_LINEAR = 4, proto/compass/v1/compass.proto:704
— “DL-051’s issues-only forge source: a Linear-origin Issue whose repo is
the project key”), so the 0015 tables admit the whole declared domain (never
a non-contiguous subset like IN (1, 4)) and the future Linear slice needs
no CHECK-widening migration on any of them. The issues table’s own
IN (1, 2, 3) is the separate prerequisite documented as OQ-E.
OQ-E (out of scope — the Linear-ingestion prerequisite; follow-up filed by the driver)
Section titled “OQ-E (out of scope — the Linear-ingestion prerequisite; follow-up filed by the driver)”SEA-1810’s new tables are Linear-ready (their CHECKs admit 4), but actually INGESTING Linear issues has three prerequisites, ALL out of scope for this GitHub-read slice and belonging to the future Linear-ingestion slice:
- The
issuesCHECK:0013_issues.sql:32isforge_provider SMALLINT NOT NULL CHECK (forge_provider IN (1, 2, 3))— “GitHub/GitLab/Forgejo; never UNSPECIFIED(0)”. It EXCLUDES Linear(4): the projection cannot store a Linear-provider issue today. Widening it toIN (1, 2, 3, 4)is one migration on that ONE table. - The store Go enum:
store.ForgeProviderconstants stop atForgeProviderForgejo = 3(store/issues.go:30-35). T2 already addsForgeProviderLinear = 4so the Go domain matches the 0015 CHECK domain — this half is closed by this slice; the future slice adds its producer. - A Linear
forge.Providerclient (DL-051,DECISIONS.md:129: the forge adapter is “GitHub first, Linear issues-only”;ForgeRef.hostfor Linear is the constant “linear.app”,compass.proto:711-712).
Because the 0015 tables already admit 4, that future slice touches the ONE
issues CHECK plus a client — not the whole forge-table set. The driver
files the follow-up issue; this record only documents the prerequisite. Not
load-bearing for this slice’s freeze.
Why the asymmetry (issues CHECK deferred in SQL, Go domain closed now) is
deliberate: the Go constant store.ForgeProviderLinear = 4 is required THIS
slice because 0015’s own store methods must be able to name domain value 4
(the four new tables’ CHECKs admit it), while the issues CHECK guards a
table SEA-1810 never writes with provider=4 (the driver is bound to GITHUB;
no Linear producer exists in the tree) — and widening a guard ahead of its
producer would weaken 0013’s “every issue is forge-backed” documentation, so
the widening correctly waits for the Linear producer. This deferral is
author-resolved pending Matt’s review: he may tie-break to fold the
issues-CHECK widening into 0015 if he prefers SQL/Go symmetry.
OQ-B (non-load-bearing deferral): live-demo content
Section titled “OQ-B (non-load-bearing deferral): live-demo content”Whether the dogfood demo shows LIVE GitHub issues — which would make this driver demo-critical and re-sequence its priority — is a later Matt scope call per the SEA-1810 issue body’s own escalation trip-wire. Recorded as an explicitly non-load-bearing deferral: the design is correct regardless of the ruling; only scheduling changes.