ADR 0014 — InfraAgent and the live team panel (Phase 4a closure)
- Status: accepted
- Date: 2026-07-24
- Phase: 4a (session 4 — closure)
Context
The last two items of Phase 4a: the InfraAgent (reads the module_map + the Architect's infra ADRs, proposes Dockerfiles/compose/CI via PR — never applies anything to an environment) and the full team panel (all agents instantiated, live status via Phoenix channels, no longer a static/uniform status). With this, Phase 4a closes; Phase 4b (Psychologist/Anamnese) is left for later.
Acceptance criterion: in the test project, InfraAgent delivers a PR with a valid Dockerfile that passes the gates (syntactic QA + SecOps scan), and the panel shows the whole team with correct states during a real execution.
Decisions
1. Architect→Infra handoff — same mechanism, new offer point
InfraAgent belongs to the SAME family as the session-scoped agents
(Creative/PO/Architect) — not the project-scoped pattern of
Dev/QA/SecOps — because it's activated by an explicit handoff, not
instantiated one-per-module. ArquitetoServer gains
handle_call(:offer_infra_handoff, ...) (mirroring the Creative agent's
confirm_readiness): runs a closing turn and calls
EngineApiClient.create_handoff(project_id, session_id, "arquiteto", "infra", nil). A dedicated human endpoint
POST projects/:id/sessions/:id/agents/arquiteto/handoff-infra →
OfferInfraHandoffUseCase — it does not reuse the Creative agent's
readiness endpoint (different agents and different moments in the flow).
AcceptHandoffUseCase gains an autonomy seed conditioned on
toAgent === "infra": agent_autonomy (infra, open_infra_pr) = auto_approve (PROPOSING the PR is safe to auto-approve — InfraAgent never
applies anything to an environment) and (infra, terminal) = deny.
ArquitetoSupervisor/agent-activation.ts are already generic by agent
name — no change needed beyond agent_command_controller.ex gaining the
"infra" clause.
2. The terminal restriction is structural + policy, not a new ActionType
decide() evaluates IAM → agent_autonomy → permissions.json, with each
stage only able to RAISE permissiveness — a deny in agent_autonomy
SHORT-CIRCUITS before permissions.json is even consulted. So "deny generic
terminal but allow hadolint" doesn't need (and shouldn't) be expressed
under the SAME ActionType terminal: InfraAgent's hadolint validation
(ValidateInfraFile, a :direct tool) runs as a direct call to
System.cmd via Engine.Actions.HadolintDetector, with no proposed_action
at all — the same pattern as the SecOps detectors. The denial of the
generic terminal is twofold: structural (InfraAgent's tool registry —
[ValidateInfraFile.spec(), ProposeInfraPr.spec()] — never includes
Terminal) and policy (agent_autonomy (infra, terminal) = deny,
defense in depth testable via decide() but not the actual mechanism that
prevents misuse).
3. open_infra_pr generalizes open_adr_pr to N files
New ActionType (decide.ts) calibrated like open_adr_pr
(maintainer/pending by default). domain/git/infra-pr-execution-result.ts
mirrors AdrPrExecutionResult, but with paths: string[] (a single commit
with multiple files). ExecuteInfraPrUseCase generalizes
ExecuteAdrPrUseCase: GitProvider.commitFiles already accepted
files: {path,content}[] — only the consumer was fixed at 1 file. Fixed
branch feature/infra-setup (one per session, not per slug like the ADR
one).
Correction cycle without a new PR: a session produces a SINGLE infra PR
cycle — the correction after changes_requested reuses the SAME
branch/PR instead of opening another one. ExecuteInfraPrUseCase detects
this via InfraArtifactRepository.findBySessionId (new): if an artifact
already exists for the session, the call is treated as a correction —
commitFiles only, no createBranch/openPullRequest, no new row in
infra_artifacts (it reuses the pullRequestUrl/pullRequestId from the
ORIGINAL execution, read back via listByProjectAndType).
InfraAgentServer gains handle_cast({:correct, findings}, state)
(mirroring DevAgentServer.correct/3): instructs the model to fix and call
propose_infra_pr again — the SAME tool, with no special branch for the
correction case (the differentiation lives entirely in the api).
4. Infra gates: a light parallel table, the same state machine
An infra PR has no task/story/worktree behind it (files are born as direct
content, just like an ADR — they never touch a worktree). Instead of
forcing the fit into tasks, a new table infra_artifacts (migration
0016) with the SAME gate columns that tasks has (gate_status,
gate_correction_count, blocked, blocked_reason) + pr_action_id
(the id of the open_infra_pr proposed_action — the only id the engine
knows in return). Unlike tasks (which exists BEFORE the PR and opens the
gate afterward via OpenGateUseCase), the infra artifact is only born once
the PR has already been opened — gate_status already arrives at
'awaiting_qa'.
RecordInfraGateVerdictUseCase reuses the SAME nextGateStatus (pure
domain, already agnostic of Task) against InfraArtifactRepository
instead of TaskRepository — keyed by prActionId, not taskId
(findByPrActionId). Comments on the PR go via listByProjectAndType (not
findInSessionForUpdate, which is for exclusive use inside a transaction
with a lock).
Engine.Infra.InfraGateRunner (new) is DETERMINISTIC end to end (no own
GenServer, no LLM) — run_qa/3 runs hadolint over each Dockerfile in the
PR payload (fetched via the new GetInfraPrFilesUseCase + endpoint
GET .../infra-artifacts/:prActionId/files, since there's no worktree to
point the detectors at); run_secops/3 runs gitleaks+semgrep (the same
detectors as dev SecOps) against a TEMPORARY directory written on the spot
with the payload's files (removed in the try's after). Triggered via
Engine.Gates.Dispatcher.run_infra_qa/3/run_infra_secops/3 (the same
testable indirection as Dev↔Gates, but using Task.start —
fire-and-forget — instead of its own GenServer, since the runner doesn't
need state between calls).
5. Live team panel — broadcast at existing emission points
No new outbox-relay (a bigger, unnecessary change for this acceptance
criterion): Engine.Sessions.LiveBroadcast.event_appended/4 (new, small)
is called alongside EngineApiClient.append_event in ALL the
project-scoped GenServers that only wrote events without broadcasting
(DevAgentServer, QaAgentServer, SecOpsAgentServer, InfraAgentServer,
InfraGateRunner) — broadcast on the session:<id> channel, event
event.appended. The conversational agents (Creative/PO/Architect/Infra)
gain agent.status (working/idle) at existing turn boundaries
(start/end of each handle_cast/handle_call that runs run_turn).
Web: session-channel.ts gains onEvent/onAgentStatus — on receipt,
queryClient.invalidateQueries(['session-events', ...]) (reuses the
parsing/cache the polling already had, just triggers the refetch earlier).
ProjectOverviewTab opens its OWN channel (connectSessionHeartbeat),
independent of what SessionPage already opens, since they are
different routes/tabs mounted at different moments.
lib/agent-status.ts (new) — deriveAgentRoster(events, moduleMap, executionActivated, handoffs) builds the REAL roster: creative/po/architect
always; dev-<module> per module_map module when execution was activated
(synthesizes an AgentDef reusing the generic "dev-backend" icon/color,
since there's no fixed entry for a dynamic id); qa/secops when any gate
(dev OR infra) has ever opened; infra when the handoff was accepted.
Status: conversational agents via the last agent.status; dev via a
heuristic over its own last events (dev.*/backlog.task_blocked);
qa/secops via the gateStatus of the most recent verdict (awaiting_qa/
awaiting_secops says which of the two currently has the ball — they are
project-wide singletons, processed serially). ProjectOverviewTab's
"Agent team" now uses this, with model/autonomy (AgentCard, which
already accepted the props) resolved via
getAgentModelBinding/listAgentAutonomy once per page load.
6. Activity feed and the Approvals tab
activity.ts gains branches infra.gate_changed, infra.pr_opened/
infra.pr_failed, infra.artifact_blocked and
architecture.readiness_confirmed (mirroring the existing
pr.gate_changed/adr.* branches, with text making clear it's an "infra
PR"). artifact.qa_verdict/secops_verdict (existing branch, reused by
both dev AND infra) checks prActionId in the payload to differentiate the
text without duplicating the branch.
New human endpoint GET projects/:id/infra-artifacts
(ListInfraArtifactsUseCase) + useInfraArtifacts hook. PrGateTimeline
(already-existing component, used by dev PRs) has its task: Task prop
narrowed to a structural interface GateSubject { title, blocked, blockedReason, gateStatus } — both Task and InfraArtifact satisfy it
structurally, without coupling the component to the backlog domain. New
"Infra PRs under review" section in the Approvals tab, same component.
Consequences
- Tests:
decide.spec.tsproves the short-circuit (a deny in agent_autonomy never reaches permissions.json, even with a broad allow); integrationProposeActionUseCase(InfraAgent proposingterminalbecomesdenied);RecordInfraGateVerdictUseCase(full mirror of the task-gate tests, including an already-blocked artifact rejecting a new verdict). Engine:ValidateInfraFile(missing hadolint doesn't break the turn),InfraAgentServer(happy path with N files, correction, deltas +agent.status, rehydration),InfraGateRunner(lint with a finding, approval triggers the next gate, missing scanner skips without breaking). docker build --checkis out of scope — it would require mounting the host socket into the engine's container (Alpine, no docker CLI/daemon today), a real risk not taken on without an explicit request. Only hadolint (Engine.Actions.HadolintDetector, optional binary, same pattern as the other detectors).- The "live" panel is via broadcast at existing emission points — no new generic api→engine→Phoenix outbox-relay.
- Phase 4b (Psychologist/Anamnese) remains out of scope.
Scope & assumptions
A session produces ONE infra PR cycle (corrections reuse the same branch/PR — never a second, competing PR). No deep diff↔scanner-finding correlation (same simplification as ADR 0013). Dev/qa/secops status in the panel is heuristic (best-effort over the event log, not a transactional source of truth) — good enough for the panel, not used by any gate decision.