Space / dev /constraints /voting.js
Harp404's picture
Round 2 β€” Constraint Awareness: Completion Signal
87bb7d6
Raw History Blame Contribute Delete
11 kB
/**
* Deterministic operator voting. dev/constraints/voting.js
* =============================================================================
* WHAT THIS REPLACES
*
* Before this file, the cluster voted like this:
*
* const voteYes = conj.risk_index > 40 || Math.random() < 0.70;
*
* A coin flip. The same event could be approved and denied on consecutive runs,
* no operator could explain its own vote, and the TLA+ safety proof described a
* protocol the implementation did not actually follow.
*
* Now every vote is a pure function of the constraint report. Same event, same
* answer, every time β€” and each vote carries the rule id that drove it.
*
* HOW A NODE DECIDES
*
* 1. Report BLOCKED -> NO. A hard rule is violated; nobody authorises that.
* 2. Report UNRESOLVED -> NO. "We do not vote on what we have not evaluated."
* 3. Otherwise, check this operator's own published sensitivities.
* A rule that is VIOLATED, or WAIVED by somebody else, is a NO for an
* operator that holds it as a red line.
* 4. Otherwise YES.
*
* WHY A WAIVER DOES NOT BIND EVERY OPERATOR
*
* A waiver is one party's acceptance of a risk. It is not consent from the
* others. An operator sensitive to a waived rule still votes NO β€” which is what
* makes this a negotiation between independent parties rather than a rubber
* stamp, and is the point of having a quorum at all.
*
* Zero dependencies. Pure functions.
* =============================================================================
*/
'use strict';
const { STATE, SIGNAL } = require('./engine');
// ---------------------------------------------------------------------------
// Operator postures.
//
// Real operators do not share risk tolerances β€” SSC 8.f asks crewed-vehicle
// operators to publish theirs precisely because they differ. These are each
// node's published red lines: the rules it will not proceed past, even when the
// aggregate signal permits it.
// ---------------------------------------------------------------------------
// NAMING β€” READ THIS BEFORE QUOTING ANY OF IT
//
// These are NOT agencies casting votes. No space agency has any involvement in
// this system, and presenting one as having voted would be a false claim about
// a real organisation.
//
// They are four REVIEW PROFILES: independent stances that weight different
// rules in our own rulebook, modelled on the published risk priorities of the
// operator types named in each profile's `modelled_on` field. Their purpose is
// to test whether a decision is FRAGILE β€” if a maneuver is approved under one
// weighting and refused under another, the disagreement is the finding, and it
// is surfaced rather than averaged away.
//
// Each profile's sensitivity is declared in advance and served over the API
// alongside the rulebook, so a reviewer can check that the profile voted the
// way its published stance says it should.
const POSTURES = {
1: {
name: 'ISRO (modelled)',
modelled_on: 'Earth-observation operators with long revisit cycles (e.g. ISRO)',
stance: 'data currency and decision timeline',
sensitive_to: ['FR-10', 'FR-09'],
reason: 'operates Earth-observation assets with long revisit cycles; will not act on stale orbit determination or inside a compressed decision window',
},
2: {
name: 'ESA (modelled)',
modelled_on: 'operators requiring covariance-bearing data before accepting a Pc (e.g. ESA)',
stance: 'covariance basis and probability dilution',
sensitive_to: ['FR-11', 'FR-12'],
reason: 'requires covariance-bearing data before accepting a Pc figure; treats the dilution region as disqualifying',
},
3: {
name: 'JAXA (modelled)',
modelled_on: 'operators treating element-set currency and dilution as coupled (e.g. JAXA)',
stance: 'orbit-determination quality',
sensitive_to: ['FR-10', 'FR-12'],
reason: 'holds element-set currency and dilution as coupled indicators of whether the estimate can be trusted at all',
},
4: {
name: 'SpaceX (modelled)',
modelled_on: 'large-constellation operators where per-maneuver cost compounds (e.g. SpaceX)',
stance: 'propellant cost and execution timeline',
sensitive_to: ['FR-04', 'FR-09'],
reason: 'operates a large constellation where per-maneuver propellant compounds across thousands of assets',
},
};
/**
* Published review profiles β€” served alongside the rulebook so a vote can be
* checked against its declared stance before it is cast, not rationalised
* after.
*/
function postures() {
return Object.entries(POSTURES).map(([id, p]) => ({
node_id: Number(id), name: p.name, stance: p.stance,
sensitive_to: p.sensitive_to, reason: p.reason,
}));
}
// ---------------------------------------------------------------------------
// One node's vote
// ---------------------------------------------------------------------------
/**
* @param {number} nodeId
* @param {object} report a constraint report from engine.evaluate()
* @returns {{vote:'YES'|'NO', rule_id:string|null, reason:string, basis:string}}
*/
function nodeVote(nodeId, report) {
const posture = POSTURES[nodeId] || { name: `node ${nodeId}`, stance: 'general safety', sensitive_to: [] };
if (report.signal === SIGNAL.BLOCKED) {
const b = report.blocking[0];
return {
vote: 'NO', rule_id: b.id, basis: 'HARD_VIOLATION',
reason: `${b.id} ${b.title} β€” hard rule violated${b.waivable === false ? ' (non-negotiable)' : ''}`,
};
}
if (report.signal === SIGNAL.UNRESOLVED) {
const u = report.unevaluated[0];
return {
vote: 'NO', rule_id: u.id, basis: 'UNEVALUATED',
reason: `${u.id} ${u.title} β€” not evaluated; this operator does not vote on an unknown`,
};
}
const byId = new Map(report.rules.map((r) => [r.id, r]));
for (const id of posture.sensitive_to) {
const r = byId.get(id);
if (!r) continue;
if (r.state === STATE.VIOLATED) {
return {
vote: 'NO', rule_id: id, basis: 'OPERATOR_RED_LINE',
reason: `${id} ${r.title} β€” ${posture.name} holds this as a red line (${posture.stance})`,
};
}
if (r.state === STATE.WAIVED) {
const who = (r.waiver && r.waiver.party) || 'another operator';
return {
vote: 'NO', rule_id: id, basis: 'WAIVER_NOT_ACCEPTED',
reason: `${id} ${r.title} β€” waived by ${who}; ${posture.name} does not accept another party's exception`,
};
}
}
return {
vote: 'YES', rule_id: null, basis: report.signal === SIGNAL.COMPLETE ? 'ALL_RULES_SATISFIED' : 'NO_RED_LINE_TOUCHED',
reason: report.signal === SIGNAL.COMPLETE
? 'all applicable flight rules satisfied'
: `no rule in ${posture.name}'s published sensitivities is violated`,
};
}
// ---------------------------------------------------------------------------
// The poll
// ---------------------------------------------------------------------------
/**
* Run the go/no-go poll across the online nodes.
*
* @param {Array} onlineNodes [{ id, name, online }]
* @param {object} report
* @param {number} quorum
*/
function poll(onlineNodes, report, quorum = 3) {
const votes = (onlineNodes || []).filter((n) => n && n.online !== false).map((n) => {
const v = nodeVote(n.id, report);
return {
node_id: n.id,
node_name: n.name || (POSTURES[n.id] && POSTURES[n.id].name) || `node ${n.id}`,
vote: v.vote,
rule_id: v.rule_id,
basis: v.basis,
reason: v.reason,
};
});
const yes = votes.filter((v) => v.vote === 'YES').length;
const approved = yes >= quorum;
return {
status: approved ? 'APPROVED' : 'DENIED',
approved,
votes,
yes_count: yes,
no_count: votes.length - yes,
quorum,
// Everything a reviewer needs to reproduce this outcome without running it.
basis: {
signal: report.signal,
headline: report.headline,
deterministic: true,
note: 'Each vote is a pure function of the constraint report and the operator\'s published sensitivities. Re-running with the same inputs yields the identical result.',
},
};
}
// ---------------------------------------------------------------------------
// Emergency path
// ---------------------------------------------------------------------------
/**
* Emergency override β€” the honest version.
*
* The old implementation set status APPROVED directly, with zero real votes,
* and fabricated unanimous YES from whichever nodes happened to be online. That
* contradicted the invariant formal/AstroMesh.tla proves:
*
* APPROVED => Cardinality(votes) >= Quorum
*
* An emergency is a reason to accept known risk faster. It is not a reason to
* skip the poll, and it is not a reason to cross a non-negotiable line.
*
* So: emergency generates automatic named waivers for the *waivable* hard rules
* that are currently violated, then re-evaluates and runs a real poll. If a
* non-negotiable rule is violated, it refuses β€” no override path exists.
*
* @returns {{allowed:boolean, waivers:Array, refused_by:Array, reason:string}}
*/
function emergencyWaivers(report, { party = 'EMERGENCY', reason = 'declared emergency', ts = null } = {}) {
const nonNegotiable = report.blocking.filter((r) => r.waivable === false);
if (nonNegotiable.length) {
return {
allowed: false,
waivers: [],
refused_by: nonNegotiable.map((r) => ({ id: r.id, title: r.title, detail: r.detail })),
reason: `Emergency override refused. ${nonNegotiable.length} non-negotiable rule${nonNegotiable.length > 1 ? 's are' : ' is'} violated: `
+ nonNegotiable.map((r) => `${r.id} ${r.title}`).join('; ')
+ '. These rules have no override path β€” not for an emergency, not for the leader, not for anyone.',
};
}
// Unevaluated rules cannot be waived either. An emergency does not create evidence.
if (report.unevaluated.length) {
return {
allowed: false,
waivers: [],
refused_by: report.unevaluated.map((r) => ({ id: r.id, title: r.title, detail: r.detail })),
reason: `Emergency override refused. ${report.unevaluated.length} rule${report.unevaluated.length > 1 ? 's have' : ' has'} not been evaluated: `
+ report.unevaluated.map((r) => `${r.id} ${r.title}`).join('; ')
+ '. An emergency is not evidence β€” obtain the missing inputs first.',
};
}
const waivers = report.blocking.map((r) => ({
rule_id: r.id, party, reason: `${reason} β€” auto-waived under emergency declaration`, ts,
}));
return {
allowed: true,
waivers,
refused_by: [],
reason: waivers.length
? `Emergency declared. ${waivers.length} waivable hard rule${waivers.length > 1 ? 's' : ''} auto-waived and recorded against ${party}. Quorum is still required.`
: 'Emergency declared. No hard rules were violated; the poll proceeds normally.',
};
}
module.exports = { nodeVote, poll, postures, emergencyWaivers, POSTURES };