File size: 8,028 Bytes
87bb7d6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 | /**
* Live delegate consensus. dev/constraints/delegates.js
* =============================================================================
* WHAT THIS IS FOR
*
* The consensus vote has always been the weakest part of the story to tell,
* because four simulated agencies voting in a loop looks like a loop. So this
* lets REAL PEOPLE be the delegates: they scan a code, pick an agency, and
* vote from their own phone while the maneuver is on screen.
*
* THE POINT IS NOT THE VOTING. IT IS WHAT THE VOTE CANNOT DO.
*
* A room of humans can reach unanimous YES and the maneuver still will not be
* authorised, because the constraint gate arbitrates first and a non-negotiable
* rule has no waiver path. That is the whole thesis, made physical:
*
* consensus decides WHETHER TO ACT
* constraints decide WHETHER ACTING IS PERMITTED
*
* and the second is not a vote. This module therefore never returns an
* authorisation of its own — it returns a tally, and the gate decides what the
* tally is allowed to mean.
*
* DESIGN NOTES
*
* * No database. A session is in-memory and dies with the process, because a
* demo that depends on persistence has one more thing to fail on stage.
* * No auth. Delegates are identified by an unguessable token issued at join.
* This is a demo instrument, not an election system, and it says so.
* * Simulated delegates remain available and are clearly labelled, so the demo
* works with zero phones in the room — the failure mode of "nobody scanned"
* must not be fatal.
*
* Zero dependencies. Deterministic where it can be.
* =============================================================================
*/
'use strict';
const crypto = require('crypto');
// The agencies a delegate may speak for. Postures mirror voting.js so a human
// delegate and a simulated one are answering the same question.
const AGENCIES = [
{ id: 'ISRO', label: 'ISRO', cares: ['FR-10', 'FR-09'] },
{ id: 'ESA', label: 'ESA', cares: ['FR-11', 'FR-12'] },
{ id: 'JAXA', label: 'JAXA', cares: ['FR-10', 'FR-12'] },
{ id: 'SPACEX', label: 'SpaceX', cares: ['FR-04', 'FR-09'] },
];
const VOTES = ['YES', 'NO', 'ABSTAIN'];
class DelegateSession {
constructor({ quorum = 3 } = {}) {
this.id = crypto.randomBytes(4).toString('hex');
this.opened_at = Date.now();
this.quorum = quorum;
this.delegates = new Map(); // token -> delegate
this.subject = null; // what is being voted on
this.closed = false;
}
/** Open a vote on a specific proposal. Clears any previous ballots. */
openVote(subject) {
this.subject = subject;
this.closed = false;
for (const d of this.delegates.values()) { d.vote = null; d.reason = null; d.voted_at = null; }
return this.state();
}
join({ name, agency }) {
const a = AGENCIES.find((x) => x.id === String(agency || '').toUpperCase());
const token = crypto.randomBytes(12).toString('base64url');
const d = {
token,
id: crypto.randomBytes(3).toString('hex'),
name: String(name || '').slice(0, 24) || 'Delegate',
agency: a ? a.id : AGENCIES[this.delegates.size % AGENCIES.length].id,
simulated: false,
vote: null, reason: null, voted_at: null,
joined_at: Date.now(),
};
this.delegates.set(token, d);
return d;
}
cast({ token, vote, reason }) {
const d = this.delegates.get(token);
if (!d) return { ok: false, reason: 'unknown delegate — rejoin from the code' };
if (this.closed) return { ok: false, reason: 'the vote is closed' };
const v = String(vote || '').toUpperCase();
if (!VOTES.includes(v)) return { ok: false, reason: `vote must be one of ${VOTES.join(', ')}` };
d.vote = v;
d.reason = String(reason || '').slice(0, 140) || null;
d.voted_at = Date.now();
return { ok: true, delegate: this.publicDelegate(d) };
}
/** Fill empty seats with clearly-labelled simulated delegates. */
seatSimulated(votes) {
for (const [agency, vote] of Object.entries(votes || {})) {
const token = `sim:${agency}`;
this.delegates.set(token, {
token, id: `sim-${agency.toLowerCase()}`,
name: `${agency} (simulated)`, agency,
simulated: true,
vote, reason: 'posture-driven simulated delegate',
voted_at: Date.now(), joined_at: Date.now(),
});
}
return this.state();
}
clearSimulated() {
for (const t of [...this.delegates.keys()]) {
if (t.startsWith('sim:')) this.delegates.delete(t);
}
}
publicDelegate(d) {
// never leak the token
const { token, ...rest } = d;
return rest;
}
tally() {
const rows = [...this.delegates.values()];
const counts = { YES: 0, NO: 0, ABSTAIN: 0, PENDING: 0 };
for (const d of rows) counts[d.vote || 'PENDING']++;
const cast = counts.YES + counts.NO + counts.ABSTAIN;
return {
counts,
seated: rows.length,
cast,
human: rows.filter((d) => !d.simulated).length,
quorum: this.quorum,
quorum_met: cast >= this.quorum,
// The tally's OPINION. Deliberately not called a decision — the gate
// decides, and it may refuse this outright.
consensus: counts.YES > counts.NO ? 'FAVOURS_ACTION'
: counts.NO > counts.YES ? 'OPPOSES_ACTION' : 'SPLIT',
unanimous_yes: cast > 0 && counts.YES === cast,
};
}
state() {
return {
session: this.id,
opened_at: this.opened_at,
closed: this.closed,
subject: this.subject,
agencies: AGENCIES.map((a) => ({ id: a.id, label: a.label, cares: a.cares })),
delegates: [...this.delegates.values()]
.sort((a, b) => a.joined_at - b.joined_at)
.map((d) => this.publicDelegate(d)),
tally: this.tally(),
note: 'A tally is not an authorisation. The constraint gate arbitrates, and non-negotiable rules have no waiver path.',
};
}
close() { this.closed = true; return this.state(); }
}
/**
* Combine a human tally with a constraint report.
*
* This is the function the whole feature exists for. Read the order: the gate
* is consulted FIRST, and if it refuses, the vote is recorded and overridden.
* Unanimity does not enter into it.
*/
function arbitrate(tally, report) {
const gateAuthorises = !!(report && report.authorised);
const blockers = (report && report.rules || [])
.filter((r) => r.state === 'VIOLATED' && r.waivable === false)
.map((r) => ({ id: r.id, title: r.title }));
const unknowns = (report && report.rules || [])
.filter((r) => r.state === 'UNEVALUATED')
.map((r) => ({ id: r.id, title: r.title }));
let outcome, because;
if (!gateAuthorises && blockers.length) {
outcome = 'REFUSED';
because = `${blockers.length} non-negotiable rule${blockers.length > 1 ? 's' : ''} violated — no waiver path exists`;
} else if (!gateAuthorises && unknowns.length) {
outcome = 'REFUSED';
because = `${unknowns.length} rule${unknowns.length > 1 ? 's' : ''} could not be evaluated — unknown is not a pass`;
} else if (!tally.quorum_met) {
outcome = 'PENDING';
because = `quorum not met (${tally.cast}/${tally.quorum} ballots cast)`;
} else if (tally.consensus !== 'FAVOURS_ACTION') {
outcome = 'DECLINED';
because = 'the delegates did not favour action';
} else {
outcome = 'APPROVED';
because = 'the gate permits it and the delegates favour it';
}
return {
outcome, because,
signal: report ? report.signal : null,
gate_authorises: gateAuthorises,
blockers, unknowns,
tally,
// The line that makes the point, ready for the UI to display verbatim.
overridden: outcome === 'REFUSED' && tally.consensus === 'FAVOURS_ACTION',
override_note: (outcome === 'REFUSED' && tally.unanimous_yes)
? 'Every delegate voted YES. The maneuver is still refused: a non-negotiable constraint is not subject to a vote.'
: null,
};
}
module.exports = { DelegateSession, arbitrate, AGENCIES, VOTES };
|