Skip to content

ADR-005 Provider layer

Generated — do not edit

This page is produced by scripts/generate-runtime-adrs.sh from engineering-runtime/docs/04-design-decisions/adr-005-provider-layer.md. Source commit: 38eec00 (2026-08-04 UTC). Hand edits are overwritten on the next sync and fail scripts/check-generated-adrs.sh. Change the ADR in the runtime repo, then re-run the generator. Relative links from the source ADR are rewritten to GitHub blob URLs.

Status

Adopted, and load-bearing. Partially amends adr-002-engine-boundaries.md (the Provider Catalog debate) and fulfils the forward-looking half of adr-004-execution-engine-selection.md (the Execution Planner that ADR-004 described but which did not exist).

Read this alongside both. ADR-002's core conclusion — the runtime core carries no domain knowledge — is preserved, not reversed. What changed is where domain knowledge lives when it does exist: it now has a named, bounded home instead of being pushed entirely onto AI and the industry tool.

Context

Two problems surfaced in day-to-day use of the reference implementation.

1. The Runtime Command registry made the runtime core the bottleneck for every operation.

Before this decision, the runtime held a compiled map of dotted command names to REST method/path pairs (internal/commands), and policy gated every one of them by name through allowed_commands. Three things followed from that, all bad:

  • A perfectly ordinary command failed for a reason that had nothing to do with the operation:
$ runtime github repositories list
execution failed: policy denied command "github.repositories.list":
command "github.repositories.list" is not in allowed_commands

The operation existed, the credentials were valid, the user was entitled — and it was denied because a name was missing from a list. Every new operation required a policy edit in every installation before it could run at all.

  • The vocabulary was the runtime's, not the platform's. Users typed repositories list because the registry said so, while the tool everyone already knows says repo list. The runtime was inventing a dialect on top of a vocabulary its users had already learned.

  • The runtime core knew platform specifics anyway. internal/commands contained GitHub REST paths. ADR-002's "the runtime contains no domain knowledge" was already only partly true in practice — the knowledge was there, it just had no principled home, so it accumulated in the core where every platform's entries sat side by side in one map.

2. One operation, several possible transports, and no place to decide between them.

ADR-004 identified this precisely: "creating a GitHub repository can go through the GitHub REST API or through gh repo create." It decided, correctly, that the runtime must make that choice rather than AI — and then noted honestly that no such mechanism existed. Engine selection was static: an operation was born REST or born CLI, fixed at registration, and changing it meant changing the command name callers used.

That is a real limitation, not a cosmetic one. Some operations genuinely are better served by a vendor CLI (gh pr list resolves the current repo from the working directory and carries a large evolving flag surface). Some are better over REST (simple, stable, no binary required on the machine). Some need GraphQL, because one round trip replaces four REST calls. Nothing in the architecture could express "this operation, that transport" and later change its mind.

Decision

Introduce a Provider layer between the runtime core and the engines.

Runtime (auth, config, policy, dispatch)
   |
   v
GitHub Provider   repo list / pr list / workflow run / issue create
   |
   | decides internally, per operation
   v
gh CLI  OR  REST API  OR  GraphQL

Three parts:

  1. The runtime core owns exactly four things — authentication, configuration, policy, and dispatch. It holds no per-operation knowledge of any platform. The central Runtime Command registry is removed, along with allowed_commands.

  2. A Provider owns one platform's operation surface. repo list, pr list, workflow run, issue create belong to the GitHub Provider, which publishes them as a discoverable table (runtime github --help).

  3. A Provider chooses a transport per operationrest, graphql, cli, or file. That choice is the provider's internal business. The runtime maps transport to engine and records it in the audit log; it never branches on it, and nothing above the provider may select it.

Why this is not a return to Model A

ADR-002 rejected "Heavy Provider Catalogs" — a hand-maintained, versioned catalog of every operation for every tool ("500 kubectl + 300 oc + 400 gcloud operations," called out as "a full-time job"). That rejection stands. This decision is different in four ways that matter:

Rejected Model A The Provider layer
Aimed at completeness — model every operation of every tool Aimed at curation — a small set of operations worth a first-class surface
No escape hatch: if it wasn't in the catalog, it couldn't run Escape hatches at every level: github api, github graphql, and runtime command run <binary> reach anything the curated set doesn't
AI restricted to the catalog vocabulary AI reasons over the published surface and still has raw CLI access; a cli-backed operation forwards the tool's own flags verbatim
Catalog owned centrally, growing without bound Owned per provider, deliberately small, with the runtime core still knowing nothing

The unbounded-maintenance objection is answered by never attempting completeness. The GitHub Provider ships ~19 operations, not 200, and explicitly defers everything else to api, graphql, and command run. A provider that grows a hundred operations would be a design smell, not a success.

ADR-002's four-layer "never knows" model survives intact, with one clarification to layer 4:

  1. ~~Runtime never knows Kubernetes/GitHub/GCP/Terraform domain concepts~~ → The runtime core never knows them. A Provider knows exactly one platform's concepts, and nothing else in the system does.

This is a genuine, deliberate, partial move toward Model A. It should be recorded as such rather than presented as pure continuity.

Why this fulfils ADR-004

ADR-004 decided that the runtime, never AI, chooses the execution mechanism — and described an "Execution Planner" performing "Engine Selection," while noting it was "a forward-looking elaboration, not yet built."

The Provider is that planner, with one refinement: rather than one central planner reasoning about every platform, each provider plans for its own. That is a better fit for the same reason a central registry was a bad fit — the knowledge required to choose is platform-specific, so it belongs with the platform.

ADR-004's boundary is preserved exactly:

  • The planner lives in the runtime binary, never in the AI/Capability layer. ✅
  • AI cannot select an engine, override Binary Governance, or skip the lifecycle. ✅
  • The same capability produces the same execution path every time — the choice is compiled Go, not a runtime guess. ✅

