source: Klonkt/src/services/guardianship/gated.js@ 72d8eff

main
Last change on this file since 72d8eff was 72d8eff, checked in by roboburr <roboburr@…>, 5 weeks ago

De gate-stand komt uit de besluiten, want er zijn geen lokale accounts

Barts correctie, en hij raakt twee dingen die ik fout had.

ER ZIJN GEEN LOKALE ACCOUNTS. wardGateSetting() leest de kolom alleen als de ward
op onze eigen base staat, en met een Klonkt per gebruiker woont elke ward elders.
Die functie gaf dus voor IEDERE ward null, en het gate-paneel toonde overal
"onbekend". Ik noemde dat een randgeval terwijl het het enige geval is.

EN DE GUARDIAN WEET HET WEL: hij kreeg de uitslag van het besluit door.
ap_gated_sent bewaart per voorstel de feature, de waarde en de uitkomst, dus een
geaccepteerd voorstel met waarde true IS de stand. knownSetting() leest dat.

Drie standen in plaats van twee. "Uit" en "voor zover wij weten uit" zijn niet
hetzelfde: dat tweede betekent dat niemand er ooit over besloot, en dat hoort
niet te lezen als een genomen besluit. De rij draagt nu decided en het scherm
zegt "uit (nog niets over besloten)".

Daarmee vervalt ook het gat dat ik een commit eerder noteerde: de richting van de
voorstelknop volgt nu een BEKENDE stand, dus voor een ward elders kun je ook
DICHTZETTEN voorstellen. Dat was precies de veilige richting die ontbrak.

De trap blokkeert alleen op een BESLOTEN dicht, niet op de standaard. Anders kan
afspelen nooit als eerste voorgesteld worden, en zo ging er ooit een hele
voorstelronde de verkeerde gate in.

BEKEND GAT, in het commentaar bij knownSetting gezet en niet hier opgelost: dit
ziet alleen onze EIGEN voorstellen. Antwoordde je op dat van een mede-guardian,
dan komt de uitslag wel binnen (gated_outcome) maar wordt hij niet bewaard --
handshake.js legt alleen vast voor sent-rijen die van ons zijn. Een gate die een
ander heeft geopend leest hier dus als uit, en dat is de onveilige kant.

4 tests erbij, waaronder de terugval op de oude kale-boolean vorm. Suite 599/599.

  • Property mode set to 100644
