diff --git a/src/ClawdDotNet.Core/Config/AgentConfig.cs b/src/ClawdDotNet.Core/Config/AgentConfig.cs
index 1bbbd92..39b2039 100644
--- a/src/ClawdDotNet.Core/Config/AgentConfig.cs
+++ b/src/ClawdDotNet.Core/Config/AgentConfig.cs
@@ -10,6 +10,17 @@ public sealed class AgentConfig
[JsonPropertyName("displayName")]
public string DisplayName { get; set; } = "";
+ /// #683: The unit this agent belongs to; null for the top level.
+ [JsonPropertyName("unitId")]
+ public string? UnitId { get; set; }
+
+ ///
+ /// #683: Since when the agent is in its unit (UTC) — so it can be told that it moved,
+ /// and its lead that someone joined.
+ ///
+ [JsonPropertyName("unitSinceUtc")]
+ public DateTime? UnitSinceUtc { get; set; }
+
[JsonPropertyName("model")]
public string Model { get; set; } = "anthropic/claude-sonnet-4-5";
diff --git a/src/ClawdDotNet.Core/Config/InstanceConfig.cs b/src/ClawdDotNet.Core/Config/InstanceConfig.cs
index 20dfa4a..054dbbd 100644
--- a/src/ClawdDotNet.Core/Config/InstanceConfig.cs
+++ b/src/ClawdDotNet.Core/Config/InstanceConfig.cs
@@ -172,6 +172,13 @@ public sealed class InstanceConfig
[JsonPropertyName("agents")]
public List Agents { get; set; } = new();
+ ///
+ /// #683: Departments, teams, groups — the organisation tree. Empty means no structure:
+ /// every agent can reach every other, as before units existed.
+ ///
+ [JsonPropertyName("units")]
+ public List Units { get; set; } = new();
+
[JsonPropertyName("services")]
public List Services { get; set; } = new();
}
diff --git a/src/ClawdDotNet.Core/Config/UnitConfig.cs b/src/ClawdDotNet.Core/Config/UnitConfig.cs
new file mode 100644
index 0000000..0d68c2b
--- /dev/null
+++ b/src/ClawdDotNet.Core/Config/UnitConfig.cs
@@ -0,0 +1,72 @@
+using System.Text.Json.Serialization;
+
+namespace ClawdDotNet.Core.Config;
+
+///
+/// #683: A unit — department, team, group — as a node in the organisation tree.
+///
+/// Stored in the instance configuration, next to the agents. 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.
+///
+/// No value here overrides an agent at run time (Organisation-Konzept, section
+/// 3). are only the starting values for agents that join;
+/// is checked when an agent's tools are written, never laid over
+/// them while it runs. What stands at the agent is what applies.
+///
+public sealed class UnitConfig
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = "";
+
+ /// The parent unit; null for a top-level unit.
+ [JsonPropertyName("parentId")]
+ public string? ParentId { get; set; }
+
+ ///
+ /// The agent leading this unit — one of its members. May be empty: then the nearest
+ /// unit above that has a lead takes over.
+ ///
+ [JsonPropertyName("leaderAgentId")]
+ public string? LeaderAgentId { get; set; }
+
+ /// What the unit exists for — the top of every task chain in it (#714).
+ [JsonPropertyName("goal")]
+ public string Goal { get; set; } = "";
+
+ ///
+ /// The tools members of this unit may have at most; null means no ceiling. A
+ /// ceiling applies to sub-units as well — the effective ceiling is the intersection
+ /// along the path to the root.
+ ///
+ [JsonPropertyName("toolCeiling")]
+ public List? ToolCeiling { get; set; }
+
+ /// Starting values for agents that join this unit.
+ [JsonPropertyName("defaults")]
+ public UnitDefaults Defaults { get; set; } = new();
+}
+
+///
+/// #683: What an agent gets when it joins a unit. null leaves the agent's own value.
+/// Changing these does not touch existing members — writing a value into them is a
+/// separate, previewed step.
+///
+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; }
+}
diff --git a/src/ClawdDotNet.Core/Organisation/OrgChart.cs b/src/ClawdDotNet.Core/Organisation/OrgChart.cs
new file mode 100644
index 0000000..b11693e
--- /dev/null
+++ b/src/ClawdDotNet.Core/Organisation/OrgChart.cs
@@ -0,0 +1,322 @@
+using ClawdDotNet.Core.Config;
+
+namespace ClawdDotNet.Core.Organisation;
+
+///
+/// #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.
+///
+/// One rule holds it together: an agent knows only its unit and its lead.
+/// 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.
+///
+/// Pure. 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.
+///
+/// Without units nothing changes: every agent reaches every other, as before.
+///
+public sealed class OrgChart
+{
+ private readonly Dictionary _units;
+ private readonly Dictionary _agents;
+ private readonly Dictionary> _children;
+ private readonly Dictionary> _members;
+
+ /// The key under which top-level units and agents without a unit are grouped.
+ private const string Root = "";
+
+ public OrgChart(IEnumerable units, IEnumerable agents)
+ {
+ _units = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var unit in units)
+ _units.TryAdd(unit.Id, unit);
+
+ _agents = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var agent in agents)
+ _agents.TryAdd(agent.AgentId, agent);
+
+ _children = new Dictionary>(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>(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);
+
+ /// Whether the instance has any structure at all.
+ public bool HasUnits => _units.Count > 0;
+
+ public IReadOnlyCollection 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);
+
+ /// The unit an agent belongs to; null for the top level (or an unknown unit).
+ public UnitConfig? UnitOf(string agentId)
+ => _agents.TryGetValue(agentId, out var agent) ? Unit(KnownUnit(agent)) : null;
+
+ /// Direct sub-units; null asks for the top-level units.
+ public IReadOnlyList Children(string? unitId)
+ => _children.TryGetValue(unitId ?? Root, out var list) ? list : [];
+
+ /// Direct members; null asks for the agents on the top level.
+ public IReadOnlyList MembersOf(string? unitId)
+ => _members.TryGetValue(unitId ?? Root, out var list) ? list : [];
+
+ /// The units above, nearest first — not including the unit itself.
+ public IReadOnlyList Ancestors(string unitId)
+ {
+ var result = new List();
+ var seen = new HashSet(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;
+ }
+
+ /// "Marketing / Marketplaces / Amazon".
+ 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);
+ }
+
+ /// Depth of a unit: a top-level unit is 1.
+ public int Depth(string unitId) => Unit(unitId) is null ? 0 : Ancestors(unitId).Count + 1;
+
+ /// The deepest unit's depth; 0 without units.
+ public int TreeDepth => _units.Keys.Select(Depth).DefaultIfEmpty(0).Max();
+
+ ///
+ /// 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.
+ ///
+ public int DelegationDepth(int configuredFloor) => Math.Max(configuredFloor, TreeDepth + 1);
+
+ /// The lead of a unit, if it has one that is actually a member.
+ 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;
+ }
+
+ /// The unit this agent leads, if any.
+ 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;
+ }
+
+ ///
+ /// 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. null means: the human.
+ ///
+ 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;
+ }
+
+ ///
+ /// Everyone the agent may address — the cut of the address book (section 3):
+ ///
+ /// - the members of its own unit and its effective lead;
+ /// - 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;
+ /// - on the top level: each other and the leads of the top-level units, both ways.
+ ///
+ /// Without units: everyone.
+ ///
+ public IReadOnlySet AddressBook(string agentId)
+ {
+ var result = new HashSet(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);
+
+ ///
+ /// The tools a member of the unit may have at most: the intersection of the ceilings
+ /// along the path to the top. null means no ceiling anywhere on the path.
+ ///
+ public IReadOnlySet? ToolCeiling(string? unitId)
+ {
+ if (Unit(unitId) is not { } unit)
+ return null;
+
+ HashSet? 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(node.ToolCeiling, StringComparer.OrdinalIgnoreCase);
+ else
+ ceiling.IntersectWith(node.ToolCeiling);
+ }
+ return ceiling;
+ }
+
+ ///
+ /// 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.
+ ///
+ public IReadOnlyList Problems()
+ {
+ var problems = new List();
+
+ 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 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);
+ }
+ }
+
+ /// The parent id if it names an existing unit and does not close a cycle.
+ 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(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;
+ }
+}
diff --git a/tests/ClawdDotNet.Core.Tests/Organisation/OrgChartTests.cs b/tests/ClawdDotNet.Core.Tests/Organisation/OrgChartTests.cs
new file mode 100644
index 0000000..1d40959
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Organisation/OrgChartTests.cs
@@ -0,0 +1,226 @@
+using ClawdDotNet.Core.Config;
+using ClawdDotNet.Core.Organisation;
+using Shouldly;
+
+namespace ClawdDotNet.Core.Tests.Organisation;
+
+///
+/// #683: The organisation tree and the address book it cuts. The example is the online
+/// shop from the concept, trimmed:
+///
+///
+/// (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
+///
+///
+public sealed class OrgChartTests
+{
+ private static UnitConfig Unit(string id, string? parent = null, string? lead = null, List? 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()) };
+
+ 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();
+}