Two of ADR-004's stated trade-offs are now resolved:

  • ~~"Capability authors must pre-select the engine shape today."~~ They no longer do. A capability names an operation; the provider picks the transport.
  • ~~"AI still needs enough knowledge to pick a valid step shape."~~ AI names operations from a published, discoverable table, validated before execution.

One remains open: there is still no automatic fallback (try REST, fall back to CLI on failure). A provider's transport choice is fixed per operation at compile time. That is deliberate — a runtime-chosen fallback would make the same request take different paths on different days, which is exactly the non-determinism ADR-004 exists to prevent. Fallback, if ever built, must be explicit and declared, not reactive.

Governance consequence: allow-list becomes deny-list

This is the most security-relevant part of the decision and deserves to be stated plainly rather than buried.

Before: allowed_commands was an allow-list. An operation not named in policy could not execute. Deny-by-default.

After: operations are discovered from the provider that owns them. A provider with no policy entry may run every operation it exposes. Governance is expressed as denials:

providers:
  github:
    enabled: true
    denied:
      - api DELETE
  files:
    enabled: true
    denied:
      - delete

This is a deliberate reduction in default strictness, accepted because:

  • The previous default was not actually protective in practice — every installation immediately allow-listed the full seeded set, so the list functioned as ceremony, not governance. It denied things nobody wanted denied and was edited only when it got in the way.
  • The set of operations is bounded and compiled into the binary. Unlike an open-ended CLI surface, a provider cannot gain an operation at runtime. Deny-by-default protects against unknown operations appearing; here there are none.
  • Real governance shifted to where the risk actually is: denied prefixes match against "<operation> <args...>", so policy can deny a whole operation (files delete) or one narrow case (workflow run deploy-prod.yml, api DELETE) — which the old name-based allow-list could not express at all. The corollary is that a rule must name an operation that exists: denying repo delete, which no provider offers, enforces nothing, so runtime config validate reports rules that can never match.

Installations that want an allow-list still have one: providers.<name>.allowed, which restricts a provider to an explicit set of prefixes. enabled: false disables a provider entirely.

Two invariants are preserved and must stay preserved:

  • Policy is evaluated on the operation, never on the transport. A provider moving an operation from REST to CLI cannot launder it past a denial.
  • Binary Governance still applies on top. When a provider picks the cli transport, allowed_binaries and command_policy are evaluated in addition to the provider rule — a provider can only reach binaries the installation already permits.

Implementation

  • internal/provider — the contract: Provider, Operation, Invocation, Transport, plus the registry providers register into via init(). This is the only provider-related thing in internal/, and it is platform-agnostic.
  • providers/<name> — provider implementations, deliberately outside internal/ because they are the extension point. main.go imports them for side effects; nothing else references them.
  • internal/engine/graphql.go — a new generic GraphQL engine, added because the transport now exists as a first-class choice.
  • Policy: providers replaces allowed_commands; allowed_binaries/command_policy unchanged.
  • Audit: records carry a transport field, so a reader can see that github repo list went out over REST while github pr list shelled out to gh.

Adding a platform is a new package under providers/ plus one import line. internal/ never changes. If adding a platform appears to require an internal/ change, this decision is being violated.

Consequences

Positive

  • Operations run without a policy edit. The failure that motivated this ADR cannot recur.
  • The runtime speaks the platform's vocabulary, not an invented dialect — repo list, matching what users already know.
  • Transport is now a real, revisable decision. An operation can move REST → GraphQL when it turns out to need four round trips, with no change to the runtime core, to policy, or to any capability that calls it.
  • Capabilities got simpler and more durable. They name operations, not mechanisms; a capability written today survives a provider's transport change.
  • The runtime core genuinely has no platform knowledge now — more true after this change than before it, since internal/commands used to hold GitHub REST paths.
  • New platform support is additive and isolated, with a compiler-enforced boundary (internal/ cannot import providers/).

Trade-offs

  • Deny-by-default was traded for discoverability — see the governance section above. Installations wanting strictness must opt into providers.<name>.allowed.
  • Providers can rot. A curated operation set is a maintenance commitment: when a vendor changes an API, the provider must follow. The old model pushed that cost onto AI and the CLI; this one accepts a bounded amount of it deliberately. The mitigation is curation discipline — if a provider is growing toward completeness, that is the ADR-002 failure mode reappearing and should be resisted.
  • Two things are now called "provider." An Auth Engine provider is a credential source (github, gcp); a Runtime Provider is an operation surface (providers/github). They are related — a Runtime Provider declares which Auth provider it uses — but they are not the same concept, and the overloading is a real cost in the documentation.
  • The transport choice is invisible at the call site, which is the intent, but means a caller cannot tell that github pr list needs gh installed while github repo list does not. runtime github --help and runtime config validate exist to close that gap; the audit log records it after the fact.
  • A curated surface can hide capability. Someone who only reads runtime github --help may not realize the full REST and GraphQL APIs remain reachable via api/graphql. The escape hatches must stay prominently documented or the surface starts to feel like a cage — which is precisely what Model A felt like.
  • adr-002-engine-boundaries.md — the Provider Catalog debate this partially amends. Its core conclusion (thin runtime core, no domain knowledge in the core, Binary Governance rather than semantic governance) stands.
  • adr-004-execution-engine-selection.md — the decision this fulfils. The Provider is the Execution Planner that ADR predicted.
  • adr-001-capabilities.md — unchanged in substance; capabilities still hold engineering knowledge and still get no execution path of their own. Only the step grammar changed (provider: + args: instead of a registered command name).
  • adr-003-ai-interface.md — unchanged. AI still never executes; it now reasons over a published operation surface instead of a registry.