The operator ruled the implementation repo's main (50c8719) the latest and accepted state. Sync the spec to it. Accepted changes are reflected: one bare-metal environment, TrueNAS bulk storage, R2 off-cluster backup, CloudNativePG, single-node VPN gateway with Cilium fail-closed, inbound WireGuard, per-app auth sidecars, Renovate, the CI gate as approver, and the pm-agent. New ADR-0017..0025; ADR-0016 corrected and accepted; R-* IDs unchanged, eight amended. Where the implementation has simply not met a requirement, the requirement stands and the shortfall is recorded in docs/16-known-gaps.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
3.6 KiB
ADR-0025 — In-cluster AI Program Manager (pm-agent)
- Status: Accepted
- Date: 2026-11-08
- Serves: R-GITOPS-01, R-TEST-03, R-TEST-06, R-SEC-02
- Supersedes: —
- Accepted by: operator ruling of 2026-11-08 that the implementation is the accepted state; effective on merge.
Context
The operator wants a Gitea issue to be able to become a pull request without a workstation session. That
puts an AI agent inside the cluster, holding a repository write token and acting on text written by
whoever can reach the repository. It is a new trust boundary: input is untrusted, the actor can author
code, and it needs egress to an external model API. "May create Jobs" is also dangerous in its own
right, since Kubernetes places no check on a Job's serviceAccountName.
Decision
Run pm-agent: a dispatcher in its own namespace that polls labelled issues on the implementation repo and acts as the Program Manager on each thread; replying to an explicit mention on a PR is an optional capability, off by default. It starts at most a small, capped number of worker Jobs, each of which authors a change, runs the merge gate and opens a PR. The issue thread and the PR hold all of its state and correspondence.
- Contributor, never approver. It cannot push to
mainand never merges. Its bot account is a non-administrator, so it cannot waive review (ADR-0024). - Three independent layers between a hostile comment and the cluster. The deciding model holds no tools; the dispatcher accepts only a validated vocabulary and refuses rather than repairs; and a fail-closed admission policy admits only the committed Job shape.
- Workers hold no cluster credential; egress is limited to Gitea and the model API over TLS.
- Delivery tooling, not a catalog service. It declares objects a ServiceDefinition cannot express
and is hand-authored under
platform/with its own non-gating Flux tier, so it can never stall the app tier. It adds noR-*requirement. - Bound by the governance every agent is bound by. It is an implementation agent under AGENTS.md and R-TEST-06. It does not hold the System Architect role, and it declines and escalates work that needs a spec change.
Alternatives considered
- Receive Gitea webhooks — needs a security setting on the git host widened so it can call into the cluster, and a missed delivery is lost silently. Rejected; polling reads current state.
- One agent that both triages and implements — puts tools in reach of raw issue text. Rejected.
- A database for state — a second source of truth beside the thread. Rejected.
- Federated Keycloak machine identity — Gitea's API cannot validate a third-party token. Rejected; a local account with a scoped token is what Gitea can verify.
- RBAC alone as the boundary — cannot constrain what a created Job runs as. Rejected; admission is the boundary and RBAC its necessary half.
Consequences
- Issues become an intake for implementation work, and every result lands as an ordinary gated PR.
- Accepted residual risks, documented in the implementation repo's threat model: a worker can read its own credentials (bounded by one-repository, non-admin scope and by egress); TLS egress is wider than one API host; and no Gitea token scope separates pushing from merging, so "never merges" rests on the code making no merge call.
- An external model provider sees repository and issue content.
- Its capability can be stopped by configuration without removing the service.
- The worker cannot measure the live platform; anything needing a cluster stays unverified in its report.