- Status: Accepted —
UnitActorandAgentActorboth implement the same addressable / message-receiving contract; a unit is indistinguishable from an agent at the messaging boundary. - Date: 2026-04-21
- Related code:
src/Cvoya.Spring.Core/IAddressable.cs,src/Cvoya.Spring.Core/Messaging/IMessageReceiver.cs,src/Cvoya.Spring.Dapr/Actors/AgentActor.cs,src/Cvoya.Spring.Dapr/Actors/UnitActor.cs. - Related docs:
docs/architecture/units.md,docs/concepts/units.md.
V1 had a flat team structure: one team of expert agents and a leader, with bespoke routing through the leader. Hierarchical organisations (engineering → backend team → individual engineer; communities of practice; ad-hoc gatherings) were not expressible. v2 needed nesting from day one, but two shapes were on the table:
- Distinct
AgentandUnittypes. Each has its own interfaces; routing knows which it's talking to and applies different code paths. Senders that want to send to "the unit" call intoUnitFacade.SendToTeam(...); senders that want to send to "an agent" go throughAgentDispatcher. - Composite pattern. A unit IS an agent. Both types implement the same interfaces (
IAddressable,IMessageReceiver); an address transparently resolves to either anAgentActoror aUnitActor; senders never know which they're talking to.
Adopt the composite pattern. UnitActor and AgentActor implement the same messaging interfaces and live behind the same address space. Routing, boundary checks, permissions, activity emission, and the orchestration spectrum all run uniformly regardless of whether the target is a leaf agent or a nested unit.
- Recursive composition is free. A unit containing a unit containing a unit needs no special handling: the outer unit's
OrchestrationStrategypicks one of its members; that member, if it's a unit, runs its own strategy; and so on. There is no "depth N" code path anywhere in the dispatcher. - Boundary opacity becomes a property of the composite. A unit chooses how much of its internal structure to project (see ADR 0008). To external senders the unit looks exactly like an agent — same address shape, same message verbs.
- Routing is single-hop. A path address resolves to a flat actor id (ADR 0023); there is no multi-hop forwarding through each level of the hierarchy.
- Skill projection follows the same rule. Capabilities are enumerated through the expertise directory, not the agent roster (ADR 0014); a unit's projected capabilities are first-class skills indistinguishable from a leaf agent's.
- Distinct
AgentandUnittypes with explicit delegation. Every routing call site, every boundary enforcer, every activity emitter would have to branch on type. New consumer code (the MCP skill surface, the A2A gateway, the directory) would have to rediscover the same branch each time. - Unit as a coordinator service over a fixed roster. Loses recursion and forces the routing layer to special-case "unit-of-units". Also defeats the boundary-as-decorator design: with a separate
UnitService, every external read would need a parallel "is the caller inside?" check at each layer.
- One mental model for senders. "I send a message to an address" — whether the address resolves to a clone, a leaf agent, a unit, or a unit-of-units, the surface is identical.
- The dispatcher is small. No type-branching on
target.IsUnit. The differentiation lives in the actor'sOnMessageAsyncimplementation. - Boundary projection composes. The boundary decorator (ADR 0008) wraps an aggregator whose recursion is itself uniform across leaf agents and nested units.
- Skill catalog projects uniformly.
expertise/{slug}(ADR 0014) names point at unit-projected capabilities and leaf-agent capabilities through the same naming scheme; the caller never has to know which.