ADR-177: single canonical owner per external integration
Status: Proposed Date: 2026-05-09 Related: ADR-111 (broker — instance of this principle for model providers), ADR-116 (RAG — instance for the embedding index), ADR-115 (principle-aware reviewers — primary citer), ADR-113 (principles tier — where this lives), ADR-083 (defense-in-depth rule promotion — the substrate ladder this rides)
Context
Two concrete divergences surfaced on 2026-05-09 made the lack of a named principle expensive:
/operate/runners(factory UI) vswarp.gyrum.ai/runners. Both surfaces independently scrape GitHub Actions for fleet-runner state. ai-research'sDefaultRunnerReposlists 8 repos (after warp #2091); warp's hardcodedcanonicalFleetReposlists 5. Different repo lists, different endpoints (workflow_runsvsworkflow_jobs), different cache TTLs (25s vs 60s). Operator surfaces show drifting data and the alignment ticket warp #1567 has been parked behind aTODO: source from canonical list when warp#1567 shipscomment in warp's runners handler atapi/internal/httpapi/runners.go:70(cross-repo:gyrum-labs/warp) since the original split.- Dashboard "32 completed today" vs 43 PRs merged. "PR merged → ticket transitioned to done" is a client-side integration: each laptop's
gyrum-complete-prPOSTs/api/v1/items/auto-tallyafter merge. Any path that bypasses the wrapper (gh pr merge, GitHub web UI, admin merge, an agent without the devtools env loaded,--no-auto-tally) leaves the warp ticket open even though the work shipped. Today's gap was 11 PRs. Tomorrow it will be a different 11. Filed as warp #2133 for the structural fix.
Both shapes share the same root cause: an external dependency has no canonical in-fleet owner, so every service that needs it builds its own integration. The fleet already has worked examples of the right shape:
- Broker (ADR-111) owns model-provider integration. No service calls Anthropic, OpenAI, Ollama, or Together directly.
- RAG (ADR-116) owns the embedding index. No service maintains its own pgvector store.
- Warp owns work-items. No service has its own queue table; everything reads through warp's API.
What is missing is the general principle these specific ADRs instantiate. Without it, the next external integration (Stripe, Linear, Slack, GitHub webhooks, S3, anything else) repeats the same vacuum-fill failure mode and ships before anyone notices the duplication. Filed under warp #2130.
Decision
Every external dependency has exactly one canonical in-fleet owner. Other services that need the dependency call the owner; they do not maintain a parallel integration.
The shape is:
- "External dependency" means anything outside the fleet's in-process Postgres state — provider APIs (GitHub, Anthropic, Stripe), external data sources (RSS, webhooks), cloud services, even shared filesystems mounted into multiple services.
- "Canonical owner" is one service. The owner is responsible for the integration's credentials, rate limits, retries, audit log, and observability. The owner exposes a fleet-internal API; consumers go through that API, not through the external dependency.
- "One owner" is enforced both at authoring time (a new integration must declare its owner before the code lands) and at PR-review time (a structural scan rejects new code that adds a second consumer without an owner annotation).
This explicitly does not mandate a microservice-per-domain architecture. A monolith with one well-bounded GitHub-Actions client that 5 in-process consumers read from is fine; what is not fine is two services each maintaining their own GitHub-Actions client.
The principle rides the ADR-083 defense-in-depth ladder, layered four ways:
| Layer | Mechanism | Catches |
|---|---|---|
| Authoring | persona reviewers (per ADR-115) quote ADR-177 when they see a second integration appear | future code at design time |
| PR-review | gyrum-fleet-integrations --check (warp #2131, warp #2132) — gate on diff |
net-new duplication at merge time |
| Runtime | broker / RAG / warp themselves enforce single-path access for their domain | bypass attempts at runtime |
| Periodic | nightly cron runs the scan against the full fleet, files warp tickets when a new multi-consumer endpoint appears | drift after merge, including changes outside the gated repos |
The CLI baseline is ratcheting: existing duplications (today's runners-vs-runners and PR-merge-vs-tally) are locked into the baseline as known violations and the count can only go down. New duplications fail the gate; resolving an existing one decreases the baseline. Sister-shape exactly to gyrum-canon-coverage (warp #2127) and shellcheck-budget (warp #782).
When migration is required (an existing service has its own integration that should move to the canonical owner), the migration ticket cites this ADR, declares the future owner, and sequences the cutover. The "TODO: source from canonical list" comment in warp's runners handler (cross-repo reference: gyrum-labs/warp api/internal/httpapi/runners.go) becomes an explicit owned ticket rather than a parked note.
Consequences
What becomes easier.
- Single audit surface per integration. Every GitHub call goes through one service; "where did this rate-limit come from" has one answer.
- Single credential surface. Rotations, OAuth flows, webhook secrets live in one place. The "n+1 service learns it had a second copy of the token" failure mode disappears.
- Single point of repair when an external API changes. Provider deprecates an endpoint → one service updates, every consumer keeps working.
- Cross-service consistency. The runners-vs-runners divergence today exists because two scrapers can disagree; with one owner there is one answer to "what's the fleet's runner state."
- Cheaper onboarding. New services don't re-derive integration patterns; they discover the canonical owner via the integrations registry and call it.
What becomes harder.
- Adding a new external integration requires upfront design ("who owns this?") rather than just shipping a client. The principle pushes the cost of duplication forward in time, where it is cheaper to address than after two services have diverged.
- The canonical owner becomes a critical path for its domain. Mitigation: the owner pattern is the same one ADR-111 (broker) and ADR-116 (RAG) already operate; the failure modes are known and the playbooks are written.
- Migrations of existing duplications cost work. Today's worked examples (warp #1567, warp #2133) are non-trivial. Mitigation: ratchet baseline means we walk away from the problem monotonically — each migration reduces the count, no migration is forced, but no new instance lands.
- Some "integrations" are subjective. Two services calling the same internal Postgres are not duplication; two services scraping the same external API are. The CLI baseline carries operator-annotated
canonical-owner: <service>entries to disambiguate.
What we sign up to maintain.
- The
gyrum-fleet-integrationsCLI baseline (lands as.gyrum/fleet-integrations-baseline.jsonper repo when warp #2131 ships), with ratchet semantics (count goes down only).
- The integrations registry (planned at
dark-factory/docs/integrations.md) — listing each external dependency, its owner, the rationale, and the in-fleet API consumers should call. - The migration tickets when an existing duplication has not yet been collapsed. Today's open: warp #1567 (runners), warp #2133 (PR-merge → ticket transition).
- Persona-reviewer attention to "is this a new integration?" during PR review — quoting this ADR when the answer is yes.
Alternatives considered
- Mandate microservices for every domain. Rejected — wrong axis. Microservice count is not the goal; ownership is. A 100-service fleet with two services maintaining their own GitHub clients is no better than today. A monolith with one canonical GitHub-Actions module is fine. The principle is about who owns the integration, not how many processes the fleet has.
- Document each integration's ownership case-by-case (status quo). Rejected — that is what ADR-111 / ADR-116 / warp's queue ownership already do, and the runners and PR-merge divergences shipped anyway because there is no general principle to cite when authoring code that does not match an existing ADR. The general rule has to exist independently of any specific instance.
- Detect duplication only via the structural CLI; skip the principle. Rejected — a structural check without a principle is unmotivated; reviewers cannot judge edge cases (when is shared usage legitimate? when is the second consumer the canonical owner?) without a rule to reason from. ADR-083's pattern is principle plus structural check at all four layers; this one follows the same shape.
- Allow every service its own integration; reconcile at view time. Rejected — that is today, and today produces the runners-vs-runners and PR-merge-vs-tally drifts. Reconciliation-at-view shifts the cost from authoring to operations, where it is paid every operator-hour rather than once.
Supersedes: none Superseded by: leave blank until a later ADR reverses this one
References
- ADR-083 — defense-in-depth rule promotion (the four-layer ladder this principle rides)
- ADR-111 — broker (instance of this principle for model providers)
- ADR-113 — principles tier (where ADR-177 lives in the knowledge layer)
- ADR-115 — principle-aware reviewers (primary citer at PR-review time)
- ADR-116 — RAG (instance of this principle for the embedding index)
- ADR-117 — module guidelines (the per-module-type rules; ADR-177 is the cross-module rule)
- warp #1567 — canonical fleet-repos consolidation (runners-vs-runners migration)
- warp #2130 — this ADR's parent ticket
- warp #2131 —
gyrum-fleet-integrationsCLI (structural enforcement) - warp #2132 — PR-gate wire-up for the CLI
- warp #2133 — warp owns "PR merged → ticket done" via webhook (third concrete demonstration)
- warp #2127 — canon-coverage CLI (sister-shape ratchet substrate)
- warp #782 — shellcheck-budget (sister-shape ratchet substrate)