Data & Control Flow¶
This page traces the life of a single SCM event from the moment it arrives at the operator's webhook endpoint to a merged pull/merge request in the source repository. A second, independent path - the async memory ingest pipeline - runs in parallel and keeps the knowledge graph fresh.
The one-writer rule
The agent writes conversation (issue_write, mr_write(comment|reply)) and notes. The operator writes reviews, merges, labels, Issue.status.status, MergeRequest.status.status, and every state transition. No agent ever writes Task.status.state.
Overview¶
flowchart LR
subgraph Ingress
A1(SCM webhook) --> W(Webhook server\nHMAC verify)
A2(Grafana alert) --> W
end
W --> QE(QueuedEvent CR\npriority, seq)
QE --> |admit at maxConcurrentAgents| PS(pod-spawn\nnew Task or stage respawn)
PS --> P(claude-code-wrapper pod\nname: task-agentKind)
P --> |GET /tasks/t/context| B(the context bundle)
P --> |MCP| CLI(tatara-cli)
CLI --> |read| MEM(tatara-memory)
P --> |issue_write / mr_write| IMR(Issue / MergeRequest CR\npending intent)
IMR --> |reconciler drains| SCM1(GitHub / GitLab)
P --> |POST /tasks/t/outcome| OUT(submit_outcome)
OUT --> OP(Operator: validate + stage transition)
OP --> |review, merge, labels, close| SCM2(GitHub / GitLab)
subgraph Async
GH2(push webhook) --> REPO(Repository CR\nannotated)
REPO --> JOB(ingest Job)
JOB --> MEM
end 1. Ingress - from SCM event to QueuedEvent¶
The operator exposes two webhook routes on a shared HTTP listener:
| Route | Source | Auth |
|---|---|---|
POST /operator/webhooks/{project} | GitHub or GitLab | HMAC-SHA256 over request body |
POST /operator/webhooks/{project}/grafana | Grafana Alertmanager | Bearer token, constant-time compare |
SCM webhook processing¶
- Body read - capped at 5 MiB; oversized payloads are rejected
413. - Provider detection - header inspection selects the GitHub or GitLab parser. A mismatched provider (e.g. a GitHub delivery to a GitLab-configured Project) returns
400before any signature work. - HMAC verification - the
webhookSecretkey is read from thespec.scmSecretRefSecret. The signature is verified withcrypto/subtle.ConstantTimeCompare; failure returns401. - Event routing - the parsed
WebhookEvent.Kindfield selects the handler:
| Kind | Handler |
|---|---|
push | stamps tatara.dev/reingest-requested annotation on the matching Repository |
issue or mr (not a comment) | admission path |
issue_comment / note | mirrored into the Issue/MergeRequest CR, then queued as a mid-flight event if a Task is watching |
| anything else | 202 Accepted, ignored |
Reporter intake gate¶
Before a webhook event can mint a new Task the operator applies two security checks:
- Provider mismatch guard - rejects deliveries routed to the wrong SCM provider.
- Reporter allowlist - a new
implement-origin Task (spec.kind: implement, the roleclarifyused to play) is only minted when the event author is a maintainer (spec.scm.maintainerLogins) or an account inspec.scm.reporterLogins(Project-level, overridable per-Repository viaRepositorySpec.reporterLogins). An empty allowlist is open (default). This is the prompt-injection intake gate: with it set, unknown third parties cannot drive the lifecycle by opening or commenting on an issue.
Grafana alert processing¶
Grafana delivers a JSON alert payload via bearer token. The handler:
- Verifies the bearer token against
spec.grafana.secretRef. - Ignores non-
firingalerts (e.g.resolved). - Computes the dedup key as the first 16 hex chars of
sha256(groupKey), wheregroupKeyis Grafana's alert-group field (not the labels/commonLabels). - Creates a
QueuedEventwithclass=alert,priority=0(redundant with the reserved alert pool, kept for clarity), andpayload.agentKind=incident.
2. Admission - QueuedEvent to a pod-spawn¶
The QueuedEvent buffer¶
A QueuedEvent CR is the durable, ordered buffer between the webhook handler and the dispatcher. The admission unit is one agent pod-spawn, not one Task: a Task advancing from one pod-spawning stage to the next enqueues a fresh QueuedEvent, so every pod-spawn - whether it mints a new Task or respawns a pod on an existing one - passes the same chokepoint. spec.maxConcurrentAgents == 0 freezes the whole project mid-flight, including a Task already in progress.
# Example QueuedEvent fields
spec:
seq: 42 # monotonic, Project-scoped
class: normal # or "alert" for incidents
priority: 1 # 0=incident, 1=webhook-originated, 2=cron/sweep-originated
projectRef: my-project
repositoryRef: tatara-cli # empty except documentation
dedupKey: "iss:tatara-cli#17" # a FIELD, never a label - see below
payload:
agentKind: implement # the pod to spawn
newTask: # exactly one of newTask / taskRef is set
name: my-project-implement-2026-07-12-a1b2c
kind: implement # the ORIGIN kind (SweepIssueKind)
goal: "..."
projectRef: my-project
issueKeys: ["tatara-cli#17"]
status:
state: Queued # -> Admitted when a slot opens
Dedup: a field, never a label¶
QueuedEvent.spec.dedupKey holds the natural key (iss:<repo>#<number>, mr:<repo>!<number>, or an alert-group hash for incidents) as a field, not a Kubernetes label - label values cannot contain : or #, so a label-based "natural key" would have been silently hashed back into an opaque digest, exactly the failure this design exists to avoid. The authoritative lookup is the issueKey/mrKey field index on Issue/MergeRequest (see Custom Resource Reference): "is this issue already being worked?" is answered by "does the Issue CR for (repo, number) have a controller owner?", the same question the sweep's orphan predicate asks, answered by the same index. The QueuedEvent's own in-flight dedup ("is an identical event already Queued but not yet Admitted?") is a separate field-indexed lookup on queuedEventDedupKey.
| Origin kind | Dedup key |
|---|---|
implement (new issue - SweepIssueKind, the role clarify used to play) | iss:<repo>#<number>, and a deterministic Task name so a redelivered webhook collides on Create (AlreadyExists) |
implement (as an agent kind, on a different origin's Task) | No separate QueuedEvent - the handoff is a state-driven respawn (taskRef) on the same Task its own origin's dedup key already admitted |
review | None - multiple review Tasks per PR are intentional |
incident | sha256(Grafana groupKey)[:16] |
brainstorm, documentation, refine | Caller-supplied key (e.g. brainstorm-<project>, documentation-<project>) |
Queue classes, priority, and capacity gating¶
The dispatcher runs as part of the operator reconcile loop and admits events in ascending (priority, seq) order within each class - FIFO is preserved within a priority tier:
- normal class, ordered
(priority, seq): up toProject.spec.maxConcurrentAgentsconcurrent admitted pod-spawns (Queue.Capacity()overrides it whenspec.queue.capacityis set; the fallback when neither is set is3).maxConcurrentAgents: 0fully pauses admission - a direct check at the top of
admit(), never routed throughQueueCapacity(), which floors at3and would silently un-pause a0. - alert class: a separate reserved pool (
spec.queue.alertCapacity, default1). Alert slots are never consumed by normal-class events, so an incident pod-spawn is admitted even when the normal queue is saturated. - Priority 2 (cron/sweep-originated work, e.g. the nightly documentation batch) gets one reserved slot in the normal pool whenever a priority-2 event has been
Queuedfor more than an hour, so a busy project's webhook traffic (priority 1) can never starve it indefinitely.
Once a slot is available, the dispatcher either creates a new Task from payload.newTask (a mint, idempotent on the deterministic name) or resumes an existing one named by payload.taskRef (a stage-driven respawn). The QueuedEvent is GC-deleted once its pod is gone and the Task has left the stage that requested it (or is terminal) - it is never transitioned to a terminal state itself.
3. Execution - agent turn loop¶
Pod lifecycle¶
The dispatcher spawns a tatara-claude-code-wrapper Pod (plus a Service) on admission, named independently of the Task itself (see Pod naming). One pod is created per pod-spawn and is reused across every turn within that spawn; the operator submits each turn to the existing pod and only re-creates it when it has crashed or failed its readiness probe. agent.maxPodRecreations is deprecated with zero effect - respawns are uncapped, bounded only by the 24h residency cap and watched by the operator_pod_recreations_total alert (see Runbooks). A pod that never becomes Ready within the fixed 5-minute readiness window is a respawn, not an immediate failure.
The Pod receives Task identity and context via environment variables:
| env | value |
|---|---|
TATARA_TASK | Task CR name |
TATARA_PROJECT | Project CR name |
TATARA_KIND | the agent kind (Task.status.agentKind) |
TATARA_TOOL_PROFILE / TATARA_SKILL_PROFILE | the agent kind, one of seven |
TATARA_REPO | Repository CR name; empty except for documentation |
TASK_BRANCH | the branch the wrapper checks out and pushes, injected verbatim - never construct it from the Task name. tatara/<branchKind>-<number>-<slug> when the Task carries a source issue or PR, tatara/docs-<short-sha> for documentation, tatara/task-<task-name> otherwise |
TATARA_OPERATOR_URL / TATARA_MEMORY_URL | per-Project service URLs |
AGENT_POD_TTL_SECONDS | Project.spec.agentPodTTLSeconds |
TATARA_CONTRACT_VERSION | the wire-contract version the pod was built against |
There is no TASK_GOAL env var: the goal is delivered as part of the context bundle text, not an env var. OPERATOR_PUSH_URL is the Prometheus remote-write / metrics-push endpoint (.../internal/metrics/push) the wrapper pushes turn token/cost series to - it is not a git-push or task-context var. TATARA_CHAT_URL and every conversation-resume var (HANDOFF_KEY, CONVERSATION_SESSION_ID, CONVERSATION_OBJECT_KEY) are gone: there is no resume mode, and what carries forward between pods is Task.status.notes, not a replayed transcript.
Runs one persistent interactive claude process; tatara-cli (baked into the wrapper image) acts as the local stdio MCP server.
sequenceDiagram
participant OP as Operator reconciler
participant POD as claude-code-wrapper pod
participant CLI as tatara-cli (MCP stdio)
participant MEM as tatara-memory
participant SCM as GitHub / GitLab
OP->>POD: Create pod (Task env injected)
POD->>CLI: MCP stdio handshake
POD->>CLI: tools/list (filtered by TATARA_TOOL_PROFILE)
CLI->>MEM: Authenticate (confidential client_credentials)
POD->>OP: GET /tasks/t/context (the rendered bundle)
POD->>CLI: query / code_search / code_context (MCP calls)
CLI->>MEM: GET /query, /code-graph
MEM-->>CLI: knowledge graph context
CLI-->>POD: tool results
POD->>OP: issue_write / mr_write (pending intent, persisted first)
OP->>SCM: reconciler drains the pending intent
POD->>OP: POST /internal/turn-complete (callback)
OP->>OP: Update Task status (stats, notes)
POD->>OP: POST /tasks/t/outcome (submit_outcome, terminal for this pod-spawn)
OP->>OP: validate against agentKind + stage, apply the transition MCP tool surface¶
tatara-cli gates its tools/list by the TATARA_TOOL_PROFILE env var, keyed on the agent kind (seven values). The gate is the platform's sole authz boundary: all agent pods share one OIDC identity (see Identity & OIDC), so authorization cannot key on who the caller is - only on which tools the profile allows. resolveProfile fails closed uniformly: an empty TATARA_TOOL_PROFILE and an unrecognized non-empty value are treated identically, serving only the always-on set with no submit_outcome - a pod with a profile the server does not understand gets six tools and cannot terminate its Task. Twenty tools total, five groups (Platform, SCM, code graph, memory, the one-name seven-schema submit_outcome); see MCP tools by agent kind for the authoritative per-kind grant table.
Mid-flight events into a live pod¶
If a human comments while a turn is in flight, the webhook handler mirrors the comment onto the Issue/MergeRequest CR and, unless the author is spec.scm.botLogin, appends it to Task.status.pendingEvents (Go-side cap 20, drop-oldest). Without the bot-author filter, the operator's own park comment would land back in the queue and un-park the Task the operator just parked - a fully autonomous hallucinated-approval-to-prod path. Delivered at the next turn boundary: rendered as an <events> block before the bundle (the delta first, then the refreshed baseline) - see Mid-flight events. If no pod is running, one spawns and the events ride in turn-0. The field is cleared only after the wrapper's submit for that turn returns 202, as a set-difference against what was actually delivered - never a blind nil assignment, which would silently drop an event that arrived between render and clear.
4. Writeback - results back to the SCM¶
Writeback splits along the one-writer rule stated above, and both halves are driven by durable, persist-first intents rather than a single post-turn reconcile sweep.
The agent's half: conversation¶
issue_write and mr_write(comment|reply) persist a PendingComment (or, for a review verdict, PendingReview) on the Issue/MergeRequest CR before any forge call, and return immediately; the Issue/MergeRequest reconciler drains the intent to the forge and clears it on a verified append. This crash-safety ordering means a wrapper pod that dies mid-write never leaves an ambiguous state: the intent either was not yet posted (retry) or was posted and mirrored (idempotent skip). mr_write(action=open) is idempotent on the Task's TASK_BRANCH head branch - an existing open MR on that branch returns {"existing":true} without a forge call - and is refused with 409 once the Task already owns a merged MR for that repo.
The operator's half: reviews, merges, labels, status¶
Everything triggered by an accepted submit_outcome and not reachable from any MCP tool:
| Origin kind (accepted outcome) | Operator action |
|---|---|
implement (action=approved) | Runs the extended approval grammar - citation plus plan-hash pin; on success, advances state=under-implementation and the same pod goes on to write code |
implement / documentation (action=submitted) | Resolves mergeOrder (fix C2: auto-filled for the single-repo case, required and validated otherwise); state advances to awaiting-review |
review (verdict=approve) | Reads the live PR head, posts a COMMENT-event SCM review under the bot identity (never APPROVE - the platform's one bot identity means GitHub 422s a self-approve), then walks mergeOrder and merges - see Merge and deploy |
review (verdict=request_changes) | Posts the findings as a COMMENT-event review with inline comments; state returns to under-implementation (or, for a kind=review Task on a human PR, parks at awaiting-human, bounded by maxHumanReviewRounds) |
brainstorm / incident (action=propose/file_issue) | Creates the proposed Issue CR(s), scoped to the alert rule for incidents |
refine | Applies folds (adopts a member Task's Issues/MRs, deletes the member), closes, and links directly - no PR, no proposal |
deployed -> done (pod-less) | Closes every owned Issue still open, citing the release, and only then stamps deliveredAt |
No agent writes Issue.status.status, MergeRequest.status.status, a label, or Task.status.state directly - every one of those is an operator write, gated by an accepted submit_outcome or by the pod-less merged/deployed state logic.
The mirror is the ledger¶
There is no separate work-item ledger on the Task. The Issue and MergeRequest CRs are the ledger: each carries its own status.state/status.status, its comment history, and an owner-ref list naming every Task that has ever touched it (with exactly one controller=true owner at a time - see Ownership, GC, and admission).
SCM egress and rate limiting¶
A per-project token bucket, ~20 requests/second, is shared by every reader and writer the operator drives against the forge - the last line of defence against a sweep, a reconcile storm, and an agent poll loop coinciding. Rate-limit handling (honour Retry-After, then X-RateLimit-Reset, then exponential backoff, bounded retries) is wired into the read path as well as the write path; a caller that exhausts retries sees a typed rate-limited error and requeues rather than failing the Task outright. GitHub's secondary limit (80 content-creating requests/minute, 500/hour) returns 403, not 429, and carries no X-RateLimit-Remaining header - detection keys on the response body's secondary rate limit marker as well as the status.
The budget holds because of the mirror: the steady-state agent read cost of the platform is zero forge requests, except scm_read(kind=ci). scm_read(issues|mr|comments) is served entirely from the Issue/MergeRequest CR mirror - see scm_read(kind=ci) is the only live forge read.
5. Async ingest path¶
Repository content is kept fresh independently of the agent turn loop. Two triggers kick off an ingest:
- Push webhook: the webhook handler stamps the
tatara.dev/reingest-requestedannotation on the matching Repository CR; the Repository reconciler picks this up within seconds. - Scheduled re-ingest: the Repository reconciler's
scheduleNextReingestparses the per-Repositoryspec.reingestSchedulecron and, when a fire is due, stamps the sametatara.dev/reingest-requestedannotation. For an already-ingested repo that annotation yields an incremental ingest (--since <lastIngestedCommit>), not a full one - it guards against missed push webhooks, not a periodic full rebuild. (The separateissueScancron scans issue/PR lifecycle state and does not drive ingest.)
sequenceDiagram
participant GH as GitHub / GitLab
participant OP as Operator (Repository reconciler)
participant JOB as ingest Job (Kubernetes batch)
participant ING as tatara-memory-repo-ingester binary
participant MEM as tatara-memory service
participant LR as LightRAG
participant NEO as Neo4j
GH->>OP: Push webhook
OP->>OP: Stamp reingest-requested annotation on Repository CR
OP->>JOB: Create batchv1.Job (owner=Repository)
JOB->>JOB: init container: git clone (token from ScmSecretRef)
JOB->>ING: main container: tatara-ingest --repo-root ... [--since <sha>]
ING->>MEM: POST /v1/memories:bulk (chunked files)
MEM->>LR: insert documents
LR->>NEO: write entity + relationship nodes
LR->>MEM: persist in Postgres
ING->>OP: kubectl patch ConfigMap <repo>-ingest-result (HEAD SHA)
OP->>OP: Update Repository.status.lastIngestedCommit Incremental vs. full ingest¶
| Mode | Trigger | --since flag | BackoffLimit |
|---|---|---|---|
| Incremental | push webhook (known lastIngestedCommit) | <lastIngestedCommit SHA> | 0 (deterministic failure; escalates immediately to full) |
| Full | first ingest, or incremental failure | none | 2 (transient clone/network failures self-heal) |
The ingest Job clones into a namespaced path (/workspace/owner/.../repo) so concurrent ingest Jobs for different repos never collide. The HTTP timeout for memory API calls is set to 300 seconds (overriding the ingester's 60 s default) to accommodate semantic extraction round-trips to OpenAI during large bulk ingests.
Semantic ingest requires an OpenAI key
When openAISecretName is configured in the operator Helm values, the ingest Job runs a second-pass semantic extraction (entity relationship inference via gpt-4o-mini by default) in addition to AST-based code graph construction. If no OpenAI secret is provided, the ingester runs AST-only and does not fail.
Key data stores and their roles¶
| Store | Technology | Contents | Updated by |
|---|---|---|---|
| Task CR | Kubernetes etcd | Stage, agent kind, notes journal, merge order/cursor, stats | Operator reconciler, webhook handler |
| Issue / MergeRequest CR | Kubernetes etcd | Mirrored SCM title/body/comments, platform status, pending write intents | Operator reconciler (mirror sync + pending-intent drain) |
| QueuedEvent CR | Kubernetes etcd | Ordered, prioritized admission queue | Webhook handler (produce), dispatcher (consume) |
| Repository CR | Kubernetes etcd | Last-ingested commit SHA, reingest annotation | Operator reconciler, webhook handler |
| Project ConfigMap (seq) | Kubernetes etcd | Monotonic per-project sequence counter | queue.SeqSource (CAS) |
| ingest-result ConfigMap | Kubernetes etcd | HEAD SHA written by the ingest Job | ingest Job (kubectl patch) |
| tatara-memory (LightRAG + Neo4j + Postgres) | In-cluster | Code entity graph, text chunks, spilled notes | ingest Job via memory service, operator (note spill) |