Einheiten, Stufe 1: Organisationsbaum und Adressbuch als reine Rechnung (#683)

- UnitConfig (Id, Name, ParentId, LeaderAgentId, Goal, ToolCeiling, Defaults)
  in der Instanzkonfiguration neben den Agenten; AgentConfig.UnitId und
  UnitSinceUtc. Bewusst Konfiguration statt Datenbank: Die Werkzeugrechte der
  Agenten stehen schon dort, die Wahrheit ueber einen Agenten bleibt an einem
  Ort. Strukturaenderungen gehen ins Audit (Stufe 3).
- OrgChart: Pfade, Tiefe, Leitung, effektive Leitung (die naechste Leitung
  darueber, wenn die eigene Einheit keine hat oder man sie selbst leitet),
  Adressbuch, Delegationstiefe aus dem Baum (Tiefe + 1, der eingestellte Wert
  bleibt Untergrenze), Rechte-Obergrenze als Schnittmenge entlang des Pfades,
  und eine Liste der Probleme (unbekannte Verweise, Zyklen, Leitung ist kein
  Mitglied, Werkzeuge ueber der Obergrenze).
- Adressbuch: eigene Einheit und effektive Leitung; als Leitung die
  Leitungen der Untereinheiten (bei leitungslosen Untereinheiten deren
  Mitglieder), der Geschwistereinheiten und die eigene Leitung; die oberste
  Ebene und die Leitungen der obersten Einheiten sehen sich gegenseitig. Jede
  Linie ist in beide Richtungen begehbar. Ohne Einheiten sieht jeder jeden,
  wie bisher.

Tests: OrgChartTests (20) am Onlinehandel-Beispiel aus dem Konzept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-24 12:39:11 +02:00
co-authored by Claude Opus 5.5
parent dc11961805
commit d014a20c38
5 changed files with 638 additions and 0 deletions
@@ -10,6 +10,17 @@ public sealed class AgentConfig
[JsonPropertyName("displayName")]
public string DisplayName { get; set; } = "";
/// <summary>#683: The unit this agent belongs to; <c>null</c> for the top level.</summary>
[JsonPropertyName("unitId")]
public string? UnitId { get; set; }
/// <summary>
/// #683: Since when the agent is in its unit (UTC) — so it can be told that it moved,
/// and its lead that someone joined.
/// </summary>
[JsonPropertyName("unitSinceUtc")]
public DateTime? UnitSinceUtc { get; set; }
[JsonPropertyName("model")]
public string Model { get; set; } = "anthropic/claude-sonnet-4-5";
@@ -172,6 +172,13 @@ public sealed class InstanceConfig
[JsonPropertyName("agents")]
public List<AgentConfig> Agents { get; set; } = new();
/// <summary>
/// #683: Departments, teams, groups — the organisation tree. Empty means no structure:
/// every agent can reach every other, as before units existed.
/// </summary>
[JsonPropertyName("units")]
public List<UnitConfig> Units { get; set; } = new();
[JsonPropertyName("services")]
public List<ServiceConfig> Services { get; set; } = new();
}
+72
View File
@@ -0,0 +1,72 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Config;
/// <summary>
/// #683: A unit — department, team, group — as a node in the organisation tree.
///
/// <para><b>Stored in the instance configuration, next to the agents.</b> An agent's tools
/// already live there; a unit's rules belong beside them, so the truth about an agent is in
/// one place. Every structural change is written to the audit log by the administration,
/// which is what the concept asked the database for.</para>
///
/// <para><b>No value here overrides an agent at run time</b> (Organisation-Konzept, section
/// 3). <see cref="Defaults"/> are only the starting values for agents that join;
/// <see cref="ToolCeiling"/> is checked when an agent's tools are written, never laid over
/// them while it runs. What stands at the agent is what applies.</para>
/// </summary>
public sealed class UnitConfig
{
[JsonPropertyName("id")]
public string Id { get; set; } = "";
[JsonPropertyName("name")]
public string Name { get; set; } = "";
/// <summary>The parent unit; <c>null</c> for a top-level unit.</summary>
[JsonPropertyName("parentId")]
public string? ParentId { get; set; }
/// <summary>
/// The agent leading this unit — one of its members. May be empty: then the nearest
/// unit above that has a lead takes over.
/// </summary>
[JsonPropertyName("leaderAgentId")]
public string? LeaderAgentId { get; set; }
/// <summary>What the unit exists for — the top of every task chain in it (#714).</summary>
[JsonPropertyName("goal")]
public string Goal { get; set; } = "";
/// <summary>
/// The tools members of this unit may have at most; <c>null</c> means no ceiling. A
/// ceiling applies to sub-units as well — the effective ceiling is the intersection
/// along the path to the root.
/// </summary>
[JsonPropertyName("toolCeiling")]
public List<string>? ToolCeiling { get; set; }
/// <summary>Starting values for agents that join this unit.</summary>
[JsonPropertyName("defaults")]
public UnitDefaults Defaults { get; set; } = new();
}
/// <summary>
/// #683: What an agent gets when it joins a unit. <c>null</c> leaves the agent's own value.
/// Changing these does not touch existing members — writing a value into them is a
/// separate, previewed step.
/// </summary>
public sealed class UnitDefaults
{
[JsonPropertyName("model")]
public string? Model { get; set; }
[JsonPropertyName("dailyCostUsd")]
public decimal? DailyCostUsd { get; set; }
[JsonPropertyName("dailyTokens")]
public long? DailyTokens { get; set; }
[JsonPropertyName("whenExhausted")]
public string? WhenExhausted { get; set; }
}
@@ -0,0 +1,322 @@
using ClawdDotNet.Core.Config;
namespace ClawdDotNet.Core.Organisation;
/// <summary>
/// #683: The organisation tree of an instance — units, members, leads — and everything that
/// follows from it: who an agent can reach, who its lead is, how deep delegation may go,
/// which tools a unit's members may have at most.
///
/// <para><b>One rule holds it together: an agent knows only its unit and its lead.</b>
/// The cut of the address book is the organisation (Organisation-Konzept, section 3). It
/// is computed from the tree, never configured, so it cannot be widened by accident —
/// whoever wants an agent to reach further moves it in the tree, visibly.</para>
///
/// <para><b>Pure.</b> Built from the configuration, no I/O, no state. Every question is
/// answered from the snapshot it was built from, so the engine, the administration and
/// the tests all see the same organisation.</para>
///
/// <para><b>Without units nothing changes:</b> every agent reaches every other, as before.</para>
/// </summary>
public sealed class OrgChart
{
private readonly Dictionary<string, UnitConfig> _units;
private readonly Dictionary<string, AgentConfig> _agents;
private readonly Dictionary<string, List<UnitConfig>> _children;
private readonly Dictionary<string, List<AgentConfig>> _members;
/// <summary>The key under which top-level units and agents without a unit are grouped.</summary>
private const string Root = "";
public OrgChart(IEnumerable<UnitConfig> units, IEnumerable<AgentConfig> agents)
{
_units = new Dictionary<string, UnitConfig>(StringComparer.OrdinalIgnoreCase);
foreach (var unit in units)
_units.TryAdd(unit.Id, unit);
_agents = new Dictionary<string, AgentConfig>(StringComparer.OrdinalIgnoreCase);
foreach (var agent in agents)
_agents.TryAdd(agent.AgentId, agent);
_children = new Dictionary<string, List<UnitConfig>>(StringComparer.OrdinalIgnoreCase);
foreach (var unit in _units.Values)
{
var parent = KnownParent(unit) ?? Root;
if (!_children.TryGetValue(parent, out var list))
_children[parent] = list = [];
list.Add(unit);
}
_members = new Dictionary<string, List<AgentConfig>>(StringComparer.OrdinalIgnoreCase);
foreach (var agent in _agents.Values)
{
var key = KnownUnit(agent) ?? Root;
if (!_members.TryGetValue(key, out var list))
_members[key] = list = [];
list.Add(agent);
}
}
public static OrgChart From(InstanceConfig instance) => new(instance.Units, instance.Agents);
/// <summary>Whether the instance has any structure at all.</summary>
public bool HasUnits => _units.Count > 0;
public IReadOnlyCollection<UnitConfig> Units => _units.Values;
public UnitConfig? Unit(string? unitId)
=> unitId is not null && _units.TryGetValue(unitId, out var unit) ? unit : null;
public AgentConfig? Agent(string agentId) => _agents.GetValueOrDefault(agentId);
/// <summary>The unit an agent belongs to; <c>null</c> for the top level (or an unknown unit).</summary>
public UnitConfig? UnitOf(string agentId)
=> _agents.TryGetValue(agentId, out var agent) ? Unit(KnownUnit(agent)) : null;
/// <summary>Direct sub-units; <c>null</c> asks for the top-level units.</summary>
public IReadOnlyList<UnitConfig> Children(string? unitId)
=> _children.TryGetValue(unitId ?? Root, out var list) ? list : [];
/// <summary>Direct members; <c>null</c> asks for the agents on the top level.</summary>
public IReadOnlyList<AgentConfig> MembersOf(string? unitId)
=> _members.TryGetValue(unitId ?? Root, out var list) ? list : [];
/// <summary>The units above, nearest first — not including the unit itself.</summary>
public IReadOnlyList<UnitConfig> Ancestors(string unitId)
{
var result = new List<UnitConfig>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { unitId };
var current = Unit(unitId);
while (current is not null && KnownParent(current) is { } parentId && seen.Add(parentId))
{
current = _units[parentId];
result.Add(current);
}
return result;
}
/// <summary>"Marketing / Marketplaces / Amazon".</summary>
public string PathOf(string unitId)
{
if (Unit(unitId) is not { } unit)
return "";
var names = Ancestors(unitId).Select(u => u.Name).Reverse().Append(unit.Name);
return string.Join(" / ", names);
}
/// <summary>Depth of a unit: a top-level unit is 1.</summary>
public int Depth(string unitId) => Unit(unitId) is null ? 0 : Ancestors(unitId).Count + 1;
/// <summary>The deepest unit's depth; 0 without units.</summary>
public int TreeDepth => _units.Keys.Select(Depth).DefaultIfEmpty(0).Max();
/// <summary>
/// How many hand-overs a delegation chain may have: enough to go from the top to the
/// bottom of the tree and one step further, and never less than the configured floor.
/// Before #683 this was a fixed 3 — lead, department, team, agent already used it up.
/// </summary>
public int DelegationDepth(int configuredFloor) => Math.Max(configuredFloor, TreeDepth + 1);
/// <summary>The lead of a unit, if it has one that is actually a member.</summary>
public AgentConfig? LeaderOf(string? unitId)
{
if (Unit(unitId) is not { LeaderAgentId: { Length: > 0 } leaderId } unit)
return null;
return _agents.TryGetValue(leaderId, out var leader)
&& string.Equals(KnownUnit(leader), unit.Id, StringComparison.OrdinalIgnoreCase)
? leader
: null;
}
/// <summary>The unit this agent leads, if any.</summary>
public UnitConfig? LedBy(string agentId)
{
var unit = UnitOf(agentId);
return unit is not null && LeaderOf(unit.Id) is { } leader
&& string.Equals(leader.AgentId, agentId, StringComparison.OrdinalIgnoreCase)
? unit
: null;
}
/// <summary>
/// Who the agent answers to: the lead of its unit — or, if it leads that unit itself or
/// the unit has no lead, the nearest lead above. <c>null</c> means: the human.
/// </summary>
public AgentConfig? EffectiveLeaderOf(string agentId)
{
var unit = UnitOf(agentId);
if (unit is null)
return null;
foreach (var candidate in new[] { unit }.Concat(Ancestors(unit.Id)))
{
if (LeaderOf(candidate.Id) is { } leader
&& !string.Equals(leader.AgentId, agentId, StringComparison.OrdinalIgnoreCase))
return leader;
}
return null;
}
/// <summary>
/// Everyone the agent may address — the cut of the address book (section 3):
/// <list type="bullet">
/// <item>the members of its own unit and its effective lead;</item>
/// <item>as a lead: the leads of its sub-units — and, where a sub-unit has no lead, that
/// sub-unit's members, because this lead is who they answer to — and the leads of its
/// sibling units;</item>
/// <item>on the top level: each other and the leads of the top-level units, both ways.</item>
/// </list>
/// Without units: everyone.
/// </summary>
public IReadOnlySet<string> AddressBook(string agentId)
{
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (!_agents.ContainsKey(agentId))
return result;
if (!HasUnits)
{
result.UnionWith(_agents.Keys);
result.Remove(agentId);
return result;
}
var unit = UnitOf(agentId);
foreach (var member in MembersOf(unit?.Id))
result.Add(member.AgentId);
if (EffectiveLeaderOf(agentId) is { } lead)
result.Add(lead.AgentId);
if (unit is null)
{
// Top level: the house management reaches the heads of the top-level units.
AddLeadsBelow(null, result);
}
else if (LedBy(agentId) is { } led)
{
AddLeadsBelow(led.Id, result);
foreach (var sibling in Children(KnownParent(led)))
{
if (!string.Equals(sibling.Id, led.Id, StringComparison.OrdinalIgnoreCase)
&& LeaderOf(sibling.Id) is { } siblingLead)
result.Add(siblingLead.AgentId);
}
// A top-level lead reaches the top level — the same line, seen from below.
if (KnownParent(led) is null)
foreach (var member in MembersOf(null))
result.Add(member.AgentId);
}
result.Remove(agentId);
return result;
}
public bool CanAddress(string fromAgentId, string toAgentId)
=> AddressBook(fromAgentId).Contains(toAgentId);
/// <summary>
/// The tools a member of the unit may have at most: the intersection of the ceilings
/// along the path to the top. <c>null</c> means no ceiling anywhere on the path.
/// </summary>
public IReadOnlySet<string>? ToolCeiling(string? unitId)
{
if (Unit(unitId) is not { } unit)
return null;
HashSet<string>? ceiling = null;
foreach (var node in new[] { unit }.Concat(Ancestors(unit.Id)))
{
if (node.ToolCeiling is null)
continue;
if (ceiling is null)
ceiling = new HashSet<string>(node.ToolCeiling, StringComparer.OrdinalIgnoreCase);
else
ceiling.IntersectWith(node.ToolCeiling);
}
return ceiling;
}
/// <summary>
/// What is wrong with the structure — empty when it is sound. Checked when the
/// administration writes and when an instance starts; the tree is still usable with
/// problems (unknown references count as top level), but they are named.
/// </summary>
public IReadOnlyList<string> Problems()
{
var problems = new List<string>();
foreach (var unit in _units.Values)
{
if (string.IsNullOrWhiteSpace(unit.Id))
problems.Add("A unit has no id.");
if (unit.ParentId is { Length: > 0 } parentId && !_units.ContainsKey(parentId))
problems.Add($"Unit '{unit.Id}' names the parent '{parentId}', which does not exist.");
if (IsInCycle(unit.Id))
problems.Add($"Unit '{unit.Id}' is part of a cycle — a unit cannot contain itself.");
if (unit.LeaderAgentId is { Length: > 0 } leaderId && LeaderOf(unit.Id) is null)
problems.Add(_agents.ContainsKey(leaderId)
? $"Unit '{unit.Id}' names '{leaderId}' as lead, but that agent is not a member of it."
: $"Unit '{unit.Id}' names the lead '{leaderId}', which does not exist.");
}
foreach (var agent in _agents.Values)
{
if (agent.UnitId is { Length: > 0 } unitId && !_units.ContainsKey(unitId))
problems.Add($"Agent '{agent.AgentId}' belongs to the unit '{unitId}', which does not exist.");
if (ToolCeiling(KnownUnit(agent)) is { } ceiling)
{
var beyond = agent.Tools.Keys.Where(t => !ceiling.Contains(t)).ToList();
if (beyond.Count > 0)
problems.Add($"Agent '{agent.AgentId}' has tools beyond its unit's ceiling: {string.Join(", ", beyond)}.");
}
}
return problems;
}
// ─── helpers ───
private void AddLeadsBelow(string? unitId, HashSet<string> into)
{
foreach (var child in Children(unitId))
{
if (LeaderOf(child.Id) is { } lead)
{
into.Add(lead.AgentId);
continue;
}
// No lead there: its members answer to the lead above — so this one sees them,
// and looks further down for the next leads.
foreach (var member in MembersOf(child.Id))
into.Add(member.AgentId);
AddLeadsBelow(child.Id, into);
}
}
/// <summary>The parent id if it names an existing unit and does not close a cycle.</summary>
private string? KnownParent(UnitConfig unit)
=> unit.ParentId is { Length: > 0 } parentId && _units.ContainsKey(parentId) && !IsInCycle(unit.Id)
? _units[parentId].Id
: null;
private string? KnownUnit(AgentConfig agent)
=> agent.UnitId is { Length: > 0 } unitId && _units.TryGetValue(unitId, out var unit) ? unit.Id : null;
private bool IsInCycle(string unitId)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var current = unitId;
while (current is not null && _units.TryGetValue(current, out var unit))
{
if (!seen.Add(current))
return true;
current = unit.ParentId is { Length: > 0 } p ? p : null;
}
return false;
}
}
@@ -0,0 +1,226 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Organisation;
/// <summary>
/// #683: The organisation tree and the address book it cuts. The example is the online
/// shop from the concept, trimmed:
///
/// <code>
/// (top level) ceo
/// ├── purchasing lead: purchasing-lead
/// │ ├── suppliers lead: suppliers-lead · negotiator
/// │ └── quality (no lead) · sampler
/// ├── marketing lead: marketing-lead
/// │ └── marketplaces lead: marketplaces-lead
/// │ └── amazon lead: amazon-lead · amazon-ads, amazon-listing
/// └── accounting lead: accounting-lead · bookkeeper
/// </code>
/// </summary>
public sealed class OrgChartTests
{
private static UnitConfig Unit(string id, string? parent = null, string? lead = null, List<string>? ceiling = null)
=> new() { Id = id, Name = char.ToUpperInvariant(id[0]) + id[1..], ParentId = parent, LeaderAgentId = lead, ToolCeiling = ceiling };
private static AgentConfig Agent(string id, string? unit = null, params string[] tools)
=> new() { AgentId = id, DisplayName = id, UnitId = unit, Tools = tools.ToDictionary(t => t, _ => new Dictionary<string, object?>()) };
private static OrgChart Shop() => new(
[
Unit("purchasing", lead: "purchasing-lead"),
Unit("suppliers", "purchasing", "suppliers-lead"),
Unit("quality", "purchasing"),
Unit("marketing", lead: "marketing-lead"),
Unit("marketplaces", "marketing", "marketplaces-lead"),
Unit("amazon", "marketplaces", "amazon-lead"),
Unit("accounting", lead: "accounting-lead")
],
[
Agent("ceo"),
Agent("purchasing-lead", "purchasing"),
Agent("suppliers-lead", "suppliers"),
Agent("negotiator", "suppliers"),
Agent("sampler", "quality"),
Agent("marketing-lead", "marketing"),
Agent("marketplaces-lead", "marketplaces"),
Agent("amazon-lead", "amazon"),
Agent("amazon-ads", "amazon"),
Agent("amazon-listing", "amazon"),
Agent("accounting-lead", "accounting"),
Agent("bookkeeper", "accounting")
]);
// ═══════════════════ the tree ═══════════════════
[Fact]
public void Paths_and_depths_follow_the_tree()
{
var org = Shop();
org.PathOf("amazon").ShouldBe("Marketing / Marketplaces / Amazon");
org.Depth("marketing").ShouldBe(1);
org.Depth("amazon").ShouldBe(3);
org.TreeDepth.ShouldBe(3);
}
[Fact]
public void Delegation_may_reach_from_the_top_to_the_bottom_and_one_step_further()
{
Shop().DelegationDepth(configuredFloor: 3).ShouldBe(4, "before #683 it was a fixed 3");
Shop().DelegationDepth(configuredFloor: 6).ShouldBe(6, "the configured value stays a floor");
new OrgChart([], [Agent("a")]).DelegationDepth(3).ShouldBe(3);
}
// ═══════════════════ leads ═══════════════════
[Fact]
public void A_member_answers_to_the_lead_of_its_unit()
=> Shop().EffectiveLeaderOf("amazon-ads")!.AgentId.ShouldBe("amazon-lead");
[Fact]
public void A_lead_answers_to_the_lead_above()
=> Shop().EffectiveLeaderOf("amazon-lead")!.AgentId.ShouldBe("marketplaces-lead");
[Fact]
public void In_a_unit_without_lead_the_nearest_lead_above_takes_over()
=> Shop().EffectiveLeaderOf("sampler")!.AgentId.ShouldBe("purchasing-lead");
[Fact]
public void Above_a_top_level_lead_there_is_only_the_human()
=> Shop().EffectiveLeaderOf("marketing-lead").ShouldBeNull();
[Fact]
public void A_lead_who_is_not_a_member_does_not_count()
{
var org = new OrgChart([Unit("team", lead: "outsider")], [Agent("outsider"), Agent("member", "team")]);
org.LeaderOf("team").ShouldBeNull();
org.Problems().ShouldContain(p => p.Contains("not a member"));
}
// ═══════════════════ the address book ═══════════════════
[Fact]
public void A_member_sees_its_unit_and_its_lead_and_nothing_else()
{
Shop().AddressBook("amazon-ads").ShouldBe(["amazon-lead", "amazon-listing"], ignoreOrder: true);
}
[Fact]
public void The_logistician_does_not_learn_that_an_ads_agent_exists()
{
// The example from the item: with a flat list, everyone saw everyone.
Shop().CanAddress("bookkeeper", "amazon-ads").ShouldBeFalse();
Shop().CanAddress("amazon-ads", "bookkeeper").ShouldBeFalse();
}
[Fact]
public void A_lead_sees_its_team_its_sub_leads_its_sibling_leads_and_its_own_lead()
{
Shop().AddressBook("marketplaces-lead").ShouldBe(
["amazon-lead", "marketing-lead"], ignoreOrder: true);
Shop().AddressBook("purchasing-lead").ShouldBe(
// sub-lead; the lead-less quality team directly; sibling leads; the top level.
["suppliers-lead", "sampler", "marketing-lead", "accounting-lead", "ceo"], ignoreOrder: true);
}
[Fact]
public void Members_of_a_lead_less_unit_and_the_lead_above_see_each_other()
{
var org = Shop();
org.CanAddress("sampler", "purchasing-lead").ShouldBeTrue();
org.CanAddress("purchasing-lead", "sampler").ShouldBeTrue();
}
[Fact]
public void The_top_level_and_the_top_level_leads_see_each_other()
{
var org = Shop();
org.AddressBook("ceo").ShouldBe(["purchasing-lead", "marketing-lead", "accounting-lead"], ignoreOrder: true);
org.CanAddress("marketing-lead", "ceo").ShouldBeTrue();
}
[Fact]
public void Every_line_can_be_walked_both_ways()
{
// Whoever I can reach as my lead can reach me: escalation and delegation use the
// same line.
var org = Shop();
foreach (var agent in org.Units.SelectMany(u => org.MembersOf(u.Id)).Concat(org.MembersOf(null)))
{
foreach (var other in org.AddressBook(agent.AgentId))
org.CanAddress(other, agent.AgentId).ShouldBeTrue($"{agent.AgentId} → {other} but not back");
}
}
[Fact]
public void Without_units_everyone_reaches_everyone_as_before()
{
var org = new OrgChart([], [Agent("a"), Agent("b"), Agent("c")]);
org.HasUnits.ShouldBeFalse();
org.AddressBook("a").ShouldBe(["b", "c"], ignoreOrder: true);
}
[Fact]
public void An_unknown_agent_reaches_nobody()
=> Shop().AddressBook("ghost").ShouldBeEmpty();
// ═══════════════════ tool ceiling ═══════════════════
[Fact]
public void A_ceiling_applies_along_the_whole_path_down()
{
var org = new OrgChart(
[
Unit("marketing", ceiling: ["WebFetch", "Taskboard", "AgentComm"]),
Unit("amazon", "marketing", ceiling: ["WebFetch", "Taskboard", "FileRW"])
],
[Agent("ads", "amazon")]);
org.ToolCeiling("amazon")!.ShouldBe(["WebFetch", "Taskboard"], ignoreOrder: true,
"FileRW is not allowed further up, AgentComm not here");
org.ToolCeiling("marketing")!.Count.ShouldBe(3);
org.ToolCeiling(null).ShouldBeNull();
}
[Fact]
public void Tools_beyond_the_ceiling_are_named_as_a_problem()
{
var org = new OrgChart(
[Unit("marketing", ceiling: ["WebFetch"])],
[Agent("ads", "marketing", "WebFetch", "Database")]);
org.Problems().ShouldContain(p => p.Contains("ads") && p.Contains("Database"));
}
// ═══════════════════ broken structures ═══════════════════
[Fact]
public void A_cycle_is_named_and_does_not_hang()
{
var org = new OrgChart([Unit("a", "b"), Unit("b", "a")], [Agent("x", "a")]);
org.Problems().ShouldContain(p => p.Contains("cycle"));
org.Ancestors("a").Count.ShouldBeLessThan(3);
org.AddressBook("x").ShouldBeEmpty();
}
[Fact]
public void Unknown_references_are_named()
{
var org = new OrgChart([Unit("team", "nowhere")], [Agent("x", "missing")]);
org.Problems().ShouldContain(p => p.Contains("nowhere"));
org.Problems().ShouldContain(p => p.Contains("missing"));
}
[Fact]
public void A_sound_structure_has_no_problems()
=> Shop().Problems().ShouldBeEmpty();
}