diff --git a/docs/Organisation-Konzept.md b/docs/Organisation-Konzept.md
index 5898211..e4e3ed4 100644
--- a/docs/Organisation-Konzept.md
+++ b/docs/Organisation-Konzept.md
@@ -1051,6 +1051,20 @@ Agenten einen neuen Gesprächspartner. Die Stimmberechtigten reden nicht miteina
sehen den Antrag und antworten. Deshalb steht sie **nicht** in der Ausnahmeliste aus
Abschnitt 3.
+> **Umgesetzt (#702).** Die Regel steht je Einheit (`votes`: Operationen, Stimmberechtigte,
+> angenommen ab, abgelehnt bei höchstens, Quorum, Frist, Obergrenze je Tag) und wird nur vom
+> Menschen gesetzt. Eine abgedeckte Freigabe geht **vor** der Leitung (#687) an die
+> Abstimmung: Jeder Stimmberechtigte bekommt eine eigene Aufgabe mit dem eingefrorenen
+> Aufruf und sonst nichts, stimmt mit `cast_vote` (ja/nein, Begründung, Beleg — beides
+> Pflicht) und sieht keine andere Stimme. Gezählt wird, sobald das Ergebnis feststeht: früh,
+> wenn die restlichen Stimmen es nicht mehr ändern können, sonst wenn alle abgestimmt haben
+> oder die Frist um ist. Dazwischen und ohne Quorum entscheidet der Mensch, mit allen
+> Stimmen neben dem Aufruf in der Freigabeansicht. Wer beantragt, stimmt nicht mit; wer
+> fremde Inhalte liest, bekommt keinen Stimmzettel; Änderungen an Agenten und Budgets kommen
+> nie zur Abstimmung. Erfasst wird jede Stimme mit Lauf, die Kosten der Abstimmenden stehen
+> bei jeder Abstimmung. Nicht umgesetzt: Schwellen nach Betrag — ein Freigabeaufruf trägt
+> keinen allgemein lesbaren Betrag; das braucht eine Betragsangabe je Werkzeug.
+
### Die Doctrine: wie eine Leitung steuert, ohne jeden Auftrag anzufassen
Ein Agent wird heute von zwei Texten geprägt: `Identity.md` sagt, **wer** er ist,
diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs
index 8d3f324..52642ee 100644
--- a/src/ClawdDotNet.App/AppHost.cs
+++ b/src/ClawdDotNet.App/AppHost.cs
@@ -653,7 +653,7 @@ public sealed class AppHost : IAsyncDisposable
var (scanner, staging, board) = BuildTaskboard(
instance, taskRepository, engine, tools, stateStore, audit,
- stagingRepository, dauerfreigaben, stagingGate, cases, loggerFactory, logger);
+ stagingRepository, dauerfreigaben, stagingGate, cases, storage, loggerFactory, logger);
var host = new AppHost
{
@@ -732,6 +732,13 @@ public sealed class AppHost : IAsyncDisposable
spotChecks.Start();
host._shutdown.Add(spotChecks.DisposeAsync);
+ // #702: votes whose time is up are counted without waiting for the next ballot.
+ if (engine.Votes is Core.Staging.Votes votes)
+ {
+ votes.Start();
+ host._shutdown.Add(votes.DisposeAsync);
+ }
+
// #699: costs per unit, and what changed in them.
var costWatch = new Core.Accounting.CostWatch(
storage, usage, () => Core.Organisation.OrgChart.From(instance), () => instance.CostWatch, board,
@@ -838,6 +845,7 @@ public sealed class AppHost : IAsyncDisposable
SqliteDauerfreigabeRepository dauerfreigaben,
StagingGate stagingGate,
Core.Cases.ICaseRepository cases,
+ SqliteStorage storage,
ILoggerFactory loggerFactory,
ILogger logger)
{
@@ -858,9 +866,17 @@ public sealed class AppHost : IAsyncDisposable
stagingRepository, board, () => engine.OrganisationProvider?.Invoke(),
agent => Core.Organisation.ReportingLine.TrustOf(agent, tools.Get),
loggerFactory.CreateLogger("ClawdDotNet.Core.Staging.Leads")) { Service = staging };
- stagingGate.Route = leadApprovals.RouteAsync;
engine.LeadApprovals = leadApprovals;
+ // #702: What a unit puts to a blind vote goes there first; otherwise to the lead.
+ var votes = new Votes(
+ storage, stagingRepository, board, () => engine.OrganisationProvider?.Invoke(),
+ agent => Core.Organisation.ReportingLine.TrustOf(agent, tools.Get),
+ loggerFactory.CreateLogger("ClawdDotNet.Core.Staging.Votes")) { Service = staging, Audit = audit };
+ stagingGate.Route = async (call, ct) => await votes.RouteAsync(call, ct) ?? await leadApprovals.RouteAsync(call, ct);
+ engine.Votes = votes;
+
+
var dispatcher = new EngineTaskDispatcher(
engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory,
tasks: taskRepository);
diff --git a/src/ClawdDotNet.App/Services/OrgAdministration.cs b/src/ClawdDotNet.App/Services/OrgAdministration.cs
index 5b4c53e..30da681 100644
--- a/src/ClawdDotNet.App/Services/OrgAdministration.cs
+++ b/src/ClawdDotNet.App/Services/OrgAdministration.cs
@@ -271,6 +271,78 @@ public sealed class OrgAdministration(AppHost host, ILogger logger)
return AdminResult.Success(trimmed.Count == 0 ? message : message + $" Lowered with it: {string.Join(", ", trimmed)}.");
}
+ // ─── votes (#702) ───
+
+ ///
+ /// #702: What a unit puts to a blind vote, and who votes. Only a human sets it. Changes to
+ /// agents and budgets are never put to a vote; the thresholds must leave room for a human
+ /// between approval and rejection. null or no operations takes the vote away.
+ ///
+ public AdminResult SetVotes(string unitId, IReadOnlyCollection? operations, IReadOnlyCollection? voters,
+ int? approveAt = null, int? rejectAt = null, int? quorum = null, double? hours = null, int? perDay = null, string reason = "")
+ {
+ if (FindUnit(unitId) is not { } unit)
+ return AdminResult.Failure($"There is no unit '{unitId}'.");
+
+ var ops = (operations ?? []).Select(o => o.Trim()).Where(o => o.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ if (ops.Count == 0)
+ {
+ unit.Votes = null;
+ Save(null, "unit_votes", new { unitId, operations = ops }, reason);
+ return AdminResult.Success($"'{unit.Name}' puts nothing to a vote now.");
+ }
+
+ foreach (var op in ops)
+ {
+ var dot = op.IndexOf('.');
+ var tool = dot < 0 ? op : op[..dot];
+ var operation = dot < 0 ? null : op[(dot + 1)..];
+ if (tool.Length == 0 || (dot >= 0 && string.IsNullOrWhiteSpace(operation)))
+ return AdminResult.Failure($"'{op}' is no operation — write Tool.operation, or Tool for all of its operations.");
+ if (FreigabeRegeln.IstImmerFreigabe(tool, operation)
+ || (operation is null && FreigabeRegeln.ImmerFreigabe.Any(e => e.StartsWith(tool + ".", StringComparison.OrdinalIgnoreCase))))
+ return AdminResult.Failure($"'{op}' changes agents or budgets — that always stays with a human.");
+ }
+
+ var people = (voters ?? []).Select(v => v.Trim().TrimStart('@')).Where(v => v.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ var unknown = people.Where(v => host.AgentAdmin.Find(v) is null).ToList();
+ if (unknown.Count > 0)
+ return AdminResult.Failure($"No such agent: {string.Join(", ", unknown)}.");
+ if (people.Count < 2)
+ return AdminResult.Failure("A vote needs at least two voters — one is a lead's approval.");
+
+ var current = unit.Votes ?? new UnitVotes();
+ var rule = new UnitVotes
+ {
+ Operations = ops,
+ Voters = people,
+ ApproveAt = approveAt ?? Math.Min(current.ApproveAt, people.Count),
+ RejectAt = rejectAt ?? current.RejectAt,
+ Quorum = quorum ?? current.Quorum,
+ Hours = hours ?? current.Hours,
+ PerDay = perDay ?? current.PerDay
+ };
+ if (rule.ApproveAt < 1 || rule.ApproveAt > people.Count)
+ return AdminResult.Failure($"Approval needs between 1 and {people.Count} yes votes.");
+ if (rule.RejectAt < 0 || rule.RejectAt >= rule.ApproveAt)
+ return AdminResult.Failure("Rejection must lie below approval — between them a human decides.");
+ if (rule.Quorum < 0 || rule.Quorum > people.Count)
+ return AdminResult.Failure($"The quorum lies between 0 (as many as approve) and {people.Count}.");
+ if (rule.Hours <= 0)
+ return AdminResult.Failure("The voters' time comes in hours, above zero.");
+ if (rule.PerDay < 0)
+ return AdminResult.Failure("The daily cap cannot be negative.");
+
+ unit.Votes = rule;
+ Save(null, "unit_votes", new { unitId, operations = ops, voters = people, rule.ApproveAt, rule.RejectAt, quorum = rule.EffectiveQuorum, rule.Hours, rule.PerDay }, reason);
+
+ var foreign = people.Where(v => host.AgentAdmin.Find(v) is { } a && Core.Organisation.ReportingLine.TrustOf(a, host.Tools.Get).ReadsForeignContent).ToList();
+ var message = $"'{unit.Name}' puts {string.Join(", ", ops)} to a vote of {people.Count}: approved from {rule.ApproveAt} yes, rejected at {rule.RejectAt} or fewer, a human in between; at least {rule.EffectiveQuorum} votes.";
+ return AdminResult.Success(foreign.Count == 0
+ ? message
+ : message + $" {string.Join(", ", foreign)} read(s) content from outside and get(s) no ballot while that holds.");
+ }
+
// ─── spot checks (#693) ───
///
@@ -932,7 +1004,7 @@ public sealed class OrgAdministration(AppHost host, ILogger logger)
{
Id = u.Id, Name = u.Name, ParentId = u.ParentId, LeaderAgentId = u.LeaderAgentId,
Goal = u.Goal, ToolCeiling = u.ToolCeiling?.ToList(), Defaults = u.Defaults, Approvals = u.Approvals,
- Inspection = u.Inspection
+ Inspection = u.Inspection, Votes = u.Votes
};
private static bool Same(string? a, string? b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
diff --git a/src/ClawdDotNet.Core/Config/UnitConfig.cs b/src/ClawdDotNet.Core/Config/UnitConfig.cs
index 2a06df9..31def91 100644
--- a/src/ClawdDotNet.Core/Config/UnitConfig.cs
+++ b/src/ClawdDotNet.Core/Config/UnitConfig.cs
@@ -63,6 +63,60 @@ public sealed class UnitConfig
///
[JsonPropertyName("inspection")]
public UnitInspection? Inspection { get; set; }
+
+ ///
+ /// #702: Operations that are put to a blind vote instead of the lead; null means none.
+ /// Set only by a human — a vote is a deliberate loosening, not a default.
+ ///
+ [JsonPropertyName("votes")]
+ public UnitVotes? Votes { get; set; }
+}
+
+///
+/// #702: A vote — several agents check independently and vote blind before something happens.
+/// Not opinions are counted but independent checks enforced: every voter looks at its own data.
+///
+public sealed class UnitVotes
+{
+ /// The operations put to the vote: Tool.operation, or Tool for all of it.
+ [JsonPropertyName("operations")]
+ public List Operations { get; set; } = [];
+
+ /// The agents that vote. Whoever proposes the call does not vote on it.
+ [JsonPropertyName("voters")]
+ public List Voters { get; set; } = [];
+
+ /// Yes votes from which the call is approved.
+ [JsonPropertyName("approveAt")]
+ public int ApproveAt { get; set; } = 3;
+
+ /// At this many yes votes or fewer, the call is rejected. Between the two, a human decides.
+ [JsonPropertyName("rejectAt")]
+ public int RejectAt { get; set; } = 1;
+
+ /// Votes that must be cast for any result; 0 means . A missing vote is no yes.
+ [JsonPropertyName("quorum")]
+ public int Quorum { get; set; }
+
+ /// How long the voters have; after that, what was cast is counted.
+ [JsonPropertyName("hours")]
+ public double Hours { get; set; } = 4;
+
+ /// Votes a day for the unit — every vote costs a run per voter; beyond, the lead or a human decides.
+ [JsonPropertyName("perDay")]
+ public int PerDay { get; set; } = 10;
+
+ [JsonIgnore]
+ public TimeSpan Window => TimeSpan.FromHours(Math.Clamp(Hours, 0.25, 72));
+
+ [JsonIgnore]
+ public int EffectiveQuorum => Quorum > 0 ? Quorum : ApproveAt;
+
+ /// Whether the vote covers the operation.
+ public bool Covers(string tool, string? operation)
+ => Operations.Any(o => string.Equals(o, tool, StringComparison.OrdinalIgnoreCase)
+ || (!string.IsNullOrWhiteSpace(operation)
+ && string.Equals(o, $"{tool}.{operation}", StringComparison.OrdinalIgnoreCase)));
}
///
diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs
index ef17456..922943b 100644
--- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs
+++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs
@@ -63,6 +63,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// #693: The leads' spot checks; set by the host.
public Inspection.ISpotChecks? SpotChecks { get; set; }
+ /// #702: Blind votes on staged calls; set by the host.
+ public Staging.IVotes? Votes { get; set; }
+
///
/// #717: Special budgets. A run on a task in a funded tree draws on the pot instead of the
/// daily limits; without this, every run counts against the day.
@@ -1878,6 +1881,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
Approvals = LeadApprovals,
Doctrines = Doctrines,
SpotChecks = SpotChecks,
+ Votes = Votes,
SpecialBudgets = SpecialBudgets,
Sandbox = Sandbox,
Files = Files,
diff --git a/src/ClawdDotNet.Core/Staging/LeadApprovals.cs b/src/ClawdDotNet.Core/Staging/LeadApprovals.cs
index 0d1bb9e..6707a5b 100644
--- a/src/ClawdDotNet.Core/Staging/LeadApprovals.cs
+++ b/src/ClawdDotNet.Core/Staging/LeadApprovals.cs
@@ -179,6 +179,7 @@ public sealed class LeadApprovals(
{
var now = Now;
return (await repo.ListRoutedSinceAsync(now.AddDays(-Math.Max(1, days)), ct))
+ .Where(c => !c.Approver!.StartsWith(Votes.DecidedByPrefix, StringComparison.Ordinal))
.GroupBy(c => c.Approver!, StringComparer.OrdinalIgnoreCase)
.Select(g =>
{
diff --git a/src/ClawdDotNet.Core/Staging/Votes.cs b/src/ClawdDotNet.Core/Staging/Votes.cs
new file mode 100644
index 0000000..aa12af1
--- /dev/null
+++ b/src/ClawdDotNet.Core/Staging/Votes.cs
@@ -0,0 +1,459 @@
+using System.Globalization;
+using System.Text;
+using ClawdDotNet.Core.Config;
+using ClawdDotNet.Core.Organisation;
+using ClawdDotNet.Core.Storage;
+using ClawdDotNet.Core.Tasks;
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.Core.Staging;
+
+/// One voter's ballot. is null while it is not cast.
+public sealed record Ballot(long VoteId, string Voter, string? TaskId, bool? Yes, string Reasoning, string Evidence, string? RunId, DateTime? CastAt)
+{
+ public bool Cast => Yes is not null;
+}
+
+/// #702: A vote on one staged call, with every ballot and what came of it.
+public sealed record VoteRecord(
+ long Id, long CallId, string UnitId, string Proposer, string Operation,
+ int ApproveAt, int RejectAt, int Quorum, DateTime Until, DateTime CreatedAt,
+ string? Outcome, DateTime? DecidedAt, IReadOnlyList Ballots)
+{
+ public int YesVotes => Ballots.Count(b => b.Yes == true);
+ public int NoVotes => Ballots.Count(b => b.Yes == false);
+ public int Outstanding => Ballots.Count(b => !b.Cast);
+ public bool Open => Outcome is null;
+}
+
+/// The voter's side, as the Escalation tool sees it.
+public interface IVotes
+{
+ /// The votes waiting for this voter — the call only, never another ballot.
+ Task> BallotsForAsync(string voter, CancellationToken ct);
+
+ Task CastAsync(long voteId, string voter, AgentTrust trust, bool yes, string reasoning, string evidence, string? runId, CancellationToken ct);
+}
+
+///
+/// #702: Several agents check independently and vote blind before something happens
+/// (Organisation-Konzept, section 8).
+///
+/// Not opinions, independent checks. Every voter gets its own task with the call
+/// and nothing else; none sees another ballot before casting its own. Otherwise a cheap model
+/// agrees with what it just read, and four checks become one check and three echoes. That is
+/// the opposite of the meeting (#686), where every contribution is visible on purpose.
+///
+/// Rules, not the model, decide the count. Approved from approveAt yes
+/// votes, rejected at rejectAt or fewer, a human in between. A missing vote is no yes:
+/// without the quorum, a human decides. Whoever proposes the call does not vote, and a voter
+/// that reads content from outside gets no ballot (#715) — its vote would be the most
+/// rewarding target an injected text can have.
+///
+/// The human stays above it — it sees the call and can decide it first, and what
+/// changes agents or budgets is never put to a vote ().
+/// Every ballot is kept with reasoning, evidence and run, so the costs of a vote can be set
+/// against what it prevented.
+///
+public sealed class Votes(
+ SqliteStorage storage,
+ IStagingRepository repo,
+ TaskboardService board,
+ Func organisation,
+ Func trustOf,
+ ILogger logger,
+ TimeProvider? clock = null) : IVotes, IAsyncDisposable
+{
+ public const string DecidedByPrefix = "vote:";
+
+ public static readonly TimeSpan Interval = TimeSpan.FromMinutes(5);
+
+ private readonly TimeProvider _clock = clock ?? TimeProvider.System;
+ private readonly CancellationTokenSource _cts = new();
+ private Task? _loop;
+
+ /// Set once the service exists — it needs the gate, the gate needs this.
+ public StagingService? Service { get; set; }
+
+ /// Where the voters' runs are recorded — for what a vote cost.
+ public Audit.IAuditRepository? Audit { get; init; }
+
+ /// What the voters' runs cost so far; null while no cost is known.
+ public async Task CostAsync(VoteRecord vote, CancellationToken ct)
+ {
+ decimal? cost = null;
+ if (Audit is null)
+ return null;
+ foreach (var runId in vote.Ballots.Select(b => b.RunId).OfType())
+ if (await Audit.GetReceiptForRunAsync(runId, ct) is { CostIsKnown: true } receipt)
+ cost = (cost ?? 0) + receipt.CostUsd;
+ return cost;
+ }
+
+ private DateTime Now => _clock.GetUtcNow().UtcDateTime;
+
+ ///
+ /// The vote this call goes to — the nearest unit of the proposer, upwards, whose rule covers
+ /// the operation — with the agents that may vote on it, or null.
+ ///
+ public static (UnitConfig Unit, UnitVotes Rule, IReadOnlyList Voters)? RuleFor(
+ OrgChart? org, string proposer, string tool, string? operation, Func trustOf)
+ {
+ if (org is not { HasUnits: true } || FreigabeRegeln.IstImmerFreigabe(tool, operation) || org.UnitOf(proposer) is not { } unit)
+ return null;
+
+ foreach (var candidate in new[] { unit }.Concat(org.Ancestors(unit.Id)))
+ {
+ if (candidate.Votes is not { } rule || !rule.Covers(tool, operation))
+ continue;
+ var voters = rule.Voters
+ .Where(v => !string.Equals(v, proposer, StringComparison.OrdinalIgnoreCase))
+ .Select(org.Agent).OfType()
+ .Where(a => !trustOf(a).ReadsForeignContent)
+ .Select(a => a.AgentId)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ // A vote that cannot reach its quorum is no vote — the lead or a human decides.
+ return voters.Count >= rule.EffectiveQuorum && voters.Count >= rule.ApproveAt ? (candidate, rule, voters) : null;
+ }
+ return null;
+ }
+
+ ///
+ /// Puts a freshly staged call to the vote, if a rule covers it and the unit has room today.
+ /// Returns the line the proposing agent reads, or null.
+ ///
+ public async Task RouteAsync(StagedCall call, CancellationToken ct)
+ {
+ if (RuleFor(organisation(), call.AgentId, call.Tool, call.Action, trustOf) is not var (unit, rule, voters))
+ return null;
+
+ if (await CountSinceAsync(unit.Id, Now.AddDays(-1), ct) >= Math.Max(0, rule.PerDay))
+ {
+ logger.LogInformation("Approval #{Id}: '{Unit}' has held its {Cap} votes for today — not put to a vote", call.Id, unit.Id, rule.PerDay);
+ return null;
+ }
+
+ var until = Now + rule.Window;
+ var label = Label(call);
+ var voteId = await storage.WriteAsync(async conn =>
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ INSERT INTO Votes (CallId, UnitId, Proposer, Operation, ApproveAt, RejectAt, Quorum, Until, CreatedAt)
+ VALUES (@call, @unit, @proposer, @op, @approve, @reject, @quorum, @until, @at)
+ RETURNING Id
+ """;
+ cmd.Parameters.AddWithValue("@call", call.Id);
+ cmd.Parameters.AddWithValue("@unit", unit.Id);
+ cmd.Parameters.AddWithValue("@proposer", call.AgentId);
+ cmd.Parameters.AddWithValue("@op", label);
+ cmd.Parameters.AddWithValue("@approve", rule.ApproveAt);
+ cmd.Parameters.AddWithValue("@reject", rule.RejectAt);
+ cmd.Parameters.AddWithValue("@quorum", rule.EffectiveQuorum);
+ cmd.Parameters.AddWithValue("@until", until.ToString("O"));
+ cmd.Parameters.AddWithValue("@at", Now.ToString("O"));
+ return Convert.ToInt64(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
+ }, ct);
+
+ foreach (var voter in voters)
+ {
+ var task = await board.CreateAsync(new TaskItem
+ {
+ Title = $"Vote #{voteId}: {label} from {call.AgentId}",
+ Body = Request(voteId, call, until),
+ Assignee = "@" + voter,
+ Priority = 4,
+ Acceptance = "Your vote is cast with reasoning and evidence from your own data.",
+ OriginRunId = call.RunId
+ }, ct);
+ await storage.WriteAsync(async conn =>
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "INSERT INTO VoteBallots (VoteId, Voter, TaskId) VALUES (@vote, @voter, @task)";
+ cmd.Parameters.AddWithValue("@vote", voteId);
+ cmd.Parameters.AddWithValue("@voter", voter);
+ cmd.Parameters.AddWithValue("@task", task.Id);
+ return await cmd.ExecuteNonQueryAsync(ct);
+ }, ct);
+ }
+
+ // The call waits for the vote, not for the human — the human can still decide it first.
+ await repo.SetApproverAsync(call.Id, DecidedByPrefix + voteId, until, "", ct);
+ logger.LogInformation("Approval #{Id} ({Op}) of {Agent} put to vote #{Vote}: {Voters}", call.Id, label, call.AgentId, voteId, string.Join(", ", voters));
+ return $"{voters.Count} agents check it independently and vote (vote #{voteId}, until {until:yyyy-MM-dd HH:mm} UTC); without a clear result a human decides.";
+ }
+
+ public async Task> BallotsForAsync(string voter, CancellationToken ct)
+ {
+ var result = new List<(VoteRecord, StagedCall)>();
+ foreach (var vote in await QueryAsync("Outcome IS NULL AND Id IN (SELECT VoteId FROM VoteBallots WHERE Voter = @p AND Yes IS NULL)", voter, ct))
+ if (await repo.GetAsync(vote.CallId, ct) is { Status: StagingStatus.Pending } call)
+ result.Add((vote, call));
+ return result;
+ }
+
+ public async Task CastAsync(long voteId, string voter, AgentTrust trust, bool yes, string reasoning, string evidence, string? runId, CancellationToken ct)
+ {
+ if (await GetAsync(voteId, ct) is not { } vote)
+ return LeadDecision.Fail($"There is no vote #{voteId}.");
+ var ballot = vote.Ballots.FirstOrDefault(b => string.Equals(b.Voter, voter, StringComparison.OrdinalIgnoreCase));
+ if (ballot is null)
+ return LeadDecision.Fail($"You do not vote on #{voteId}.");
+ if (ballot.Cast)
+ return LeadDecision.Fail($"You voted on #{voteId} already — a vote is not changed.");
+ if (!vote.Open)
+ return LeadDecision.Fail($"Vote #{voteId} is closed ({vote.Outcome}).");
+ if (trust.ReadsForeignContent)
+ return LeadDecision.Fail("You read content from outside, so you do not vote — your ballot stays empty.");
+ if (string.IsNullOrWhiteSpace(reasoning))
+ return LeadDecision.Fail("'reasoning' is required — why yes or no.");
+ if (string.IsNullOrWhiteSpace(evidence))
+ return LeadDecision.Fail("'evidence' is required — what you checked, with source and time. Without it a vote is an opinion.");
+
+ var cast = await storage.WriteAsync(async conn =>
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ UPDATE VoteBallots SET Yes = @yes, Reasoning = @reasoning, Evidence = @evidence, RunId = @run, CastAt = @at
+ WHERE VoteId = @vote AND Voter = @voter AND Yes IS NULL
+ """;
+ cmd.Parameters.AddWithValue("@yes", yes ? 1 : 0);
+ cmd.Parameters.AddWithValue("@reasoning", reasoning.Trim());
+ cmd.Parameters.AddWithValue("@evidence", evidence.Trim());
+ cmd.Parameters.AddWithValue("@run", (object?)runId ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@at", Now.ToString("O"));
+ cmd.Parameters.AddWithValue("@vote", voteId);
+ cmd.Parameters.AddWithValue("@voter", ballot.Voter);
+ return await cmd.ExecuteNonQueryAsync(ct);
+ }, ct);
+ if (cast == 0)
+ return LeadDecision.Fail($"You voted on #{voteId} already.");
+
+ await CloseTaskAsync(ballot.TaskId, TaskItemStatus.Done, null, ct);
+ var outcome = await ConcludeAsync(voteId, ct);
+ return new LeadDecision(true, outcome is null
+ ? $"Your vote on #{voteId} is cast. You will not see the others' votes."
+ : $"Your vote on #{voteId} is cast. The vote is concluded: {Describe(outcome)}.");
+ }
+
+ ///
+ /// Counts the vote if its result is settled — early when the rest can no longer change it,
+ /// otherwise once every ballot is in or the time is up. Returns the outcome, or null
+ /// while it is open.
+ ///
+ public async Task ConcludeAsync(long voteId, CancellationToken ct)
+ {
+ if (await GetAsync(voteId, ct) is not { Open: true } vote)
+ return null;
+
+ var call = await repo.GetAsync(vote.CallId, ct);
+ string? outcome;
+ if (call is not { Status: StagingStatus.Pending })
+ outcome = "superseded";
+ else
+ {
+ var cast = vote.YesVotes + vote.NoVotes;
+ var over = vote.Outstanding == 0 || Now >= vote.Until;
+ if (vote.YesVotes >= vote.ApproveAt && cast >= vote.Quorum)
+ outcome = "approved";
+ else if (cast >= vote.Quorum && vote.YesVotes + vote.Outstanding <= vote.RejectAt)
+ outcome = "rejected";
+ else if (!over)
+ outcome = null;
+ else
+ outcome = cast < vote.Quorum ? "human:quorum" : "human:undecided";
+ }
+ if (outcome is null)
+ return null;
+
+ // Only one pass concludes a vote, however many ballots arrive at once.
+ var won = await storage.WriteAsync(async conn =>
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "UPDATE Votes SET Outcome = @o, DecidedAt = @at WHERE Id = @id AND Outcome IS NULL";
+ cmd.Parameters.AddWithValue("@o", outcome);
+ cmd.Parameters.AddWithValue("@at", Now.ToString("O"));
+ cmd.Parameters.AddWithValue("@id", voteId);
+ return await cmd.ExecuteNonQueryAsync(ct);
+ }, ct);
+ if (won == 0)
+ return null;
+
+ foreach (var open in vote.Ballots.Where(b => !b.Cast))
+ await CloseTaskAsync(open.TaskId, TaskItemStatus.Canceled, $"Vote #{voteId} is concluded ({outcome}) — your vote is no longer needed.", ct);
+
+ var decidedBy = DecidedByPrefix + voteId;
+ switch (outcome)
+ {
+ case "approved" when Service is { } service:
+ await service.ApproveAsync(vote.CallId, decidedBy, ct);
+ break;
+ case "rejected" when Service is { } service:
+ await service.RejectAsync(vote.CallId, decidedBy, Reasons(vote), ct);
+ break;
+ case "human:quorum" or "human:undecided" when call is not null:
+ // The window ends now: the call is the human's, with every ballot beside it.
+ await repo.SetApproverAsync(vote.CallId, decidedBy, Now, "", ct);
+ break;
+ }
+
+ logger.LogInformation("Vote #{Vote} on approval #{Call}: {Outcome} ({Yes} yes, {No} no, {Missing} missing)",
+ voteId, vote.CallId, outcome, vote.YesVotes, vote.NoVotes, vote.Outstanding);
+ return outcome;
+ }
+
+ /// Concludes what is due: votes whose time is up or whose call a human decided meanwhile.
+ public async Task SweepAsync(CancellationToken ct)
+ {
+ var concluded = 0;
+ foreach (var vote in await QueryAsync("Outcome IS NULL", "", ct))
+ if (await ConcludeAsync(vote.Id, ct) is not null)
+ concluded++;
+ return concluded;
+ }
+
+ public async Task GetAsync(long voteId, CancellationToken ct)
+ => (await QueryAsync("Id = @p", voteId.ToString(CultureInfo.InvariantCulture), ct)).FirstOrDefault();
+
+ /// The vote of each call — for the human's view of the approvals.
+ public async Task> ForCallsAsync(IEnumerable callIds, CancellationToken ct)
+ {
+ var result = new Dictionary();
+ foreach (var id in callIds.Distinct())
+ if ((await QueryAsync("CallId = @p", id.ToString(CultureInfo.InvariantCulture), ct)).FirstOrDefault() is { } vote)
+ result[id] = vote;
+ return result;
+ }
+
+ public Task> RecentAsync(string unitId, int limit, CancellationToken ct)
+ => QueryAsync("UnitId = @p", unitId, ct, limit);
+
+ public static string Describe(string? outcome) => outcome switch
+ {
+ "approved" => "approved",
+ "rejected" => "rejected",
+ "human:quorum" => "too few votes — a human decides",
+ "human:undecided" => "no clear result — a human decides",
+ "superseded" => "decided by a human before the vote ended",
+ _ => "open"
+ };
+
+ private static string Reasons(VoteRecord vote)
+ => "Rejected by vote: " + string.Join(" | ", vote.Ballots.Where(b => b.Yes == false).Select(b => $"{b.Voter}: {b.Reasoning} ({b.Evidence})"));
+
+ private static string Request(long voteId, StagedCall call, DateTime until)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine($"{call.AgentId} proposes {Label(call)}. Before it runs, several agents check it independently — you are one of them (vote #{voteId}, until {until:yyyy-MM-dd HH:mm} UTC).");
+ sb.AppendLine();
+ sb.AppendLine("The call, exactly as it would run:");
+ sb.AppendLine(Engine.UntrustedContent.Rahmen(call.Tool, string.IsNullOrWhiteSpace(call.ArgumentsJson) ? call.Proposal : call.ArgumentsJson));
+ sb.AppendLine();
+ sb.AppendLine("Check it against your own data and your own area — not against what the proposer says about it. " +
+ "You do not see the other votes, and they do not see yours: that is the point.");
+ sb.AppendLine($"Then use the Escalation tool: cast_vote (vote {voteId}, choice yes or no, reasoning, evidence — what you checked, with source and time). " +
+ "A vote without evidence is an opinion. Not voting is no yes.");
+ return sb.ToString().TrimEnd();
+ }
+
+ private static string Label(StagedCall call)
+ => string.IsNullOrWhiteSpace(call.Action) ? call.Tool : $"{call.Tool}.{call.Action}";
+
+ private async Task CloseTaskAsync(string? taskId, TaskItemStatus status, string? note, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(taskId))
+ return;
+ try
+ {
+ if (await board.GetAsync(taskId, ct) is { Status: not (TaskItemStatus.Done or TaskItemStatus.Canceled or TaskItemStatus.Archived) })
+ {
+ if (note is not null)
+ await board.AddCommentAsync(taskId, "system", note, ct);
+ await board.UpdateAsync(taskId, t => t with { Status = status }, ct);
+ }
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ logger.LogWarning(ex, "The voter's task {Task} could not be closed", taskId);
+ }
+ }
+
+ // ─── storage ───
+
+ private async Task CountSinceAsync(string unitId, DateTime since, CancellationToken ct)
+ {
+ await using var conn = await storage.OpenConnectionAsync(ct);
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT COUNT(*) FROM Votes WHERE UnitId = @unit AND CreatedAt >= @since";
+ cmd.Parameters.AddWithValue("@unit", unitId);
+ cmd.Parameters.AddWithValue("@since", since.ToString("O"));
+ return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
+ }
+
+ private async Task> QueryAsync(string where, string value, CancellationToken ct, int limit = 200)
+ {
+ await using var conn = await storage.OpenConnectionAsync(ct);
+ var votes = new List();
+ using (var cmd = conn.CreateCommand())
+ {
+ cmd.CommandText = $"SELECT Id, CallId, UnitId, Proposer, Operation, ApproveAt, RejectAt, Quorum, Until, CreatedAt, Outcome, DecidedAt FROM Votes WHERE {where} ORDER BY Id DESC LIMIT @limit";
+ cmd.Parameters.AddWithValue("@p", value);
+ cmd.Parameters.AddWithValue("@limit", limit);
+ await using var r = await cmd.ExecuteReaderAsync(ct);
+ while (await r.ReadAsync(ct))
+ votes.Add(new VoteRecord(r.GetInt64(0), r.GetInt64(1), r.GetString(2), r.GetString(3), r.GetString(4),
+ r.GetInt32(5), r.GetInt32(6), r.GetInt32(7), Time(r, 8)!.Value, Time(r, 9)!.Value,
+ r.IsDBNull(10) ? null : r.GetString(10), Time(r, 11), []));
+ }
+
+ for (var i = 0; i < votes.Count; i++)
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT VoteId, Voter, TaskId, Yes, Reasoning, Evidence, RunId, CastAt FROM VoteBallots WHERE VoteId = @vote ORDER BY Voter";
+ cmd.Parameters.AddWithValue("@vote", votes[i].Id);
+ var ballots = new List();
+ await using var r = await cmd.ExecuteReaderAsync(ct);
+ while (await r.ReadAsync(ct))
+ ballots.Add(new Ballot(r.GetInt64(0), r.GetString(1), r.IsDBNull(2) ? null : r.GetString(2),
+ r.IsDBNull(3) ? null : r.GetInt64(3) == 1, r.GetString(4), r.GetString(5),
+ r.IsDBNull(6) ? null : r.GetString(6), Time(r, 7)));
+ votes[i] = votes[i] with { Ballots = ballots };
+ }
+ return votes;
+ }
+
+ private static DateTime? Time(SqliteDataReader r, int i)
+ => r.IsDBNull(i) ? null : DateTime.Parse(r.GetString(i), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
+
+ // ─── loop ───
+
+ public void Start() => _loop ??= RunAsync(_cts.Token);
+
+ private async Task RunAsync(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ try { await Task.Delay(Interval, ct); }
+ catch (OperationCanceledException) { return; }
+
+ try { await SweepAsync(ct); }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ logger.LogWarning(ex, "Counting the votes failed — next attempt in {Minutes} min", Interval.TotalMinutes);
+ }
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _cts.CancelAsync();
+ if (_loop is not null)
+ {
+ try { await _loop; }
+ catch (OperationCanceledException) { }
+ }
+ _cts.Dispose();
+ }
+}
diff --git a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs
index eab236c..29b3417 100644
--- a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs
+++ b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs
@@ -342,6 +342,37 @@ public sealed class SqliteStorage
TaskId TEXT NULL
);
+ -- #702: blind votes on staged calls, one ballot per voter with reasoning, evidence and run.
+ CREATE TABLE IF NOT EXISTS Votes (
+ Id INTEGER PRIMARY KEY AUTOINCREMENT,
+ CallId INTEGER NOT NULL,
+ UnitId TEXT NOT NULL,
+ Proposer TEXT NOT NULL,
+ Operation TEXT NOT NULL,
+ ApproveAt INTEGER NOT NULL,
+ RejectAt INTEGER NOT NULL,
+ Quorum INTEGER NOT NULL,
+ Until TEXT NOT NULL,
+ CreatedAt TEXT NOT NULL,
+ Outcome TEXT NULL,
+ DecidedAt TEXT NULL
+ );
+
+ CREATE INDEX IF NOT EXISTS IX_Votes_Call ON Votes (CallId);
+ CREATE INDEX IF NOT EXISTS IX_Votes_Open ON Votes (Outcome);
+
+ CREATE TABLE IF NOT EXISTS VoteBallots (
+ VoteId INTEGER NOT NULL,
+ Voter TEXT NOT NULL,
+ TaskId TEXT NULL,
+ Yes INTEGER NULL,
+ Reasoning TEXT NOT NULL DEFAULT '',
+ Evidence TEXT NOT NULL DEFAULT '',
+ RunId TEXT NULL,
+ CastAt TEXT NULL,
+ PRIMARY KEY (VoteId, Voter)
+ );
+
CREATE TABLE IF NOT EXISTS StagedCalls (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
RunId TEXT NOT NULL,
diff --git a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs
index 5efda55..e30a526 100644
--- a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs
+++ b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs
@@ -58,6 +58,9 @@ public sealed record AgentToolContext(
/// #693: The spot checks of a lead. null outside the engine.
public Inspection.ISpotChecks? SpotChecks { get; init; }
+ /// #702: The ballots of a voter. null outside the engine.
+ public Staging.IVotes? Votes { get; init; }
+
/// #654: Where code runs — never in the main process. Unavailable under Windows.
public Sandbox.ISandboxRunner? Sandbox { get; init; }
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.css b/src/ClawdDotNet.Manager.Web/wwwroot/app.css
index bed2bfd..607c1d1 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/app.css
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.css
@@ -2637,6 +2637,16 @@ button.org-einheit .org-name {
gap: 4px;
}
+/* #702: a vote beside the call — every ballot with its reasoning and evidence. */
+.staging-stimmen {
+ list-style: none;
+ margin: 8px 0 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
/* #693: the lead's spot checks and the measures waiting for the human. */
.org-stichproben h4 {
margin: 14px 0 6px;
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
index 7cc1dc8..8b8f565 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
@@ -138,6 +138,20 @@
"org.measure.security": "Sicherheitsverdacht",
"org.measureStatus.accepted": "angenommen",
"org.measureStatus.dismissed": "abgelehnt",
+ "org.votes": "Abstimmungen",
+ "org.votesHint": "Operationen, die mehrere Agenten vor der Ausführung unabhängig prüfen und blind abstimmen — statt der Leitung. Stimmberechtigte wählen, die auf verschiedene Daten sehen (Konten, Lager, Sortiment): Es geht um unabhängige Prüfungen, nicht um eine Mehrheit. Wer beantragt, stimmt nicht mit; wer fremde Inhalte liest, bekommt keinen Stimmzettel; eine fehlende Stimme ist kein Ja. Zwischen Annahme und Ablehnung und ohne Quorum entscheidest du. Jede Stimme kostet einen Lauf.",
+ "org.votesVoters": "Stimmberechtigte (ein Agent je Zeile)",
+ "org.votesApproveAt": "Angenommen ab Ja-Stimmen",
+ "org.votesRejectAt": "Abgelehnt bei höchstens Ja-Stimmen",
+ "org.votesQuorum": "Mindestens abgegebene Stimmen (0: wie für Annahme)",
+ "org.votesHours": "Stunden für die Abstimmung",
+ "org.votesHeld": "Abgehaltene Abstimmungen",
+ "org.voteWhen": "Wann",
+ "org.voteOperation": "Aufruf",
+ "org.voteTally": "Stimmen",
+ "org.voteTallyText": "{yes} ja · {no} nein · {missing} fehlen",
+ "org.voteOutcome": "Ergebnis",
+ "org.voteCost": "Kosten der Abstimmenden",
"org.ceilingInherited": "Von oben begrenzt auf: {tools}",
"org.settings": "Sammelwerte",
"org.settingsHint": "Werden nach einer Vorschau in die Agenten geschrieben. Die Einheit merkt sich den Wert nur als Vorgabe für neue Mitglieder — zur Laufzeit übertrumpft nichts einen Agenten.",
@@ -659,6 +673,14 @@
"staging.rejected": "Aufruf wurde abgelehnt.",
"staging.rejectFailed": "Ablehnen fehlgeschlagen: {error}",
"staging.withLead": "Die Leitung {lead} darf dies bis {until} entscheiden, im Rahmen dessen, was ihre Einheit erlaubt. Du kannst vorher entscheiden; danach liegt es allein bei dir.",
+ "staging.voteRunning": "Abstimmung #{id} läuft bis {until}: {yes} ja, {no} nein, {missing} ausstehend. Du kannst vorher entscheiden.",
+ "staging.voteDone": "Abstimmung #{id}: {outcome} — {yes} ja, {no} nein, {missing} fehlen.",
+ "staging.voteRule": "Angenommen ab {approve} Ja, abgelehnt bei höchstens {reject}, mindestens {quorum} Stimmen",
+ "staging.voteCost": "Läufe der Abstimmenden: ${cost}",
+ "staging.voteYes": "ja",
+ "staging.voteNo": "nein",
+ "staging.voteMissing": "fehlt",
+ "staging.voteEvidence": "Beleg: {evidence}",
"staging.leadsTitle": "Freigaben durch Leitungen",
"staging.leadsHint": "Was Leitungen in den letzten 30 Tagen an deiner Stelle freigegeben haben. Eine Leitung, die alles freigibt, verursacht nur Kosten — sieh dir ihre Entscheidungen an.",
"staging.leadName": "Leitung",
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
index 3e69871..e3671b4 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
@@ -138,6 +138,20 @@
"org.measure.security": "Security suspicion",
"org.measureStatus.accepted": "accepted",
"org.measureStatus.dismissed": "dismissed",
+ "org.votes": "Votes",
+ "org.votesHint": "Operations that several agents check independently and vote on blind before they run — instead of the lead. Choose voters that look at different data (accounts, stock, range): independent checks are the point, not a majority. Whoever proposes does not vote; voters that read content from outside get no ballot; a missing vote is no yes. Between approval and rejection, and without the quorum, you decide. Every voter costs a run.",
+ "org.votesVoters": "Voters (one agent per line)",
+ "org.votesApproveAt": "Approved from yes votes",
+ "org.votesRejectAt": "Rejected at yes votes or fewer",
+ "org.votesQuorum": "Votes needed at least (0: as for approval)",
+ "org.votesHours": "Hours for the vote",
+ "org.votesHeld": "Votes held",
+ "org.voteWhen": "When",
+ "org.voteOperation": "Call",
+ "org.voteTally": "Votes",
+ "org.voteTallyText": "{yes} yes · {no} no · {missing} missing",
+ "org.voteOutcome": "Outcome",
+ "org.voteCost": "Cost of the voters",
"org.ceilingInherited": "Limited from above to: {tools}",
"org.settings": "Unit-wide values",
"org.settingsHint": "Written into the agents after a preview. The unit keeps the value only as a default for new members — nothing overrides an agent at run time.",
@@ -659,6 +673,14 @@
"staging.rejected": "Call was rejected.",
"staging.rejectFailed": "Rejection failed: {error}",
"staging.withLead": "Its lead {lead} may decide this until {until}, within the limits its unit grants. You can decide it first; after that it is yours alone.",
+ "staging.voteRunning": "Vote #{id} runs until {until}: {yes} yes, {no} no, {missing} outstanding. You can decide it first.",
+ "staging.voteDone": "Vote #{id}: {outcome} — {yes} yes, {no} no, {missing} missing.",
+ "staging.voteRule": "Approved from {approve} yes, rejected at {reject} or fewer, at least {quorum} votes",
+ "staging.voteCost": "voters' runs: ${cost}",
+ "staging.voteYes": "yes",
+ "staging.voteNo": "no",
+ "staging.voteMissing": "missing",
+ "staging.voteEvidence": "Evidence: {evidence}",
"staging.leadsTitle": "Approvals by leads",
"staging.leadsHint": "What leads approved instead of you in the last 30 days. A lead that approves everything does nothing but cost — look at its decisions.",
"staging.leadName": "Lead",
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js
index 4cc01bc..3d4eeb7 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js
@@ -369,6 +369,7 @@ function unitPanel(unit) {
cards.push(approvalsCard(unit));
cards.push(doctrineCard(unit));
cards.push(inspectionCard(unit));
+ cards.push(votesCard(unit));
cards.push(settingsCard(unit));
// Members.
@@ -504,6 +505,69 @@ function doctrineCard(unit) {
]);
}
+/**
+ * #702: What the unit puts to a blind vote, who votes, and the thresholds. A vote counts
+ * independent checks, not opinions — the voters should look at different data. The votes held
+ * stand below, with every ballot.
+ */
+function votesCard(unit) {
+ const current = unit.votes;
+ const ops = erstelleElement("textarea", { rows: 2, placeholder: "Database.insert" });
+ ops.value = (current?.operations ?? []).join("\n");
+ const voters = erstelleElement("textarea", { rows: 3, placeholder: "accounting\nlogistics\nmarketing" });
+ voters.value = (current?.voters ?? []).join("\n");
+ const number = (value, min) => erstelleElement("input", { type: "number", min: String(min), step: "1", value: String(value) });
+ const approveAt = number(current?.approveAt ?? 3, 1);
+ const rejectAt = number(current?.rejectAt ?? 1, 0);
+ const quorum = number(current?.quorum ?? 0, 0);
+ const hours = erstelleElement("input", { type: "number", min: "0.25", step: "0.25", value: String(current?.hours ?? 4) });
+ const perDay = number(current?.perDay ?? 10, 0);
+ const held = erstelleElement("div", { klasse: "org-stichproben" });
+
+ (async () => {
+ let list = [];
+ try { list = await api(`/organisation/units/${encodeURIComponent(unit.id)}/votes`); } catch { return; }
+ if (!list.length) return;
+ held.append(erstelleElement("h4", { text: t("org.votesHeld") }));
+ held.append(erstelleElement("table", { klasse: "daten-tabelle" },
+ erstelleElement("thead", {}, erstelleElement("tr", {},
+ ...[t("org.voteWhen"), t("org.voteOperation"), t("org.voteTally"), t("org.voteOutcome"), t("org.voteCost")].map((h) => erstelleElement("th", { text: h })))),
+ erstelleElement("tbody", {}, ...list.map((v) => erstelleElement("tr", {},
+ erstelleElement("td", { text: formatiereDatumZeit(v.createdAt) }),
+ erstelleElement("td", { text: `${v.operation} · ${v.proposer}` }),
+ erstelleElement("td", { text: t("org.voteTallyText", { yes: v.yes, no: v.no, missing: v.missing }) }),
+ erstelleElement("td", { text: v.outcomeText }),
+ erstelleElement("td", { text: v.costUsd != null ? `$${v.costUsd.toFixed(4)}` : "—" }))))));
+ })();
+
+ const lines = (el) => el.value.split(/[\n,]/).map((o) => o.trim()).filter(Boolean);
+ return erstelleKarte(t("org.votes"), [
+ erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.votesHint") }),
+ erstelleElement("div", { klasse: "formular-raster" },
+ erstelleElement("label", { text: t("org.approvalsOperations") }), ops,
+ erstelleElement("label", { text: t("org.votesVoters") }), voters,
+ erstelleElement("label", { text: t("org.votesApproveAt") }), approveAt,
+ erstelleElement("label", { text: t("org.votesRejectAt") }), rejectAt,
+ erstelleElement("label", { text: t("org.votesQuorum") }), quorum,
+ erstelleElement("label", { text: t("org.votesHours") }), hours,
+ erstelleElement("label", { text: t("org.approvalsPerDay") }), perDay),
+ erstelleElement("button", {
+ type: "button", klasse: "klein", text: t("common.save"),
+ onclick: () => send(`/organisation/units/${encodeURIComponent(unit.id)}/votes`, "PUT", {
+ operations: lines(ops),
+ voters: lines(voters),
+ approveAt: Number(approveAt.value),
+ rejectAt: Number(rejectAt.value),
+ quorum: Number(quorum.value),
+ hours: Number(hours.value),
+ perDay: Number(perDay.value),
+ reason: reason()
+ })
+ }),
+ held
+ ]);
+}
+
/**
* #693: The lead's spot checks. How often it looks at the runs of the agents it leads, what
* came of the last checks, and the measures it proposed — which only a human decides. Nothing
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js
index 3fb10a1..d34bdfb 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js
@@ -10,6 +10,7 @@ import {
PFADE,
erstelleKarte,
erstelleModal,
+ erstellePlakette,
formatiereDatumZeit,
zeigeMeldung
} from "../modules/dom.js";
@@ -21,6 +22,28 @@ function menschWartet(anfrage) {
return !anfrage.withLead;
}
+/**
+ * #702: A vote on the call — the tally, and each ballot with its reasoning and evidence. The
+ * voters never see this; the human decides with all of it in view when the vote is undecided.
+ */
+function voteBox(vote, running) {
+ const kopf = running
+ ? t("staging.voteRunning", { id: vote.id, until: formatiereDatumZeit(vote.until), yes: vote.yes, no: vote.no, missing: vote.missing })
+ : t("staging.voteDone", { id: vote.id, outcome: vote.outcomeText, yes: vote.yes, no: vote.no, missing: vote.missing });
+ const regeln = t("staging.voteRule", { approve: vote.approveAt, reject: vote.rejectAt, quorum: vote.quorum })
+ + (vote.costUsd != null ? " · " + t("staging.voteCost", { cost: vote.costUsd.toFixed(4) }) : "");
+ return erstelleElement("div", { klasse: "hinweis-box staging-abstimmung" },
+ erstelleElement("strong", { text: kopf }),
+ erstelleElement("div", { klasse: "gedaempft text-klein", text: regeln }),
+ erstelleElement("ul", { klasse: "staging-stimmen" }, ...vote.ballots.map((b) => erstelleElement("li", {},
+ erstellePlakette(
+ b.choice === "yes" ? t("staging.voteYes") : b.choice === "no" ? t("staging.voteNo") : t("staging.voteMissing"),
+ b.choice === "yes" ? "erfolg" : b.choice === "no" ? "gefahr" : "neutral"),
+ erstelleElement("strong", { text: ` ${b.voter}` }),
+ b.choice ? erstelleElement("div", { klasse: "text-klein", text: b.reasoning }) : "",
+ b.choice ? erstelleElement("div", { klasse: "gedaempft text-klein", text: t("staging.voteEvidence", { evidence: b.evidence }) }) : ""))));
+}
+
export async function holeWartendeAnzahl() {
try {
const liste = await api("/staging");
@@ -145,7 +168,10 @@ async function ladeFreigaben() {
const teile = [datenliste, argBox];
// #687: A lead decides it within its unit's limits — the human can still decide first.
- if (anfrage.withLead) {
+ // #702: Or a blind vote does; its ballots stand beside the call.
+ if (anfrage.vote) {
+ teile.unshift(voteBox(anfrage.vote, !!anfrage.withLead));
+ } else if (anfrage.withLead) {
teile.unshift(erstelleElement("div", {
klasse: "hinweis-box",
text: t("staging.withLead", { lead: anfrage.withLead, until: formatiereDatumZeit(anfrage.leadUntil) })
diff --git a/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs b/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs
index 38ead4c..16b3646 100644
--- a/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs
+++ b/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs
@@ -41,6 +41,7 @@ public sealed class EscalationTool : IAgentTool
spot_checks lists the runs picked for you, with what the audit log shows; inspect_run (run) shows one run in full;
check_done (check, verdict ok|finding, note) closes a check. propose_measure (agent, measure, reasoning, check) puts a measure to a human:
sharpen_task, change_model, adjust_rights, suspend — or security, if you suspect the agent is steered from outside.
+ As a voter: list_ballots shows the calls you are asked to vote on; cast_vote (vote, choice yes|no, reasoning, evidence) — check against your own data, with source and time. You never see the other votes.
""";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
@@ -49,7 +50,7 @@ public sealed class EscalationTool : IAgentTool
"properties": {
"action": {
"type": "string",
- "enum": ["find_decisions", "escalate", "list_cases", "decide", "handle", "forward", "open_conflict", "state_position", "request_budget", "list_approvals", "approve_call", "reject_call", "doctrine_read", "doctrine_set", "spot_checks", "inspect_run", "check_done", "propose_measure"]
+ "enum": ["find_decisions", "escalate", "list_cases", "decide", "handle", "forward", "open_conflict", "state_position", "request_budget", "list_approvals", "approve_call", "reject_call", "doctrine_read", "doctrine_set", "spot_checks", "inspect_run", "check_done", "propose_measure", "list_ballots", "cast_vote"]
},
"amount_usd": { "type": "number", "description": "request_budget: the amount for the task and all its parts, in USD." },
"subject": { "type": "string", "description": "What it is about, in a few words — like a title. escalate, open_conflict, find_decisions." },
@@ -72,7 +73,10 @@ public sealed class EscalationTool : IAgentTool
"check": { "type": "integer", "description": "check_done, propose_measure: the spot check number." },
"verdict": { "type": "string", "enum": ["ok", "finding"], "description": "check_done: ok, or finding (then 'note' with the evidence)." },
"agent": { "type": "string", "description": "propose_measure: the agent it concerns." },
- "measure": { "type": "string", "enum": ["sharpen_task", "change_model", "adjust_rights", "suspend", "security"], "description": "propose_measure: what should follow." }
+ "measure": { "type": "string", "enum": ["sharpen_task", "change_model", "adjust_rights", "suspend", "security"], "description": "propose_measure: what should follow." },
+ "vote": { "type": "integer", "description": "cast_vote: the vote number (#…)." },
+ "choice": { "type": "string", "enum": ["yes", "no"], "description": "cast_vote: your vote." },
+ "evidence": { "type": "string", "description": "cast_vote: what you checked — source and time. Required: without it a vote is an opinion." }
},
"required": ["action"]
}
@@ -104,6 +108,10 @@ public sealed class EscalationTool : IAgentTool
if (Get(input, "action") is "spot_checks" or "inspect_run" or "check_done" or "propose_measure")
return await SpotCheckAsync(input, context, ct);
+ // #702: A voter's ballot.
+ if (Get(input, "action") is "list_ballots" or "cast_vote")
+ return await BallotAsync(input, context, ct);
+
// #687: A lead deciding the approvals its unit grants it.
if (Get(input, "action") is "list_approvals" or "approve_call" or "reject_call")
return await ApprovalAsync(input, context, ct);
@@ -176,7 +184,7 @@ public sealed class EscalationTool : IAgentTool
break;
default:
- return ToolResult.Fail("'action' must be one of find_decisions, escalate, list_cases, decide, handle, forward, open_conflict, state_position, request_budget, list_approvals, approve_call, reject_call, doctrine_read, doctrine_set, spot_checks, inspect_run, check_done, propose_measure.");
+ return ToolResult.Fail("'action' must be one of find_decisions, escalate, list_cases, decide, handle, forward, open_conflict, state_position, request_budget, list_approvals, approve_call, reject_call, doctrine_read, doctrine_set, spot_checks, inspect_run, check_done, propose_measure, list_ballots, cast_vote.");
}
return outcome.Ok ? ToolResult.Ok(outcome.Message) : ToolResult.Fail(outcome.Message);
@@ -328,6 +336,35 @@ public sealed class EscalationTool : IAgentTool
}
}
+ private static async Task BallotAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
+ {
+ if (context.Votes is not { } votes)
+ return ToolResult.Fail("Votes are not available in this instance.");
+
+ if (Get(input, "action") == "list_ballots")
+ {
+ var open = await votes.BallotsForAsync(context.AgentId, ct);
+ if (open.Count == 0)
+ return ToolResult.Ok("No vote waits for you.");
+ var sb = new StringBuilder($"{open.Count} vote(s) wait for you — check each against your own data:\n");
+ foreach (var (vote, call) in open)
+ {
+ sb.AppendLine($"- vote #{vote.Id}: {vote.Operation} from {vote.Proposer}, until {vote.Until:yyyy-MM-dd HH:mm} UTC. The call:");
+ sb.AppendLine(Core.Engine.UntrustedContent.Rahmen(call.Tool, string.IsNullOrWhiteSpace(call.ArgumentsJson) ? call.Proposal : call.ArgumentsJson));
+ }
+ return ToolResult.Ok(sb.ToString().TrimEnd());
+ }
+
+ if (!input.TryGetProperty("vote", out var v) || v.ValueKind != JsonValueKind.Number || !v.TryGetInt64(out var voteId))
+ return ToolResult.Fail("'vote' is required — the vote number.");
+ var choice = Get(input, "choice");
+ if (choice is not ("yes" or "no"))
+ return ToolResult.Fail("'choice' must be yes or no.");
+ var cast = await votes.CastAsync(voteId, context.AgentId, context.Trust, choice == "yes",
+ Get(input, "reasoning") ?? "", Get(input, "evidence") ?? "", context.RunId, ct);
+ return cast.Ok ? ToolResult.Ok(cast.Message) : ToolResult.Fail(cast.Message);
+ }
+
/// One run as the engine recorded it. The result text is the agent's — marked as such.
private static string Describe(RunInspection run)
{
diff --git a/src/ClawdDotNet.Web/Contracts/Dtos.cs b/src/ClawdDotNet.Web/Contracts/Dtos.cs
index d92debd..3cc9df1 100644
--- a/src/ClawdDotNet.Web/Contracts/Dtos.cs
+++ b/src/ClawdDotNet.Web/Contracts/Dtos.cs
@@ -322,7 +322,9 @@ public sealed record StagedCallDto(
bool SuggestAllowSimilar = false,
// #687: the lead that may decide it instead of the human, and until when.
string? WithLead = null,
- DateTime? LeadUntil = null);
+ DateTime? LeadUntil = null,
+ // #702: the blind vote on it, if one was held.
+ VoteDto? Vote = null);
/// #687: How a lead decided the approvals it was given.
public sealed record LeadApprovalStatsDto(string LeadId, int Routed, int Approved, int Rejected, int Lapsed, double? ApprovalRate);
diff --git a/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs b/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs
index 7e00b51..6a74966 100644
--- a/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs
+++ b/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs
@@ -22,7 +22,9 @@ public sealed record UnitDto(
// #687: what the lead may approve instead of the human; null: nothing.
UnitApprovalsDto? Approvals = null,
// #693: the lead's spot checks; null: none.
- UnitInspectionDto? Inspection = null);
+ UnitInspectionDto? Inspection = null,
+ // #702: what the unit puts to a blind vote; null: nothing.
+ UnitVotesDto? Votes = null);
/// #698: One version of a unit's doctrine.
public sealed record DoctrineDto(int Version, string Text, string SetBy, DateTime SetAt, string? RunId, string Reason);
@@ -40,6 +42,20 @@ public sealed record InspectionInputDto(bool Enabled, double? Rate, double? Dens
public sealed record FindingDto(string Kind, string Text);
+/// #702: What a unit puts to a blind vote, and who votes.
+public sealed record UnitVotesDto(IReadOnlyList Operations, IReadOnlyList Voters, int ApproveAt, int RejectAt, int Quorum, double Hours, int PerDay);
+
+public sealed record VotesInputDto(IReadOnlyList? Operations, IReadOnlyList? Voters, int? ApproveAt, int? RejectAt, int? Quorum, double? Hours, int? PerDay, string Reason = "");
+
+/// #702: One ballot as the human sees it — the voters never see each other's.
+public sealed record BallotDto(string Voter, string? Choice, string Reasoning, string Evidence, DateTime? CastAt, string? RunId);
+
+/// #702: A vote on a staged call — the tally, every ballot and what the voters' runs cost.
+public sealed record VoteDto(
+ long Id, long CallId, string Proposer, string Operation, int ApproveAt, int RejectAt, int Quorum,
+ DateTime Until, DateTime CreatedAt, string Outcome, string OutcomeText, int Yes, int No, int Missing,
+ IReadOnlyList Ballots, decimal? CostUsd);
+
/// #693: A run picked for the lead, with what the audit log showed and the lead's verdict.
public sealed record SpotCheckDto(
long Id, string Lead, string AgentId, string RunId, string Why, IReadOnlyList Findings,
diff --git a/src/ClawdDotNet.Web/Contracts/VoteMapping.cs b/src/ClawdDotNet.Web/Contracts/VoteMapping.cs
new file mode 100644
index 0000000..4c3e900
--- /dev/null
+++ b/src/ClawdDotNet.Web/Contracts/VoteMapping.cs
@@ -0,0 +1,18 @@
+using ClawdDotNet.Core.Staging;
+
+namespace ClawdDotNet.Web.Contracts;
+
+/// #702: A vote for the human — with what the voters' runs cost, so a vote can be set against what it prevented.
+public static class VoteMapping
+{
+ public static async Task ToDtoAsync(VoteRecord vote, Votes votes, CancellationToken ct)
+ {
+ var cost = await votes.CostAsync(vote, ct);
+ return new VoteDto(
+ vote.Id, vote.CallId, vote.Proposer, vote.Operation, vote.ApproveAt, vote.RejectAt, vote.Quorum,
+ vote.Until, vote.CreatedAt, vote.Outcome ?? "open", Votes.Describe(vote.Outcome),
+ vote.YesVotes, vote.NoVotes, vote.Outstanding,
+ vote.Ballots.Select(b => new BallotDto(b.Voter, b.Yes is null ? null : b.Yes.Value ? "yes" : "no", b.Reasoning, b.Evidence, b.CastAt, b.RunId)).ToList(),
+ cost);
+ }
+}
diff --git a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
index d63e73b..3eeeb1d 100644
--- a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
+++ b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
@@ -347,9 +347,18 @@ internal static class ApiEndpoints
return Unavailable("Keine Freigabestelle in dieser Instanz.");
var pending = await service.ListPendingAsync(ct);
+ // #702: the vote beside each call it was held on — the human decides with every ballot in view.
+ var votes = host.Engine?.Votes is Core.Staging.Votes v
+ ? await v.ForCallsAsync(pending.Select(c => c.Id), ct)
+ : new Dictionary();
var liste = new List(pending.Count);
foreach (var call in pending)
- liste.Add(call.ToDto(await service.BisherFreigegebenAsync(call, ct), service.KenntDauerfreigaben));
+ {
+ var dto = call.ToDto(await service.BisherFreigegebenAsync(call, ct), service.KenntDauerfreigaben);
+ if (votes.TryGetValue(call.Id, out var vote))
+ dto = dto with { Vote = await VoteMapping.ToDtoAsync(vote, (Core.Staging.Votes)host.Engine!.Votes!, ct) };
+ liste.Add(dto);
+ }
return Results.Ok(liste);
});
diff --git a/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs
index 298c20f..8239d00 100644
--- a/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs
+++ b/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs
@@ -29,7 +29,8 @@ internal static class OrganisationEndpoints
new UnitDefaultsDto(u.Defaults.Model, u.Defaults.DailyCostUsd, u.Defaults.DailyTokens, u.Defaults.WhenExhausted),
chart.PathOf(u.Id), chart.Depth(u.Id),
u.Approvals is { } ap ? new UnitApprovalsDto(ap.Operations, ap.PerDay, ap.Hours) : null,
- u.Inspection is { } ins ? new UnitInspectionDto(ins.Rate, ins.DenseRate, ins.NewDays, ins.PerDay) : null))
+ u.Inspection is { } ins ? new UnitInspectionDto(ins.Rate, ins.DenseRate, ins.NewDays, ins.PerDay) : null,
+ u.Votes is { } v ? new UnitVotesDto(v.Operations, v.Voters, v.ApproveAt, v.RejectAt, v.EffectiveQuorum, v.Hours, v.PerDay) : null))
.ToList();
var agents = host.Instance.Agents
@@ -104,6 +105,20 @@ internal static class OrganisationEndpoints
return Results.Ok(new OutcomeDto(true, set.IsEmpty ? $"Doctrine of '{unit.Name}' removed." : $"Doctrine of '{unit.Name}' set (version {set.Version})."));
});
+ // #702: What the unit puts to a blind vote, and the votes held.
+ org.MapPut("/units/{unitId}/votes", (AppHost host, string unitId, VotesInputDto input) =>
+ host.Organisation.SetVotes(unitId, input.Operations, input.Voters, input.ApproveAt, input.RejectAt, input.Quorum, input.Hours, input.PerDay, input.Reason).ToResponse());
+
+ org.MapGet("/units/{unitId}/votes", async (AppHost host, string unitId, CancellationToken ct) =>
+ {
+ if (host.Engine?.Votes is not Core.Staging.Votes votes)
+ return Results.Ok(Array.Empty());
+ var list = new List();
+ foreach (var vote in await votes.RecentAsync(unitId, 20, ct))
+ list.Add(await VoteMapping.ToDtoAsync(vote, votes, ct));
+ return Results.Ok(list);
+ });
+
// #693: The lead's spot checks — how often, what came of them, and the measures it proposed.
org.MapPut("/units/{unitId}/inspection", (AppHost host, string unitId, InspectionInputDto input) =>
host.Organisation.SetInspection(unitId, input.Enabled, input.Rate, input.DenseRate, input.NewDays, input.PerDay, input.Reason).ToResponse());
diff --git a/tests/ClawdDotNet.App.Tests/Services/OrgAdministrationTests.cs b/tests/ClawdDotNet.App.Tests/Services/OrgAdministrationTests.cs
index 187d5b7..8494952 100644
--- a/tests/ClawdDotNet.App.Tests/Services/OrgAdministrationTests.cs
+++ b/tests/ClawdDotNet.App.Tests/Services/OrgAdministrationTests.cs
@@ -308,6 +308,28 @@ public sealed class OrgAdministrationTests : IDisposable
Unit("marketing").Inspection.ShouldBeNull();
}
+ // ═══════════════════ votes (#702) ═══════════════════
+
+ [Fact]
+ public void A_human_puts_operations_to_a_vote_with_room_for_a_human_between_the_thresholds()
+ {
+ BuildMarketing();
+
+ Org.SetVotes("marketing", ["AgentEditor.update_soul"], ["ads", "listing"]).Message.ShouldContain("always stays with a human");
+ Org.SetVotes("marketing", ["Database.insert"], ["ads"]).Ok.ShouldBeFalse("one voter is a lead's approval");
+ Org.SetVotes("marketing", ["Database.insert"], ["ads", "nobody"]).Message.ShouldContain("nobody");
+ Org.SetVotes("marketing", ["Database.insert"], ["ads", "listing", "specialist"], approveAt: 2, rejectAt: 2).Ok
+ .ShouldBeFalse("rejection must lie below approval");
+
+ var set = Org.SetVotes("marketing", ["Database.insert"], ["ads", "listing", "specialist"], approveAt: 2, rejectAt: 0, reason: "orders");
+
+ set.Ok.ShouldBeTrue(set.Message);
+ Unit("marketing").Votes!.Voters.ShouldBe(["ads", "listing", "specialist"]);
+ Unit("marketing").Votes!.EffectiveQuorum.ShouldBe(2);
+ Org.SetVotes("marketing", [], null).Ok.ShouldBeTrue();
+ Unit("marketing").Votes.ShouldBeNull();
+ }
+
// ═══════════════════ the top (#690) ═══════════════════
private void MakeDecider(string id)
diff --git a/tests/ClawdDotNet.Core.Tests/Staging/VotesTests.cs b/tests/ClawdDotNet.Core.Tests/Staging/VotesTests.cs
new file mode 100644
index 0000000..49c627e
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Staging/VotesTests.cs
@@ -0,0 +1,231 @@
+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;
+
+///
+/// #702: Several agents check independently and vote blind — the rule counts, a missing vote
+/// is no yes, and a human decides whatever is not clear.
+///
+public sealed class VotesTests : IDisposable
+{
+ private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "votes-" + 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 Votes _votes;
+ private readonly HashSet _foreign = new(StringComparer.OrdinalIgnoreCase);
+
+ private readonly UnitConfig _purchasing = new()
+ {
+ Id = "purchasing", Name = "Purchasing", LeaderAgentId = "buy-lead",
+ Votes = new UnitVotes
+ {
+ Operations = ["Database.insert", "AgentEditor"],
+ Voters = ["accounting", "logistics", "marketing", "sales", "buyer"],
+ ApproveAt = 3, RejectAt = 1, Hours = 1, PerDay = 3
+ }
+ };
+
+ private OrgChart Org => new(
+ [_purchasing, new UnitConfig { Id = "staff", Name = "Staff" }],
+ [
+ new AgentConfig { AgentId = "buy-lead", UnitId = "purchasing" },
+ new AgentConfig { AgentId = "buyer", UnitId = "purchasing" },
+ new AgentConfig { AgentId = "accounting", UnitId = "staff" },
+ new AgentConfig { AgentId = "logistics", UnitId = "staff" },
+ new AgentConfig { AgentId = "marketing", UnitId = "staff" },
+ new AgentConfig { AgentId = "sales", UnitId = "staff" }
+ ]);
+
+ public VotesTests()
+ {
+ 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);
+ _votes = new Votes(storage, _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, VoteRecord? Vote)> Stage(string agent = "buyer", string tool = "Database", string action = "insert")
+ {
+ var call = new StagedCall { RunId = "run-1", AgentId = agent, Tool = tool, Action = action, ArgumentsJson = $"{{\"action\":\"{action}\",\"amount\":150}}" };
+ var id = await _staging.AppendAsync(call, default);
+ var line = await _votes.RouteAsync(call with { Id = id }, default);
+ var vote = (await _votes.ForCallsAsync([id], default)).GetValueOrDefault(id);
+ return ((await _staging.GetAsync(id, default))!, line, vote);
+ }
+
+ private Task Vote(long vote, string voter, bool yes, string evidence = "Account balance 4,210 EUR, 2026-10-01 08:14")
+ => _votes.CastAsync(vote, voter, AgentTrust.Standard, yes, yes ? "Fits." : "Does not fit.", evidence, "run-" + voter, default);
+
+ // ═══════════════════ where it goes ═══════════════════
+
+ [Fact]
+ public async Task A_covered_call_goes_to_a_vote_with_one_task_per_voter_and_never_to_the_proposer()
+ {
+ var (call, line, vote) = await Stage();
+
+ vote.ShouldNotBeNull();
+ vote.Ballots.Select(b => b.Voter).ShouldBe(["accounting", "logistics", "marketing", "sales"], ignoreOrder: true,
+ customMessage: "the buyer proposes, so it does not vote");
+ call.Approver.ShouldBe(Votes.DecidedByPrefix + vote.Id);
+ call.WithLead(DateTime.UtcNow).ShouldBeTrue("the vote decides first; the human can still step in");
+ line!.ShouldContain("4 agents");
+
+ var ballot = (await _tasks.GetAsync(vote.Ballots[0].TaskId!, default))!;
+ ballot.Assignee.ShouldBe("@" + vote.Ballots[0].Voter);
+ ballot.Body.ShouldContain(" (await Stage(tool: "AgentEditor", action: "update_soul")).Vote.ShouldBeNull();
+
+ [Fact]
+ public async Task A_voter_that_reads_foreign_content_gets_no_ballot_and_a_vote_without_enough_voters_is_none()
+ {
+ _foreign.Add("sales");
+ (await Stage()).Vote!.Ballots.Select(b => b.Voter).ShouldNotContain("sales");
+
+ _foreign.UnionWith(["marketing", "logistics"]);
+ (await Stage()).Vote.ShouldBeNull("one voter cannot reach three yes votes — the lead or a human decides");
+ }
+
+ [Fact]
+ public async Task A_unit_holds_no_more_votes_a_day_than_its_cap()
+ {
+ for (var i = 0; i < 3; i++)
+ (await Stage()).Vote.ShouldNotBeNull();
+ (await Stage()).Vote.ShouldBeNull();
+ }
+
+ // ═══════════════════ blind ═══════════════════
+
+ [Fact]
+ public async Task A_voter_sees_the_call_never_another_ballot_and_votes_once_with_evidence()
+ {
+ var vote = (await Stage()).Vote!;
+ await Vote(vote.Id, "accounting", false);
+
+ var open = await _votes.BallotsForAsync("logistics", default);
+ open.Single().Vote.Id.ShouldBe(vote.Id);
+ (await _votes.BallotsForAsync("accounting", default)).ShouldBeEmpty("it has voted");
+
+ (await Vote(vote.Id, "accounting", true)).Message.ShouldContain("already");
+ (await Vote(vote.Id, "buy-lead", true)).Ok.ShouldBeFalse("not a voter");
+ (await Vote(vote.Id, "logistics", true, evidence: " ")).Message.ShouldContain("'evidence'");
+ (await _votes.CastAsync(vote.Id, "logistics", new AgentTrust(TrustTier.ReadsForeignContent, ["Mail"]), true, "x", "y", null, default))
+ .Ok.ShouldBeFalse("a voter that reads outside content now does not vote");
+ }
+
+ // ═══════════════════ the count ═══════════════════
+
+ [Fact]
+ public async Task Three_yes_of_four_approve_it_and_the_remaining_ballot_is_called_off()
+ {
+ var vote = (await Stage()).Vote!;
+
+ await Vote(vote.Id, "accounting", true);
+ await Vote(vote.Id, "logistics", true);
+ (await Vote(vote.Id, "marketing", true)).Message.ShouldContain("approved");
+
+ var call = (await _staging.GetAsync(vote.CallId, default))!;
+ call.Status.ShouldBe(StagingStatus.Executed);
+ call.DecidedBy.ShouldBe(Votes.DecidedByPrefix + vote.Id);
+ _executor.Calls.ShouldBe(1);
+
+ var sales = (await _votes.GetAsync(vote.Id, default))!.Ballots.Single(b => b.Voter == "sales");
+ (await _tasks.GetAsync(sales.TaskId!, default))!.Status.ShouldBe(TaskItemStatus.Canceled);
+ }
+
+ [Fact]
+ public async Task A_rejection_is_settled_as_soon_as_the_rest_cannot_change_it()
+ {
+ var vote = (await Stage()).Vote!;
+
+ await Vote(vote.Id, "accounting", false);
+ await Vote(vote.Id, "logistics", false);
+ (await Vote(vote.Id, "marketing", false)).Message.ShouldContain("rejected", customMessage: "even a yes from sales stays at one");
+
+ var call = (await _staging.GetAsync(vote.CallId, default))!;
+ call.Status.ShouldBe(StagingStatus.Rejected);
+ _executor.Calls.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task Two_of_four_go_to_a_human_with_every_ballot()
+ {
+ var vote = (await Stage()).Vote!;
+
+ await Vote(vote.Id, "accounting", true);
+ await Vote(vote.Id, "logistics", true);
+ await Vote(vote.Id, "marketing", false);
+ (await Vote(vote.Id, "sales", false)).Message.ShouldContain("a human decides");
+
+ var call = (await _staging.GetAsync(vote.CallId, default))!;
+ call.Status.ShouldBe(StagingStatus.Pending);
+ call.WithLead(DateTime.UtcNow).ShouldBeFalse("it is the human's now");
+ (await _votes.GetAsync(vote.Id, default))!.Ballots.ShouldAllBe(b => b.Evidence.Length > 0);
+ }
+
+ [Fact]
+ public async Task A_missing_vote_is_no_yes()
+ {
+ var vote = (await Stage()).Vote!;
+ await Vote(vote.Id, "accounting", true);
+ await Vote(vote.Id, "logistics", true);
+
+ (await _votes.SweepAsync(default)).ShouldBe(0, "there is still time");
+ _clock.Advance(TimeSpan.FromHours(2));
+ (await _votes.SweepAsync(default)).ShouldBe(1);
+
+ (await _votes.GetAsync(vote.Id, default))!.Outcome.ShouldBe("human:quorum", "two votes are not the three needed — nothing is waved through");
+ (await _staging.GetAsync(vote.CallId, default))!.Status.ShouldBe(StagingStatus.Pending);
+ }
+
+ [Fact]
+ public async Task A_human_who_decides_first_ends_the_vote()
+ {
+ var vote = (await Stage()).Vote!;
+ await _service.RejectAsync(vote.CallId, "webui", "Not now.", default);
+
+ (await _votes.SweepAsync(default)).ShouldBe(1);
+ var ended = (await _votes.GetAsync(vote.Id, default))!;
+ ended.Outcome.ShouldBe("superseded");
+ foreach (var b in ended.Ballots)
+ (await _tasks.GetAsync(b.TaskId!, default))!.Status.ShouldBe(TaskItemStatus.Canceled);
+ (await Vote(vote.Id, "accounting", true)).Ok.ShouldBeFalse();
+ }
+
+ private sealed class Executor : IFrozenCallExecutor
+ {
+ public int Calls { get; private set; }
+
+ public Task ExecuteApprovedCallAsync(string agentId, string tool, string argumentsJson, string runId, CancellationToken ct)
+ {
+ Calls++;
+ return Task.FromResult("1 row inserted");
+ }
+ }
+}
diff --git a/tests/ClawdDotNet.Tools.Tests/Taskboard/VoteToolTests.cs b/tests/ClawdDotNet.Tools.Tests/Taskboard/VoteToolTests.cs
new file mode 100644
index 0000000..c13be34
--- /dev/null
+++ b/tests/ClawdDotNet.Tools.Tests/Taskboard/VoteToolTests.cs
@@ -0,0 +1,79 @@
+using System.Text.Json;
+using ClawdDotNet.Core.Config;
+using ClawdDotNet.Core.Organisation;
+using ClawdDotNet.Core.Staging;
+using ClawdDotNet.Core.Storage;
+using ClawdDotNet.Core.Tasks;
+using ClawdDotNet.Core.Tools;
+using ClawdDotNet.Tools.Taskboard;
+using ClawdDotNet.Tools.Tests.FileRW;
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+
+namespace ClawdDotNet.Tools.Tests.Taskboard;
+
+/// #702: A voter's side of a vote through the Escalation tool.
+public sealed class VoteToolTests : IDisposable
+{
+ private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
+ private readonly SqliteStagingRepository _staging;
+ private readonly Votes _votes;
+ private readonly EscalationTool _tool = new();
+
+ private readonly OrgChart _org = new(
+ [
+ new UnitConfig
+ {
+ Id = "purchasing", Name = "Purchasing",
+ Votes = new UnitVotes { Operations = ["Database.insert"], Voters = ["accounting", "logistics"], ApproveAt = 2, RejectAt = 0 }
+ }
+ ],
+ [
+ new AgentConfig { AgentId = "buyer", UnitId = "purchasing" },
+ new AgentConfig { AgentId = "accounting", UnitId = "purchasing" },
+ new AgentConfig { AgentId = "logistics", UnitId = "purchasing" }
+ ]);
+
+ public VoteToolTests()
+ {
+ var storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
+ _staging = new SqliteStagingRepository(storage);
+ var board = new TaskboardService(new SqliteTaskRepository(storage), Path.Combine(_directory, "tasks"));
+ _votes = new Votes(storage, _staging, board, () => _org, _ => AgentTrust.Standard, NullLogger.Instance);
+ }
+
+ public void Dispose()
+ {
+ SqliteStorage.ReleasePooledConnections(_directory);
+ try { Directory.Delete(_directory, recursive: true); }
+ catch { /* cleanup is secondary */ }
+ }
+
+ private Task Run(string agent, object input)
+ => _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input),
+ new AgentToolContext(agent, "inst", new Dictionary(), new NullStateStore(), NullLogger.Instance, CancellationToken.None)
+ {
+ Organisation = _org,
+ Votes = _votes,
+ RunId = "run-" + agent
+ }, CancellationToken.None);
+
+ [Fact]
+ public async Task A_voter_lists_its_ballot_and_casts_it_with_evidence()
+ {
+ var call = new StagedCall { RunId = "r", AgentId = "buyer", Tool = "Database", Action = "insert", ArgumentsJson = "{\"action\":\"insert\"}" };
+ var id = await _staging.AppendAsync(call, default);
+ await _votes.RouteAsync(call with { Id = id }, default);
+ var vote = (await _votes.ForCallsAsync([id], default))[id];
+
+ (await Run("accounting", new { action = "list_ballots" })).Content.ShouldContain($"vote #{vote.Id}");
+ (await Run("accounting", new { action = "cast_vote", vote = vote.Id, choice = "maybe", reasoning = "x", evidence = "y" }))
+ .ErrorMessage!.ShouldContain("'choice'");
+
+ var cast = await Run("accounting", new { action = "cast_vote", vote = vote.Id, choice = "no", reasoning = "Over budget.", evidence = "Balance 80 EUR, ledger 2026-10-01 08:10" });
+ cast.Success.ShouldBeTrue(cast.ErrorMessage);
+ cast.Content.ShouldContain("You will not see the others' votes");
+ (await _votes.GetAsync(vote.Id, default))!.Ballots.Single(b => b.Voter == "accounting").RunId.ShouldBe("run-accounting");
+ (await Run("logistics", new { action = "list_ballots" })).Content.ShouldNotContain("Over budget", customMessage: "blind");
+ }
+}