File size: 17.1 KB
Line 
1/**
2 * Guardianship (FEP-633c §5.6): gated settings the guardians decide together.
3 *
4 * The point of this file is that it works when the guardians are NOT on the
5 * ward's server, which is the ordinary case: a child on the family instance, a
6 * grandparent on theirs. A guardian proposes with an `Offer` of a
7 * `shaer:GatedSetting` addressed to the ward's server; the other guardians
8 * answer; the ward's server tallies and enforces, because it is the one that
9 * serves the feed.
10 *
11 * The tally is a §3.5 decision: a snapshotted set, a threshold (strict
12 * majority), a window. A setting is reversible (a permission granted can be
13 * withdrawn), so it settles as a race to the threshold and fails closed.
14 */
15import db from '../../config/database.js';
16import { listGuardians } from './relations.js';
17import * as availability from './availability.js';
18
19/** The window a gated-setting decision stays open. Reversible, so a day. */
20export const GATED_WINDOW_MS = 24 * 60 * 60 * 1000;
21
22/** Strict majority of the set: 1 of 1, 2 of 2, 2 of 3, 3 of 4. */
23export function thresholdFor(setSize) {
24 return Math.floor(setSize / 2) + 1;
25}
26
27/**
28 * Tally one decision. Pure, so the rule can be tested without a database.
29 *
30 * @param {Array<{guardian_uri: string, value: number|boolean}>} votes
31 * @param {string[]} guardianSet the guardians at the moment the decision opened
32 * @param {number} ageMs how long the decision has been open
33 * @returns {{state: 'settled'|'open'|'expired', value?: boolean}}
34 */
35export function tallyGatedSetting(votes, guardianSet, ageMs, windowMs = GATED_WINDOW_MS) {
36 const set = new Set((guardianSet || []).filter(Boolean));
37 if (!set.size) return { state: 'expired' }; // nobody may decide
38 const need = thresholdFor(set.size);
39 // Only answers from the snapshotted set count, one per guardian.
40 const seen = new Map();
41 for (const v of (votes || [])) {
42 if (!set.has(v.guardian_uri)) continue;
43 seen.set(v.guardian_uri, v.value === true || v.value === 1);
44 }
45 const yes = [...seen.values()].filter(Boolean).length;
46 const no = seen.size - yes;
47 // Race to the threshold, in both directions: settle the moment it is reached,
48 // and give up the moment it can no longer be reached.
49 if (yes >= need) return { state: 'settled', value: true };
50 if (no >= need) return { state: 'settled', value: false };
51 const undecided = set.size - seen.size;
52 if (yes + undecided < need && no + undecided < need) return { state: 'expired' };
53 if (ageMs >= windowMs) return { state: 'expired' }; // fails closed
54 return { state: 'open' };
55}
56
57/** The column a feature maps onto. Unknown features are refused, not guessed. */
58const FEATURES = {
59 'shaer:externalEmbeds': 'external_embeds',
60 'shaer:externalPlayback': 'external_playback',
61};
62/**
63 * De gates die deze Klonkt kent, met hun SOORT.
64 *
65 * Wat gated wordt is een ontwerpkeuze van de implementatie: de FEP levert het
66 * mechanisme (voorstel, tally, settle) en een paar voorbeelden, niet de lijst.
67 * Deze catalogus is die lijst, op een plek. Een gate erbij hoort een regel data
68 * te zijn en geen nieuw stuk scherm.
69 *
70 * `kind` is niet decoratief. De gates verschillen in hoe ze werken en dat mag
71 * een guardian niet hoeven raden:
72 *
73 * setting een stand, aan of uit, terug te draaien
74 * perRequest geen stand maar een stroom beslissingen (5.3 volgverzoeken)
75 * handover draagt gezag OVER; onomkeerbaar zodra de ward hem gebruikt
76 *
77 * `needs` is de trap uit shaer-ahy: zien < afspelen. Je kunt niet afspelen wat
78 * je niet mag zien, dus dat tweede is pas te bewegen als het eerste openstaat.
79 */
80export const GATE_CATALOGUE = [
81 { feature: 'shaer:externalEmbeds', kind: 'setting', reversible: true },
82 { feature: 'shaer:externalPlayback', kind: 'setting', reversible: true, needs: 'shaer:externalEmbeds' },
83 // Altijd aan voor een ward (5.3): niet te verzetten, wel te tonen. Een paneel
84 // dat alleen verstelbare dingen laat zien verzwijgt de helft van wat er geldt.
85 { feature: 'shaer:follows', kind: 'perRequest', reversible: true, fixed: true },
86];
87
88/**
89 * De gates van een ward als rijen voor het paneel. Puur, zodat de regels
90 * getoetst kunnen worden zonder database of scherm.
91 *
92 * @param settings {feature: true|false|null} -- null is ONBEKEND, niet uit
93 * @param guardianCount aantal guardians, of null als we het niet weten
94 * @param proposals [{feature, value, status}] lopende voorstellen
95 * @param waiting {feature: aantal} wat er per gate op een besluit wacht
96 */
97export function gateRows({ settings = {}, guardianCount = null, proposals = [], waiting = {} } = {}) {
98 return GATE_CATALOGUE.map((g) => {
99 // Een stand kan drie dingen zijn: beslist-aan, beslist-uit, of de standaard
100 // omdat er nooit iets besloten is. Dat derde als "uit" tonen zou een besluit
101 // suggereren dat niemand nam.
102 const raw = Object.prototype.hasOwnProperty.call(settings, g.feature) ? settings[g.feature] : null;
103 const beslist = raw && typeof raw === 'object' ? !!raw.decided : (raw === true || raw === false);
104 const value = raw && typeof raw === 'object' ? raw.value : raw;
105 // De trap: het bovenliggende moet OPEN staan. Onbekend telt niet als dicht --
106 // bij een ward elders kennen we de stand niet, en verbergen betekende daar
107 // ooit dat een voorstel nooit geopend kon worden.
108 const bovenliggend = settings[g.needs];
109 const bovenWaarde = bovenliggend && typeof bovenliggend === 'object' ? bovenliggend.value : bovenliggend;
110 const bovenBeslist = bovenliggend && typeof bovenliggend === 'object' ? bovenliggend.decided : (bovenWaarde === true || bovenWaarde === false);
111 // Alleen dichthouden als we ZEKER weten dat het bovenliggende uit staat.
112 const blockedBy = (g.needs && bovenBeslist && bovenWaarde === false) ? g.needs : null;
113 return {
114 feature: g.feature,
115 kind: g.kind,
116 reversible: !!g.reversible,
117 value,
118 decided: beslist,
119 // Vast staat vast: tonen mag, verzetten niet.
120 adjustable: !g.fixed && !blockedBy,
121 blockedBy: blockedBy || undefined,
122 // Zonder bekend aantal guardians GEEN drempel verzinnen. Nul of een gok
123 // leest als een feit, en dit is precies waar een guardian op afgaat.
124 threshold: (guardianCount && guardianCount > 0)
125 ? { need: thresholdFor(guardianCount), of: guardianCount } : null,
126 proposal: proposals.find((p) => p.feature === g.feature) || undefined,
127 waiting: waiting[g.feature] || undefined,
128 };
129 });
130}
131
132export function featureColumn(feature) {
133 return Object.prototype.hasOwnProperty.call(FEATURES, feature) ? FEATURES[feature] : null;
134}
135
136/**
137 * Record one guardian's answer and settle if the threshold is now reached.
138 * Returns the tally state so a caller can report it.
139 */
140export function recordGatedVote(slug, feature, guardianUri, value) {
141 const column = featureColumn(feature);
142 if (!column) return { state: 'expired', error: 'unknown_feature' };
143 const all = listGuardians(slug).map((g) => g.other_uri);
144 if (!all.includes(guardianUri)) return { state: 'expired', error: 'not_a_guardian' };
145 // A vote is an answer, whatever it is a vote on (§3.6): the voter is
146 // restored first, so it always counts itself back into the set below.
147 availability.oneAnswer(guardianUri, Date.now());
148 // §3.5: the threshold runs over the AVAILABLE set. Membership is checked
149 // against the full list above: any guardian may answer, and answering is
150 // exactly what brings it back in.
151 const guardians = availability.availableSet(slug, all, Date.now());
152
153 // The window opens with the first answer, and a stale decision starts over:
154 // a proposal from last month should not silently count toward today's.
155 const existing = db.prepare('SELECT MIN(opened_at) AS opened FROM ap_gated_votes WHERE slug = ? AND feature = ?')
156 .get(slug, feature);
157 let openedAt = existing && existing.opened ? new Date(existing.opened).getTime() : Date.now();
158 if (Number.isNaN(openedAt) || Date.now() - openedAt >= GATED_WINDOW_MS) {
159 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
160 openedAt = Date.now();
161 }
162 db.prepare(`INSERT INTO ap_gated_votes (slug, feature, guardian_uri, value, opened_at)
163 VALUES (?,?,?,?,?)
164 ON CONFLICT(slug, feature, guardian_uri) DO UPDATE SET value = excluded.value`)
165 .run(slug, feature, guardianUri, value ? 1 : 0, new Date(openedAt).toISOString());
166
167 const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
168 .all(slug, feature);
169 const result = tallyGatedSetting(votes, guardians, Date.now() - openedAt);
170 if (result.state === 'settled') {
171 db.prepare(`UPDATE sites SET ${column} = ? WHERE slug = ?`).run(result.value ? 1 : 0, slug);
172 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
173 } else if (result.state === 'expired') {
174 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
175 }
176 return { ...result, need: thresholdFor(guardians.length), of: guardians.length };
177}
178
179/** The open decision for a feature, for showing progress ("1 of 2"). */
180export function gatedProgress(slug, feature) {
181 const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
182 .all(slug, feature);
183 // Progress over the available set (§3.5), like the tally itself.
184 const guardians = availability.availableSet(slug, listGuardians(slug).map((g) => g.other_uri), Date.now());
185 return { votes: votes.length, need: thresholdFor(guardians.length), of: guardians.length };
186}
187
188// ── The federated shape (§5.6) ────────────────────────────────────
189// An Offer of a shaer:GatedSetting, answered with Accept/Reject. Parsing lives
190// here so both the inbox and the outbox read it the same way.
191
192/** Read a shaer:GatedSetting object, or null when this is a different Offer. */
193export function parseGatedSetting(object) {
194 if (!object || typeof object !== 'object') return null;
195 const type = Array.isArray(object.type) ? object.type[0] : object.type;
196 if (type !== 'shaer:GatedSetting' && type !== 'GatedSetting') return null;
197 const ward = object['shaer:ward'] || object.ward;
198 const feature = object['shaer:feature'] || object.feature;
199 const value = object['shaer:value'] !== undefined ? object['shaer:value'] : object.value;
200 if (typeof ward !== 'string' || typeof feature !== 'string') return null;
201 return { ward, feature, value: value === true || value === 1 || value === 'true' };
202}
203
204/** Build the Offer a guardian sends to the ward's server. */
205export function buildGatedOffer(offerId, actor, ward, feature, value) {
206 return {
207 id: offerId,
208 type: 'Offer',
209 actor,
210 to: [ward],
211 object: {
212 type: 'shaer:GatedSetting',
213 'shaer:ward': ward,
214 'shaer:feature': feature,
215 'shaer:value': !!value,
216 },
217 };
218}
219
220// ── The guardian-side copy (the missing leg of §5.6) ──────────────
221// A proposal addressed to the ward's server reaches only the proposer and the
222// ward. The other guardians never learn it exists, so a threshold of two can
223// never be met and every proposal expires unanswered. The ward's server
224// therefore FORWARDS it, exactly as it forwards a gated follow (§5.3): each
225// guardian stores a copy it can answer, and the answer travels back to the
226// ward, which tallies.
227
228let _rs = null;
229function rstmts() {
230 if (!_rs) {
231 _rs = {
232 ins: db.prepare(`INSERT INTO ap_gated_reviews (id, guardian_slug, ward_uri, ward_inbox, proposer, feature, value)
233 VALUES (?,?,?,?,?,?,?)
234 ON CONFLICT(guardian_slug, id) DO UPDATE SET value = excluded.value, ward_inbox = excluded.ward_inbox`),
235 get: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
236 bySlug: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? ORDER BY created_at DESC'),
237 del: db.prepare('DELETE FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
238 delAll: db.prepare('DELETE FROM ap_gated_reviews WHERE id = ?'),
239 };
240 }
241 return _rs;
242}
243
244export function recordGatedReview(guardianSlug, r) {
245 rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.proposer || null, r.feature, r.value ? 1 : 0);
246 return rstmts().get.get(guardianSlug, r.id);
247}
248export function getGatedReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
249export function listGatedReviews(guardianSlug) { return rstmts().bySlug.all(guardianSlug); }
250export function removeGatedReview(guardianSlug, id) { rstmts().del.run(guardianSlug, id); }
251/** Drop every guardian's copy once the decision has settled or lapsed. */
252export function clearGatedReviews(id) { rstmts().delAll.run(id); }
253
254export function rememberGatedOffer(offerId, slug, feature, value, proposer) {
255 try {
256 db.prepare('INSERT OR REPLACE INTO ap_gated_offers (offer_id, slug, feature, value, proposer) VALUES (?,?,?,?,?)')
257 .run(offerId, slug, feature, value ? 1 : 0, proposer || null);
258 } catch { /* non-fatal */ }
259}
260
261export function recallGatedOffer(offerId) {
262 try { return db.prepare('SELECT * FROM ap_gated_offers WHERE offer_id = ?').get(offerId) || null; }
263 catch { return null; }
264}
265
266// ── The proposer's own record (5.6) ───────────────────────────────
267// "Where did my proposal go?" had no answer: the status was a button caption
268// that did not survive a refresh. The ward's server tallies elsewhere, so the
269// proposer keeps its own row and the ward's server ANSWERS the Offer when the
270// decision settles: Accept when it settled on the proposed value, Reject when
271// it settled on the opposite. An open row past the window renders as expired,
272// because an expired decision settles on nothing and nobody writes home.
273
274export function recordSent(offerId, guardianSlug, wardUri, feature, value) {
275 try {
276 db.prepare(`INSERT OR REPLACE INTO ap_gated_sent (offer_id, guardian_slug, ward_uri, feature, value)
277 VALUES (?,?,?,?,?)`).run(offerId, guardianSlug, wardUri, feature, value ? 1 : 0);
278 } catch { /* non-fatal */ }
279}
280
281export function recallSent(offerId) {
282 try { return db.prepare('SELECT * FROM ap_gated_sent WHERE offer_id = ?').get(offerId) || null; }
283 catch { return null; }
284}
285
286/**
287 * De stand van een gate zoals DEZE guardian hem kent.
288 *
289 * Er zijn geen lokale accounts: elke ward woont op een andere server, dus de
290 * kolom op onze eigen sites-tabel is voor een ward altijd leeg. Wat een guardian
291 * wel heeft is de UITSLAG van besluiten -- een geaccepteerd voorstel met waarde
292 * true betekent dat de poort openging.
293 *
294 * Geeft { value, decided }:
295 * decided true we hebben een aangenomen besluit gezien; value is die waarde
296 * decided false we hebben er geen; value is de standaard voor een ward (uit)
297 *
298 * Dat verschil hoort zichtbaar te blijven. "Uit" en "voor zover wij weten uit"
299 * zijn niet hetzelfde, en het tweede is wat we meestal hebben.
300 *
301 * BEKEND GAT: dit ziet alleen onze EIGEN voorstellen. Antwoordde je op dat van
302 * een mede-guardian, dan komt de uitslag wel binnen (gated_outcome) maar wordt
303 * hij niet bewaard -- handshake.js legt alleen vast voor sent-rijen die van ons
304 * zijn. Een gate die een ander heeft geopend leest hier dus als "uit". Dat is de
305 * onveilige kant en het hoort gerepareerd te worden.
306 */
307export function knownSetting(guardianSlug, wardUri, feature) {
308 try {
309 const r = db.prepare(`SELECT value FROM ap_gated_sent
310 WHERE guardian_slug = ? AND ward_uri = ? AND feature = ? AND status = 'accepted'
311 ORDER BY created_at DESC LIMIT 1`).get(guardianSlug, wardUri, feature);
312 if (r) return { value: !!r.value, decided: true };
313 } catch { /* val terug op de standaard */ }
314 return { value: false, decided: false };
315}
316
317export function settleSent(offerId, outcome) {
318 try { db.prepare('UPDATE ap_gated_sent SET status = ? WHERE offer_id = ?').run(outcome, offerId); } catch { /* non-fatal */ }
319}
320
321/** The latest proposal per feature this guardian sent to this ward. */
322export function listSent(guardianSlug, wardUri) {
323 try {
324 return db.prepare(`SELECT * FROM ap_gated_sent WHERE guardian_slug = ? AND ward_uri = ?
325 GROUP BY feature HAVING MAX(created_at) ORDER BY created_at DESC`).all(guardianSlug, wardUri);
326 } catch { return []; }
327}
328
329/**
330 * What a sent row means on a screen. Pure, so the rule is testable: an answer
331 * wins, and silence past the window is not "still running", it is over.
332 */
333export function sentStatus(row, now) {
334 if (!row) return null;
335 if (row.status === 'accepted' || row.status === 'rejected') return row.status;
336 const opened = new Date(String(row.created_at).includes('T') ? row.created_at : `${row.created_at}Z`.replace(' ', 'T')).getTime();
337 if (Number.isFinite(opened) && now - opened >= GATED_WINDOW_MS) return 'expired';
338 return 'open';
339}
340
341export default {
342 GATE_CATALOGUE, gateRows, knownSetting,
343 tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, GATED_WINDOW_MS,
344 parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer,
345 recordGatedReview, getGatedReview, listGatedReviews, removeGatedReview, clearGatedReviews,
346 recordSent, recallSent, settleSent, listSent, sentStatus,
347};
Note: See TracBrowser for help on using the repository browser.