tatara-operator Design¶
Date: 2026-06-06 Status: approved design, pre-plan. Repo: github.com/szymonrychu/tatara-operator (new, phase 9-ish).
Purpose¶
A Kubernetes operator that orchestrates the tatara platform's unattended agentic-development loop. It manages four CRDs (Project, Repository, Task, Subtask), ingests repositories into tatara-memory, receives GitHub/GitLab webhooks to keep memory fresh and to start work from issues, and spawns tatara-claude-code-wrapper pods (with tatara-cli as their MCP server) to do the work, landing results back in the SCM.
It subsumes three previously-scoped phases that were never built as separate repos: tatara-tasks (phase 6, REST task store - the CRDs are the store now), tatara-gitlab-bridge (phase 7, webhook bridge - built in), and the orchestration role of tatara-argo-workflows (retired for tatara - replaced by operator-native Pod/Job spawning). The old monolith's Smith/Forge/Bellows/Ingot/TaskBoard CRDs are the lineage; this is the clean rewrite with simpler names and no Argo.
Goals¶
- Adding a
Repositoryto aProjectingests it intotatara-memory. - Each
Projectexposes a webhook URL that GitHub/GitLab can call on push (filtered to the default/main branch) to refresh memory incrementally. - The same webhook accepts work-item events (issue/MR) and, when they carry the Project's trigger label, creates a
Taskand runs an agent on it unattended. - A
Taskruns as one long-lived agent session;Subtasks are the unit of work, fed to the session one turn at a time; the agent self-plans subtasks via MCP. - Agent output lands as a branch push + PR/MR + a comment on the originating issue.
- Directly closes production-readiness gaps 1, 2, 4, 8 (see
docs/2026-06-06-code-graph-production-readiness.md).
Non-goals (v1)¶
- Embedding/LLM backend for semantic chunks (gap 3) - a
tatara-memoryconfig concern, not the operator's. - Retrieval-quality eval harness (gap 6) and scale validation (gap 9).
- Agent checkpoint/resume across pod restarts (owned by the wrapper ROADMAP). v1 retries by recreating the pod, bounded.
- gVisor/seccomp/Kata pod hardening (wrapper ROADMAP). v1 ships a basic egress-allowlist NetworkPolicy only.
- Per-user RBAC. OIDC audience gating only, matching the rest of tatara.
Architecture¶
One Go binary (newest stable Go, pinned in go.mod - rule 1), built with kubebuilder/controller-runtime, running three concerns in one process:
- Manager - controller-runtime reconcilers for the four CRDs.
- Webhook server - HTTP endpoint receiving GitHub/GitLab events, HMAC-verified per Project.
- REST API - OIDC-gated (audience
tatara-operator) CRUD over Project/Repository/Task/Subtask, backed by the CRDs. This is the only interface agents touch; agent pods get an OIDC token, never kube credentials. Mirrors howtatara-memoryandtatara-chatare reached.
CRDs are the single source of truth. The webhook server and REST API are two write paths into the same CRDs the reconcilers act on. Everything is namespaced to tatara.
Rejected alternatives: Argo-backed reconcilers (argo retired for tatara, heavyweight for "a pod and a job"); plain client-go without kubebuilder (boilerplate, no envtest, non-idiomatic).
Component diagram¶
GitHub/GitLab ──push/issue/MR──▶ [webhook server] ──┐
│ creates/updates CRDs
agent pod (tatara-cli) ──OIDC──▶ [REST API] ─────────┤
▼
┌──────────────────┐
│ CRDs (k8s API) │
│ Project/Repo/ │
│ Task/Subtask │
└────────┬─────────┘
│ watch
┌────────▼─────────┐
│ reconcilers │
└───┬─────────┬────┘
ingest Job (ingester) │ │ Pod+Service (wrapper)
▼ ▼
tatara-memory claude session
CRD model (tatara.dev/v1alpha1)¶
All namespaced. Field names are camelCase in spec/status per kubebuilder convention; this is not the values.yaml rule-6 surface.
Project¶
spec:
scmSecretRef: string # Secret with provider token + webhook HMAC secret
triggerLabel: string # default "tatara"; label/command that starts a Task
maxConcurrentTasks: int # default 3; cap on running Task pods in this Project
agent:
model: string # passed to wrapper MODEL
image: string # wrapper image ref
permissionMode: string # default "bypassPermissions"
maxTurnsPerTask: int # default 50; hard turn cap per Task
turnTimeoutSeconds: int # default 1800
status:
webhookURL: string # the address to configure in GitHub/GitLab
conditions: []Condition
scmSecretRef points at a Secret with keys: token (provider PAT, used for clone + PR/MR + comments) and webhookSecret (HMAC verification). One Secret per Project; the operator mounts/uses it for that Project's ingest and agent pods.
Repository¶
spec:
projectRef: string # owning Project (also set as ownerReference)
url: string # git remote (https)
defaultBranch: string # default "main"; the only branch push re-ingests
ingestEnabled: bool # default true
status:
phase: string # Pending|Ingesting|Ingested|Failed
lastIngestedCommit: string # SHA; feeds --since on next ingest
lastIngestTime: Time
jobName: string # current/last ingest Job
conditions: []Condition
Task¶
spec:
projectRef: string
repositoryRef: string # which Repository the agent works in
goal: string # the objective (issue body for webhook-born Tasks)
source: # optional; set for webhook-born Tasks
provider: string # github|gitlab
issueRef: string # owner/repo#123 or project!iid
url: string
maxTurns: int # optional override of Project.agent.maxTurnsPerTask
status:
phase: string # Pending|Planning|Running|Succeeded|Failed
podName: string
turnsCompleted: int
prURL: string # PR/MR opened on completion
resultSummary: string
conditions: []Condition
Execution model is fixed: one Task = one wrapper session pod, multi-turn, shared context. There is no per-Task execution-mode switch in v1.
Subtask¶
spec:
taskRef: string # owning Task (also ownerReference)
title: string
detail: string
order: int
status:
phase: string # Pending|Running|Done|Failed
turnId: string # wrapper turn that executed this subtask
result: string
Subtask has no independent reconciler. It is data created/updated by the agent (via the REST API / MCP) and consumed by the Task reconciler's turn loop. Owner-referenced to its Task for cascade delete.
Reconcilers¶
ProjectReconciler¶
- Validate
scmSecretRefexists and hastoken+webhookSecret. - Compute and publish
status.webhookURL(the operator's external webhook base + the Project name). - Surface
maxConcurrentTasksenforcement state in conditions (the Task reconciler does the actual gating).
RepositoryReconciler¶
Drives an ingest Job using the tatara-memory-repo-ingester image, which already carries the Go toolchain needed for type-resolved edges (closes gap 2). The Job:
- Clones
spec.urlatspec.defaultBranchusing the Project SCM token. - Runs
tatara-ingest --repo-root <clone> --base-url <tatara-memory> [--since <status.lastIngestedCommit>]with an OIDC token minted from the operator's service-account client. - On success, the reconciler records
status.lastIngestedCommit(the cloned HEAD SHA),lastIngestTime,phase=Ingested.
Triggered on Repository create (lastIngestedCommit empty -> full ingest) and on push webhook (incremental via --since). Idempotent; one active Job per Repository at a time (status.jobName guards re-entry). Job is owner-referenced to the Repository.
TaskReconciler¶
The core loop. Spawns a singleton agent session and drives it turn by turn.
- Gate: if running Tasks in the Project >=
maxConcurrentTasks, requeue. - Spawn: create a wrapper Pod + Service (owner-ref Task). The pod gets:
REPO_URL/REPO_BRANCHfrom the Repository, the Project SCM token (for clone + push), an OIDC token, andtatara-climcp-config wiring memory + operator + chat MCP servers.DEFAULT_CALLBACK_URLpoints at the operator's internal turn-callback endpoint.phase=Planning. - Plan turn (turn 0):
POST /v1/messageswith the goal plus an instruction to decompose into Subtasks via the subtask MCP tool. The agent creates Subtask CRDs through the REST API. - Iterate: on each turn callback, mark the current Subtask
Donewith its result, pick the nextPendingSubtask (byorder), andPOST /v1/messageswith that Subtask's title+detail.phase=Running. The agent may append new Subtasks mid-run. - Terminate: when no
PendingSubtasks remain, ormaxTurns/timeout is hit. Then: the agent has pushed its branch; the operator opens a PR/MR against the Repository default branch and comments the result onsource.issueRef(if set). RecordprURL,resultSummary,phase=Succeeded(orFailed). - Cleanup:
DELETE /v1/session(wrapper pod exits); delete Pod+Service. Bounded retries (recreate pod) on mid-run pod loss; no resume in v1.
The turn loop is callback-driven: the wrapper POSTs each turn result to the operator's internal endpoint, which records it and triggers the next reconcile. A periodic requeue backstops missed callbacks (poll GET /v1/messages/{turnId}).
Webhook server¶
One ingress path: <external-base>/operator/webhooks/{project}. Provider auto-detected from headers (X-GitHub-Event / X-Gitlab-Event). Payload HMAC-verified against the Project's webhookSecret. Unknown project or bad signature -> 401/404, no CRD mutation.
- Push event, ref ==
refs/heads/<Repository.defaultBranch>only (the main-branch filter): find the Repository in the Project by remote URL, trigger an incremental re-ingest (clears nothing; relies on--since). Closes gap 8. - Work-item event (issue opened / MR opened / trigger-comment) carrying the Project's
triggerLabel: create a Task withgoal= issue/MR body andsourcepopulated. The TaskReconciler picks it up and runs it unattended.
Both GitHub and GitLab are first-class behind a small scm interface with two implementations (github, gitlab) covering: clone URL auth, open PR/MR, comment on issue/MR, and webhook payload parsing/verification.
REST API (agent-facing)¶
OIDC-gated, audience tatara-operator. CRUD scoped by Project:
GET /projects, /projects/{p}
GET /projects/{p}/repositories
GET /projects/{p}/tasks, GET /tasks/{t}
PATCH /tasks/{t} # status notes from the agent
GET /tasks/{t}/subtasks
POST /tasks/{t}/subtasks # agent self-plans
PATCH /subtasks/{s} # mark Done / add result
Backed directly by the k8s API (the operator's manager client). A new tatara-cli MCP tool group (project_*, repo_*, task_*, subtask_*) maps these 1:1, the same way tatara-cli already wraps memory and (planned) chat. This tatara-cli change ships alongside the operator.
Security, observability, deploy¶
- NetworkPolicy on agent + ingest pods: egress allowlist to tatara-memory, the operator REST/webhook service, tatara-chat, the SCM host(s), and DNS. Ingress denied except operator -> wrapper. (gVisor/seccomp deferred to the wrapper ROADMAP.)
- Observability: JSON
slogeverywhere (rule 11); business actions at INFO with structured fields (rule 12); Prometheus/metrics(rule 13) - reconcile counts/errors per kind, ingest Job duration/result, turn duration, webhook events by provider/result, in-flight Tasks gauge. CRDstatus.conditionsfor human/k8s-native visibility. - OIDC: new confidential Keycloak client
tatara-operatorwith service accounts enabled (the operator mints tokens to call tatara-memory and the wrapper). Also atatara-operatoraudience so agenttatara-clitokens reach the REST API. Defined ininfra/terraform/keycloak/tatara_clients.tf. - Chart: created via
helm createthen edited (rule 5), cluster-agnostic (rule 14) - no baked regcred/affinity/ingress host/storage class. CRDs installed by the chart. values.yaml holds only camelCase scalars mapped through ConfigMap/Secret viaenvFrom(rule 6). Release added to the infra helmfiletatarabucket. - Tests: envtest for reconcilers (controller-runtime), httptest for the webhook server and REST API, a fake
scmimplementation for write-back paths. Table-driven (t.Run), errors wrapped with%w.
Production-readiness gaps addressed¶
From docs/2026-06-06-code-graph-production-readiness.md:
- Gap 1 (nothing populates the graph): Repository -> ingest Job. Closed.
- Gap 2 (ingest needs clone + Go toolchain): ingest Job uses the ingester image (Go toolchain) and clones with SCM creds. Closed.
- Gap 4 (agents can't query): agent pods run
tatara-climcp-config'd against memory/operator/chat. Closed. - Gap 8 (no freshness automation): push webhook -> main-filtered incremental re-ingest, last-ingested-commit tracked in Repository status. Closed.
- Gap 5 (never run e2e): the operator is the mechanism that makes the first real unattended run possible. Enabled.
- Out of scope, noted: gap 3 (embedding backend), gap 6 (eval harness), gap 9 (scale validation).
Build decomposition (milestones for the plan)¶
Each milestone is independently testable; built in order.
- M0 - scaffold: kubebuilder project, four CRD types + deepcopy,
go.mod(newest Go),internal/obs(slog + metrics),internal/auth(OIDC), chart skeleton. - M1 - Project + Repository + ingest: ProjectReconciler, RepositoryReconciler, ingest Job spawning, last-ingested-commit tracking. (gaps 1, 2)
- M2 - webhook server (push): HMAC verify, provider detection, push -> main-filtered incremental re-ingest. (gap 8)
- M3 - REST API + tatara-cli MCP tools: OIDC-gated CRUD + the
tatara-clitool group. - M4 - Task reconciler + turn loop: wrapper Pod+Service spawning, turn callbacks, subtask iteration, concurrency gating.
- M5 - SCM write-back + work-item->Task:
scminterface (github + gitlab), branch/PR/MR/comment, work-item webhook -> Task. (gap 4 e2e) - M6 - chart + deploy wiring: NetworkPolicy, metrics, Keycloak client, infra helmfile
tatararelease.
Open decisions resolved (picks)¶
- Agent session runs as a bare Pod + Service, not a Deployment/Job: it is an addressable singleton that exits on completion; node loss is handled by bounded recreate, not reschedule.
- The REST task store lives in this operator (CRD-backed), subsuming the separate
tatara-tasksservice. - Subtask has no controller; it is data driven by the Task loop + REST.