ADR 0038 — Agent hierarchy: areas, leads and delegation
Context
Since Phase 3b, a handoff can address any agent in the roster directly:
CreateHandoffUseCase receives toAgent and creates the record without
validating who can be a target — Handoff { fromAgent, toAgent, artifactId, status }, with toAgent free
(apps/api/src/domain/sessions/handoff.entity.ts). The only activation rule
today is canActivateAgent (domain/sessions/agent-activation.ts): an agent
only comes on stage with an accepted handoff addressed to it, except the
Creative agent, which starts on the user's command.
That worked through Phase 4 because each agent was a single unit: one
handoff, one agent, one verdict. Phase 8 introduces sub-specialization
within an area — the first instance is QA (8b), which comes to have QA
Automation and QA Performance/Security —, and in that design a handoff
addressing a sub-specialization directly would break the premise the whole
rest of the system assumes: one handoff, one response. If the Architect
could address qa-performance directly, who consolidates the verdict with
qa-automacao's? The answer can't be "no one" — the gate expects ONE
verdict per area (pr-gate-state-machine.ts:nextGateStatus, which receives
a single GateVerdict).
This ADR fixes the generic model — area, lead, delegation, consolidation, budget, failure — before any sub-specialization exists. QA (8b) and the Workflows subagent within Infra (8c) are instances of the same model, not designs of their own.
Why the ORIGIN of the failure needs a type now
CLAUDE.md has required, since ADR 0020, that every failure outcome record
its origin — infra | modelo | código | política — and forbids diagnosis by
elimination. In practice, the origin today is free text inside
reason/diagnosis: in qa_agent_server.ex:176-183,
falha_do_qa({:limit_reached, _}) returns the phrase "iteration limit
reached without emit_qa_verdict" — correct, but not a value another piece
of code can branch on.
Item 4 of this phase requires the lead to decide based on the origin
(redistribute | consolidate partial | block), and an automated decision
can't branch on prose. So this ADR types failure_origin and retrofits
the points that already informally classify failure — task_blocked,
dev.error, the QA and SecOps verdicts — rather than introducing the type
only for delegations and leaving the rest of the system with two
coexisting failure vocabularies.
Decision
1. An area belongs to a project, and has exactly one lead
agent_areas (one row per area, per project) and agent_area_members (who
belongs, with an is_lead boolean). Two invariants guaranteed in the
database, not just in the application — this repo treats a constraint as
a business rule (docs/.docmap.yml, schema-e-migrations rule):
unique(project_id, agent)onagent_area_members— an agent belongs to at most one area, and the lead is subject to the same rule: it can't also be a plain member of another area.unique(area_id) where is_lead— at most one lead per area. The existence of a lead (at least one) is a creation invariant, validated in the domain when the area is assembled, not a database constraint — an area being composed member by member would pass through a transient leadless state the database has no way to distinguish from an error.
Agents without an area — Creative, PO, Architect, Psychologist, Anamnesis —
don't enter agent_area_members and stay exactly as they are today:
addressable by direct handoff. This ADR doesn't change their flow.
Dev stays out of scope for this phase. dev-<modulo> is instantiated
dynamically per module (Phase 4 ADR) and doesn't become an area — there's no
"Dev Lead" yet. Recorded as future extension, not implemented (see
Consequences).
2. An external handoff can only address a lead or an area-less agent
CreateHandoffUseCase — the only place in the system that today writes
toAgent with no validation — now calls
assertHandoffTargetAllowed(toAgent, areaMembers) before creating the
record. A target that's an area subagent (a member, not the lead) is
rejected with a typed error (HandoffToSubagentError, carrying the agent
and the area), never silently filtered nor promoted to the lead by mistake.
OfferInfraHandoffUseCase (the readiness confirmation that signals the
engine to offer the handoff to the InfraAgent) does not write toAgent
— it only triggers the engine, which is what calls CreateHandoffUseCase
afterward. The validation lives in one place, at the point that actually
decides the target.
This is the agent-activation.ts precedent extended: that file decides "who
can ACTIVATE"; this one decides "who can be the TARGET of an external
handoff" — the second question didn't exist because, until now, every agent
was external by definition.
3. Delegation is the internal mechanism, and it's private to the area
delegations: a lead delegates a task to a subagent in the SAME area.
assertDelegationAllowed(lead, subagent, members) rejects whoever isn't the
area's lead and whoever delegates outside it. Delegation never appears
as a handoff — they're different tables and lifecycles, and the distinction
is what preserves the property "the lead is the only external contact":
nothing outside the area observes an individual delegation, only the
consolidated result.
4. Consolidation: a single artifact, external contract untouched
The lead closes the handoff it received with one
consolidated_verdict artifact — a new type in ArtifactSchemas (engine),
server-emitted like task_blocked and the *_verdict types already
are: none of the three is something the model chooses to emit via tool
call, they're the record of an outcome the server determines. Payload:
área (area), veredito (verdict), resumo (summary), and delegações
(delegations) — the list of internal verdicts referenced by id, not copied
(traceability without duplicating content).
ArtifactSchemas validates the SHAPE (every completed delegation has a
parecerArtifactId; every failed one has a failureOrigin) — it's the
same question check_extra/2 already answers for qa_verdict/
secops_verdict today. The api's domain validates the RULE — consolidation
is only possible once all delegations are resolved
(assertConsolidatable, rejecting with the list of what's missing) —
because only the api has the full list of the area's delegations; the
engine, at the moment it emits the artifact, only knows what it has
received so far.
The gates' external contract doesn't change. nextGateStatus still
receives one GateVerdict (approved | changes_requested) per gate — the
consolidated_verdict is what the QA Lead uses to decide that single
verdict; whoever calls the gate never sees a delegation. This is the
central guarantee of this ADR: the hierarchy is invisible from outside the
area.
5. Cascading budget, failure with mandatory origin
A cap on the area (agent_areas.budget_micros), a sub-cap per delegation
(delegations.budget_micros, optional — not every delegation needs one).
Exceeding the sub-cap becomes failed with failure_origin = politica —
the same classification session/project budgets already use in
budget-threshold.ts, extended to the delegation.
The Phase 1 budgets table isn't touched. It has budgets_scope_check
locked down to exactly two scopes (project XOR session); accommodating
a third scope there would alter a central table for an area's local need.
The area cap and the delegation sub-cap live in new tables.
Every subagent failure — a budget overrun, an equivalent task_blocked,
whatever it is — reaches the lead with failure_origin filled in. The lead
decides one of three outcomes, and the decision is an event
(area.decision), never a silent side effect:
- redistribute: a new delegation is born, covering what the failure left pending;
- consolidate partial: the
consolidated_verdictcloses with what there is, citing thefaileddelegation and its origin — the external consumer sees a complete verdict, even if internally a part failed; - block: the whole area gets blocked with the real origin propagated —
never a generic
changes_requestedlike Phase 4a already fixed for the case of QA without a verdict (see the comment inqa_agent_server.ex:148-153, which this ADR generalizes).
6. Failure origin: new type, retrofitted across the whole system that already classifies failure
failure_origin: infra | modelo | codigo | politica (Postgres enum,
without accents like the other schema.ts enums). Retrofit — an explicit
decision, not a model committed to delegation alone:
tasks.blocked_originandstories.blocked_origin(new columns, alongside the existingblocked/blocked_reason— new field, not a replacement);Engine.Dev.AgentIo.block_task/3gains a 4th argument; the ~18 call sites indev_agent_server.ex/noop_dev_agent_server.exare classified one by one (a failing worktree isinfra; malformed context iscodigo; iteration limit ismodelo; budget ispolitica);falha_do_qa/1(qa_agent_server.ex) and the SecOps equivalent now return an origin alongsidereason/diagnosis;task_blockedinArtifactSchemasgainsoriginamong its required keys.
No existing message is removed or rewritten — the origin is a NEW field alongside the free text, which keeps existing for humans to read.
Consequences
What becomes available
- Addressing a handoff to a subagent is a creation-time error, not a latent bug that would only show up when someone tried.
- The QA gate (8b) and the Workflows subagent within Infra (8c) have the mechanism ready — neither needs to reinvent delegation or consolidation.
- Every partial-failure decision becomes an auditable event
(
area.decision), closing the gap that CLAUDE.md already forbade in theory and had no way to enforce in practice — the origin was text, and text doesn't stop aniffrom guessing.
What's recorded as a future extension, not implemented
- Dev Lead.
dev-<modulo>continues to be instantiated dynamically per module, with no lead. If it ever needs an area, the model is already ready — an area doesn't presuppose a fixed number of members nor a fixed instruction per subagent. - Areas proposed by the Architect via
module_map. Today an area is created implicitly by each phase's scope (QA in 8b, Infra in 8c); the idea of the Architect dynamically proposing a new area, from the module map it already produces, is out of scope — it would require its own approval flow.
Risks accepted
- The origin retrofit touches Phase 4 code validated by real execution (ADR 0020). Each of the ~18 points is classified individually and no existing message changes — the risk is forgetting a point, not breaking one that already works; the tests cover the points, not a sample.
delegation_statusis born with a value that has no use yet (dispensed, reserved for 8b — "no performance NFR, delegation dismissed"). Changing a Postgres enum after it's written means a migration with a table lock; it's born now so as not to pay that cost twice.- No sweep for orphan delegations. If the engine dies between starting
a delegation and reporting the result, it stays
pendingforever in this phase — the lead doesn't consolidate (correct behavior: it doesn't close an incomplete verdict silently), but there's also no automatic alarm. Left for when there's a real case, not a hypothetical one.
Closure (Phase 8d)
The two instances anticipated by this ADR were built — QA (8b) and the Workflows subagent within Infra (8c) — plus the presentation side (8d: team panel grouped by area, PR timeline expanding internal verdicts, Insights grouped by area, feed narrating delegation). Closing out what was decided versus what stayed pending:
consolidated_verdict wasn't implemented — the decision validated in practice
Decision #4 above designed a generic consolidated_verdict artifact.
Neither QA nor Infra uses it: both already had a contract of their OWN,
predating the ADR — qa_verdict (QA) and open_infra_pr (Infra) — and
changing that contract would have broken RecordGateVerdictUseCase/
ExecuteInfraPrUseCase and the demos that already proved the happy path
without the hierarchy. Engine.Gates.QaLead.consolidar/1 produces exactly
the shape of qa_verdict; Engine.Infra.InfraLead.consolidar/2 produces
the union of files open_infra_pr already expected. Whoever consumes it
never knew there was more than one agent behind it — the ADR's central
guarantee (line 118 above) held up without needing the generic artifact.
consolidated_verdict remains available in ArtifactSchemas as a DESIGN,
not as code — a future area with no artifact of its own to reuse (unlike
QA/Infra, which already had one before becoming an area) is the natural
candidate to implement it for real. Until then, the established pattern is:
prefer reusing the artifact the area already emitted before becoming an
area; only implement the generic one if there isn't one.
Dev Lead and dynamic areas — still not implemented, and the seed recorded
The two items the "future extension" section already recorded remain outside scope, confirmed after three real instances of the model (QA, Infra, and the UI that exposes them). Recording the seed of the second, as requested:
The module_map already dictates HOW MANY dev agents exist — one per
module (devAgentId/Engine.Dev.* derive from that today, without a
table of their own: a dev's existence is a FUNCTION of the module_map, not
a parallel record). The natural next step — not implemented here — is for
the Architect to propose not just the modules but their GROUPING into an
area: for example, payments-api and payments-worker under a "payments"
area, with one of the two (or a dedicated lead) as the external contact.
This would become the same "the area exists because the module_map says
it does" dynamic that today only applies to individual devs — an area
would stop being a fixed catalog (QA, Infra) and become a PROPOSAL from
the Architect, with user approval, the way the module_map already is.
Prerequisites for this to become code, not just an idea: real
agent_areas/agent_area_members (the apparatus 8b/8c deliberately cut
from scope — today area/subagent/leadAgent in delegations are
TEXT, with no association table, because only two fixed areas were known
in advance); an approval flow for creating an area (same pattern as
handoff/module_map, user decides); and an answer to "who becomes lead"
when an area is born from a proposal instead of a fixed catalog — Dev
Lead and dynamic area are the SAME open problem, not two.