Freigabe durch die Leitung innerhalb von Grenzen (#687)

- UnitConfig.Approvals: Operationen, Hoechstzahl je Tag, Stunden je Freigabe; nur der
  Mensch setzt sie (OrgAdministration.SetApprovals mit Audit, Teilmengenregel nach oben,
  Senken oben senkt unten mit; Agentenaenderungen/Sonderbudget nie freigebbar)
- LeadApprovals: Vorlage geht an die naechste Leitung, deren Einheit die Operation
  erlaubt; nie eigene Aufrufe, nie eine Leitung mit Fremdinhalt, Tagesdeckel, Frist.
  Entscheidung ueber Escalation approve_call/reject_call, im Audit als lead:<id>
- Der Mensch sieht alles und kann zuerst entscheiden; danach wird die Leitungsaufgabe
  geschlossen. Nach der Frist nur noch der Mensch.
- StagedCalls: Approver, ApproverUntil, ApproverTaskId (Nachruestung)
- WebUI: Hinweis "bei Leitung" in der Freigabestelle, Zaehler ohne diese, Quote je
  Leitung; Karte in der Organisationsansicht; i18n en/de
- Tests: LeadApprovalsTests (13), OrgAdministrationTests (+2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-30 20:12:18 +02:00
co-authored by Claude Opus 5.5
parent dafb8241a0
commit f51186269c
27 changed files with 948 additions and 20 deletions
@@ -251,6 +251,43 @@ public sealed class OrgAdministrationTests : IDisposable
Find("newbie").UnitId.ShouldBe("amazon");
}
// ═══════════════════ approvals by the lead (#687) ═══════════════════
private UnitConfig Unit(string id) => _test.Host.Instance.Units.Single(u => u.Id == id);
[Fact]
public void A_unit_grants_its_lead_approvals_but_never_changes_to_agents()
{
BuildMarketing();
Org.SetApprovals("marketing", ["AgentEditor.update_soul"]).Message.ShouldContain("always stays with a human");
Org.SetApprovals("marketing", ["AgentEditor"]).Ok.ShouldBeFalse("a whole tool with such an operation reads as a grant for it");
Org.SetApprovals("marketing", [".x"]).Ok.ShouldBeFalse();
var set = Org.SetApprovals("marketing", ["Database.insert", "FileRW.delete"], perDay: 5, hours: 2, reason: "night shift");
set.Ok.ShouldBeTrue(set.Message);
Unit("marketing").Approvals!.Operations.ShouldBe(["Database.insert", "FileRW.delete"]);
Unit("marketing").Approvals!.PerDay.ShouldBe(5);
Org.SetApprovals("marketing", []).Ok.ShouldBeTrue();
Unit("marketing").Approvals.ShouldBeNull();
}
[Fact]
public void A_sub_unit_grants_no_more_than_the_units_above_and_shrinks_with_them()
{
BuildMarketing();
Org.SetApprovals("marketing", ["Database"]).Ok.ShouldBeTrue();
Org.SetApprovals("amazon", ["Mail.send"]).Message.ShouldContain("Marketing");
Org.SetApprovals("amazon", ["Database.insert", "Database.update"]).Ok.ShouldBeTrue("'Database' above covers each of its operations");
var lowered = Org.SetApprovals("marketing", ["Database.insert"]);
lowered.Message.ShouldContain("Amazon");
Unit("amazon").Approvals!.Operations.ShouldBe(["Database.insert"]);
}
// ═══════════════════ the top (#690) ═══════════════════
private void MakeDecider(string id)
@@ -75,6 +75,20 @@ internal sealed class InMemoryStagingRepository : IStagingRepository
}
}
public Task SetApproverAsync(long id, string approver, DateTime until, string taskId, CancellationToken ct)
{
lock (_lock)
{
if (_calls.TryGetValue(id, out var call) && call.Status == StagingStatus.Pending)
_calls[id] = call with { Approver = approver, ApproverUntil = until, ApproverTaskId = taskId };
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<StagedCall>> ListRoutedSinceAsync(DateTime since, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<StagedCall>>(
_calls.Values.Where(c => c.Approver is not null && c.CreatedAt >= since).OrderBy(c => c.Id).ToList());
/// <summary>Verschiebt den Anlagezeitpunkt eines Vorschlags in die Vergangenheit - fuer Verfallstests.</summary>
public void Altern(long id, TimeSpan um)
{
@@ -0,0 +1,223 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Staging;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Staging;
/// <summary>
/// #687: A lead approves within the limits its unit grants — never its own calls, never a
/// change to agents, never while it reads foreign content — and the human stays above it.
/// </summary>
public sealed class LeadApprovalsTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "leads-" + Guid.NewGuid().ToString("N")[..8]);
private readonly SqliteStagingRepository _staging;
private readonly SqliteTaskRepository _tasks;
private readonly TaskboardService _board;
private readonly FakeTimeProvider _clock = new(DateTimeOffset.UtcNow);
private readonly Executor _executor = new();
private readonly StagingService _service;
private readonly LeadApprovals _leads;
private readonly HashSet<string> _foreign = new(StringComparer.OrdinalIgnoreCase);
/// <summary>ops (ops-lead; grants Database.insert, 2 a day, 1 h) › team (team-lead, grants nothing; worker).</summary>
private readonly UnitConfig _ops = new()
{
Id = "ops", Name = "Operations", LeaderAgentId = "ops-lead",
Approvals = new UnitApprovals { Operations = ["Database.insert", "AgentEditor"], PerDay = 2, Hours = 1 }
};
private OrgChart Org => new(
[_ops, new UnitConfig { Id = "team", Name = "Team", ParentId = "ops", LeaderAgentId = "team-lead" }],
[
new AgentConfig { AgentId = "ops-lead", UnitId = "ops" },
new AgentConfig { AgentId = "team-lead", UnitId = "team" },
new AgentConfig { AgentId = "worker", UnitId = "team" }
]);
public LeadApprovalsTests()
{
var storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_staging = new SqliteStagingRepository(storage);
_tasks = new SqliteTaskRepository(storage);
_board = new TaskboardService(_tasks, Path.Combine(_directory, "tasks"));
_service = new StagingService(_staging, _executor, _board, NullLoggerFactory.Instance);
_leads = new LeadApprovals(_staging, _board, () => Org,
a => _foreign.Contains(a.AgentId) ? new AgentTrust(TrustTier.ReadsForeignContent, ["WebFetch"]) : AgentTrust.Standard,
NullLogger.Instance, _clock) { Service = _service };
}
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleaning up is secondary */ }
}
private async Task<(StagedCall Call, string? Line)> Stage(string agent, string tool = "Database", string action = "insert")
{
var call = new StagedCall { RunId = "run-1", AgentId = agent, Tool = tool, Action = action, ArgumentsJson = $"{{\"action\":\"{action}\"}}" };
var id = await _staging.AppendAsync(call, default);
var line = await _leads.RouteAsync(call with { Id = id }, default);
return ((await _staging.GetAsync(id, default))!, line);
}
// ═══════════════════ where it goes ═══════════════════
[Fact]
public async Task A_call_goes_to_the_nearest_lead_whose_unit_grants_it()
{
var (call, line) = await Stage("worker");
call.Approver.ShouldBe("ops-lead", "team-lead's unit grants nothing — the grant above counts");
line!.ShouldContain("ops-lead");
var inbox = (await _tasks.GetAsync(call.ApproverTaskId!, default))!;
inbox.Assignee.ShouldBe("@ops-lead");
inbox.Body.ShouldContain("<untrusted_content", Case.Sensitive, customMessage: "the frozen call is data for the lead");
inbox.Body.ShouldContain("approve_call");
}
[Fact]
public async Task What_the_unit_does_not_grant_goes_to_the_human()
=> (await Stage("worker", "Mail", "send")).Call.Approver.ShouldBeNull();
[Fact]
public async Task A_change_to_agents_stays_with_the_human_even_under_a_whole_tool_grant()
=> (await Stage("worker", "AgentEditor", "update_soul")).Call.Approver.ShouldBeNull();
[Fact]
public async Task A_lead_never_gets_its_own_call()
{
(await Stage("ops-lead")).Call.Approver.ShouldBeNull("nobody above ops-lead grants anything");
(await Stage("team-lead")).Call.Approver.ShouldBe("ops-lead");
}
[Fact]
public async Task A_lead_that_reads_foreign_content_does_not_approve()
{
_foreign.Add("ops-lead");
(await Stage("worker")).Call.Approver.ShouldBeNull();
}
[Fact]
public async Task Beyond_the_daily_cap_the_human_decides()
{
(await Stage("worker")).Call.Approver.ShouldBe("ops-lead");
(await Stage("worker")).Call.Approver.ShouldBe("ops-lead");
(await Stage("worker")).Call.Approver.ShouldBeNull();
}
// ═══════════════════ how it is decided ═══════════════════
[Fact]
public async Task The_lead_approves_the_frozen_call_and_the_decision_is_marked_as_the_leads()
{
var (call, _) = await Stage("worker");
var decided = await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, approve: true, "", default);
decided.Ok.ShouldBeTrue(decided.Message);
_executor.Calls.ShouldBe(1);
var after = (await _staging.GetAsync(call.Id, default))!;
after.Status.ShouldBe(StagingStatus.Executed);
after.DecidedBy.ShouldBe("lead:ops-lead");
(await _tasks.GetAsync(call.ApproverTaskId!, default))!.Status.ShouldBe(TaskItemStatus.Done);
}
[Fact]
public async Task A_rejection_needs_its_reason()
{
var (call, _) = await Stage("worker");
(await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, approve: false, " ", default)).Ok.ShouldBeFalse();
var rejected = await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, approve: false, "Wrong table.", default);
rejected.Ok.ShouldBeTrue(rejected.Message);
(await _staging.GetAsync(call.Id, default))!.Status.ShouldBe(StagingStatus.Rejected);
_executor.Calls.ShouldBe(0);
}
[Fact]
public async Task Only_the_routed_lead_decides_and_only_in_time()
{
var (call, _) = await Stage("worker");
(await _leads.DecideAsync(call.Id, "team-lead", AgentTrust.Standard, true, "", default)).Message.ShouldContain("not yours");
(await _leads.DecideAsync(call.Id, "ops-lead", new AgentTrust(TrustTier.ReadsForeignContent, ["Mail"]), true, "", default))
.Message.ShouldContain("outside");
_clock.Advance(TimeSpan.FromHours(1.5));
(await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, true, "", default)).Message.ShouldContain("run out");
(await _leads.PendingForAsync("ops-lead", default)).ShouldBeEmpty();
(await _service.ApproveAsync(call.Id, "webui", default)).Kind.ShouldBe(StagingResultKind.Executed, "the human decides it now");
}
[Fact]
public async Task When_the_human_decides_first_the_leads_task_is_closed()
{
var (call, _) = await Stage("worker");
await _service.RejectAsync(call.Id, "webui", "Not now.", default);
(await _tasks.GetAsync(call.ApproverTaskId!, default))!.Status.ShouldBe(TaskItemStatus.Canceled);
(await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, true, "", default)).Message.ShouldContain("already decided");
}
[Fact]
public async Task A_grant_taken_away_ends_the_leads_say()
{
var (call, _) = await Stage("worker");
_ops.Approvals = null;
(await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, true, "", default)).Message.ShouldContain("no longer grants");
}
// ═══════════════════ the rate ═══════════════════
[Fact]
public async Task The_approval_rate_per_lead_is_counted()
{
var (first, _) = await Stage("worker");
var (second, _) = await Stage("worker");
await _leads.DecideAsync(first.Id, "ops-lead", AgentTrust.Standard, true, "", default);
await _leads.DecideAsync(second.Id, "ops-lead", AgentTrust.Standard, false, "No.", default);
var stats = (await _leads.StatsAsync(30, default)).Single();
stats.LeadId.ShouldBe("ops-lead");
stats.Routed.ShouldBe(2);
stats.Approved.ShouldBe(1);
stats.Rejected.ShouldBe(1);
stats.ApprovalRate.ShouldBe(0.5);
}
[Fact]
public async Task The_gate_tells_the_agent_who_decides()
{
var gate = new StagingGate(new StagingPolicy(), _staging) { Route = _leads.RouteAsync };
var intercept = await gate.InterceptAsync("worker", "inst", "run-2", "Database", "{\"action\":\"insert\",\"table\":\"orders\"}", default, "insert");
intercept.Outcome.ShouldBe(StagingOutcome.Staged);
intercept.Message.ShouldContain("ops-lead");
intercept.Message.ShouldNotContain("menschlicher Freigabe");
}
private sealed class Executor : IFrozenCallExecutor
{
public int Calls { get; private set; }
public Task<string> ExecuteApprovedCallAsync(string agentId, string tool, string argumentsJson, string runId, CancellationToken ct)
{
Calls++;
return Task.FromResult("1 row inserted");
}
}
}