Abstimmung: mehrere Agenten pruefen unabhaengig und stimmen blind ab (#702)

A unit can put operations to a blind vote instead of its lead. Not opinions
are counted but independent checks enforced.

- UnitConfig.votes: operations, voters, approveAt, rejectAt, quorum, hours,
  perDay - set only by a human (OrgAdministration.SetVotes).
- Core/Staging/Votes: routes a covered staged call before the lead; one task
  per voter with the frozen call and nothing else; cast_vote needs reasoning
  and evidence. Counted as soon as the result is settled; in between and
  without the quorum a human decides. The proposer does not vote, voters that
  read foreign content get no ballot, changes to agents never go to a vote.
- Escalation tool: list_ballots, cast_vote.
- Approvals view shows each vote with every ballot and the voters' costs;
  organisation view sets the rule and lists the votes held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-01 12:04:33 +02:00
co-authored by Claude Opus 5.5
parent d834e5ac38
commit a5dd503d9b
23 changed files with 1238 additions and 11 deletions
@@ -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;
@@ -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",
@@ -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",
@@ -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
@@ -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) })