source: Klonkt/src/services/guardianship/gated.js@ ea211b9

main
Last change on this file since ea211b9 was ea211b9, checked in by roboburr <roboburr@…>, 4 weeks ago

Je hoort te weten dat je de doorslag geeft (shaer-8vt)

De telling is een race naar de drempel: zodra het aantal gehaald is, is het
besluit gevallen. Bij 2 van 3 is de tweede ja meteen de beslissing -- en sinds
Barts meerderheidsbesluit van vandaag is bij een volgverzoek met twee guardians
de EERSTE ja dat al. Wie antwoordde wist dat niet, en het scherm zei het nergens.

Dat kon niet, en waarom niet was de vondst bij shaer-nf9: gatedProgress werkt op
een LOKALE slug en er zijn geen lokale wards. De telling loopt op de server van
het kind, en die stuurde hem niet mee. Nu wel: shaer:decisive reist mee met de
doorgestuurde Offer, voor gate-voorstellen en voor volgverzoeken in beide
richtingen.

EEN JA/NEE, GEEN TELLING, en dat is een besluit. Een getal ("1 van 2") reist mee,
veroudert onderweg en leest daarna als een feit; de beschikbare set schuift met
3.6 bovendien mee. En hoeveel guardians een kind heeft, en wie er al gestemd
heeft, is niet vanzelf iets dat elke mede-guardian hoort te zien. Een
waarschuwing veroudert ook, maar hij CLAIMT niets -- en dat scheelt.

BIJ TWIJFEL WAARSCHUWEN. Ontbreekt het veld (een oudere server), dan zeggen we
dat je beslist. De twee fouten zijn niet gelijk: zeggen dat je beslist terwijl
dat niet zo is maakt iemand voorzichtiger dan nodig; niets zeggen terwijl hij wel
beslist laat hem het onwetend doen. Daar staat een toets op, en de mutatie die
ertoe doet -- onbekend als "je beslist niets" lezen -- maakt hem rood.

Eerlijk over de mutatietest: mijn eerste poging (de Number.isFinite-guards
weghalen) gaf nul fouten, want die geven voor undefined dezelfde uitkomst. De
regel bijt wel tegen de realistische verkeerde versie.

Zes toetsen, drie talen. Suite 747/747.

  • Property mode set to 100644
File size: 23.4 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 'shaer:externalThreads': 'external_threads',
62 'shaer:images': 'gate_images',
63 'shaer:messages': 'gate_messages',
64 'shaer:compose': 'gate_compose',
65 'shaer:replies': 'gate_replies',
66 'shaer:music': 'gate_music',
67 'shaer:quoteCards': 'gate_quote_cards',
68 'shaer:customEmoji': 'gate_custom_emoji',
69 'shaer:accountMove': 'gate_account_move',
70};
71/**
72 * De gates die deze Klonkt kent, met hun SOORT.
73 *
74 * Wat gated wordt is een ontwerpkeuze van de implementatie: de FEP levert het
75 * mechanisme (voorstel, tally, settle) en een paar voorbeelden, niet de lijst.
76 * Deze catalogus is die lijst, op een plek. Een gate erbij hoort een regel data
77 * te zijn en geen nieuw stuk scherm.
78 *
79 * `kind` is niet decoratief. De gates verschillen in hoe ze werken en dat mag
80 * een guardian niet hoeven raden:
81 *
82 * setting een stand, aan of uit, terug te draaien
83 * perRequest geen stand maar een stroom beslissingen (5.3 volgverzoeken)
84 * handover draagt gezag OVER; onomkeerbaar zodra de ward hem gebruikt
85 *
86 * `needs` is de trap uit shaer-ahy: zien < afspelen. Je kunt niet afspelen wat
87 * je niet mag zien, dus dat tweede is pas te bewegen als het eerste openstaat.
88 */
89export const GATE_CATALOGUE = [
90 // Werkend: er is een kolom, de tally kan erover beslissen en de server dwingt
91 // hem af bij het serveren (of, voor compose/messages/move, bij het INNEMEN:
92 // wat de ward niet mag versturen wordt aan de outbox geweigerd).
93 { feature: 'shaer:externalEmbeds', kind: 'setting', reversible: true },
94 { feature: 'shaer:externalPlayback', kind: 'setting', reversible: true, needs: 'shaer:externalEmbeds' },
95 // Sinds 8-8 ("maak ze allemaal functioneel", Bart): de hele setting-familie
96 // schakelt echt. De bead-nummers blijven staan, want elk van deze heeft nog
97 // een app-kant (wat de UI toont als de poort dicht is) en die woont daar.
98 { feature: 'shaer:externalThreads', kind: 'setting', reversible: true, bead: 'shaer-9y2' },
99 { feature: 'shaer:images', kind: 'setting', reversible: true, bead: 'shaer-6p5' },
100 { feature: 'shaer:messages', kind: 'setting', reversible: true, bead: 'shaer-3ow' },
101 { feature: 'shaer:compose', kind: 'setting', reversible: true, bead: 'shaer-qgev' },
102 // MEEDOEN AAN EEN GESPREK IS OOK IETS (Bart, 8-8). Dit stond hier bewust niet:
103 // een antwoord gold als meedoen en niet als eigen podium, dus compose liet het
104 // door. Bart heeft dat teruggedraaid -- wie mag antwoorden staat los van wie
105 // mag posten, en het hoort een eigen poort te zijn die je kunt zien.
106 //
107 // Los van compose en niet eronder: je kunt willen dat een kind wel meepraat
108 // maar geen eigen podium heeft, en ook precies andersom.
109 { feature: 'shaer:replies', kind: 'setting', reversible: true, bead: 'shaer-r4c' },
110 { feature: 'shaer:music', kind: 'setting', reversible: true, bead: 'shaer-rmz' },
111 { feature: 'shaer:quoteCards', kind: 'setting', reversible: true, bead: 'shaer-mls' },
112 { feature: 'shaer:customEmoji', kind: 'setting', reversible: true, bead: 'shaer-ytw' },
113 { feature: 'shaer:accountMove', kind: 'setting', reversible: true, bead: 'shaer-tge' },
114 // Altijd aan voor een ward (5.3): niet te verzetten, wel te tonen. Een paneel
115 // dat alleen verstelbare dingen laat zien verzwijgt de helft van wat er geldt.
116 { feature: 'shaer:follows', kind: 'perRequest', reversible: true, fixed: true },
117
118 // GEPLAND, en dat is bij deze twee geen achterstand maar een besluit.
119 //
120 // publicProfile is niet een veld dat je wegfiltert: het is het hele publieke
121 // web-oppervlak van een site (de Krant, de AP-objecten, de scrape-vraag van
122 // shaer-hj0). Dat dichtzetten zonder dat ontwerp is een half slot, en een
123 // half slot leest als een heel slot -- gevaarlijker dan geen.
124 //
125 // available: false is geen detail. featureColumn() kent deze naam niet, dus
126 // een voorstel strandt op unknown_feature, en de rij leest als "hier is nog
127 // niets van", niet als een gesloten poort.
128 { feature: 'shaer:publicProfile', kind: 'setting', reversible: true, available: false, bead: 'shaer-hj0' },
129 // De enige die gezag OVERDRAAGT, en daarmee de enige die niet terug te draaien
130 // is zodra het kind hem gebruikt (shaer-90v). Telt met de lapse-vorm: volle
131 // set, volle venster. Die vorm hoort daar beslist te worden, niet hier
132 // geimproviseerd: een verkeerd gemaakte onafhankelijkheid is een kind zonder
133 // vangnet.
134 { feature: 'shaer:independence', kind: 'handover', reversible: false, available: false, bead: 'shaer-90v' },
135];
136
137/**
138 * De gates van een ward als rijen voor het paneel. Puur, zodat de regels
139 * getoetst kunnen worden zonder database of scherm.
140 *
141 * @param settings {feature: true|false|null} -- null is ONBEKEND, niet uit
142 * @param guardianCount aantal guardians, of null als we het niet weten
143 * @param proposals [{feature, value, status}] lopende voorstellen
144 * @param waiting {feature: aantal} wat er per gate op een besluit wacht
145 */
146export function gateRows({ settings = {}, guardianCount = null, proposals = [], waiting = {}, requested = {} } = {}) {
147 return GATE_CATALOGUE.map((g) => {
148 // Een stand kan drie dingen zijn: beslist-aan, beslist-uit, of de standaard
149 // omdat er nooit iets besloten is. Dat derde als "uit" tonen zou een besluit
150 // suggereren dat niemand nam.
151 const raw = Object.prototype.hasOwnProperty.call(settings, g.feature) ? settings[g.feature] : null;
152 const beslist = raw && typeof raw === 'object' ? !!raw.decided : (raw === true || raw === false);
153 const value = raw && typeof raw === 'object' ? raw.value : raw;
154 // De trap: het bovenliggende moet OPEN staan. Onbekend telt niet als dicht --
155 // bij een ward elders kennen we de stand niet, en verbergen betekende daar
156 // ooit dat een voorstel nooit geopend kon worden.
157 const bovenliggend = settings[g.needs];
158 const bovenWaarde = bovenliggend && typeof bovenliggend === 'object' ? bovenliggend.value : bovenliggend;
159 const bovenBeslist = bovenliggend && typeof bovenliggend === 'object' ? bovenliggend.decided : (bovenWaarde === true || bovenWaarde === false);
160 // Alleen dichthouden als we ZEKER weten dat het bovenliggende uit staat.
161 const blockedBy = (g.needs && bovenBeslist && bovenWaarde === false) ? g.needs : null;
162 return {
163 feature: g.feature,
164 kind: g.kind,
165 reversible: !!g.reversible,
166 value,
167 decided: beslist,
168 // Vast staat vast: tonen mag, verzetten niet.
169 // Wat er niet is, valt niet te verzetten. Een knop die op unknown_feature
170 // strandt is erger dan geen knop.
171 available: g.available !== false,
172 adjustable: g.available !== false && !g.fixed && !blockedBy,
173 blockedBy: blockedBy || undefined,
174 // Zonder bekend aantal guardians GEEN drempel verzinnen. Nul of een gok
175 // leest als een feit, en dit is precies waar een guardian op afgaat.
176 threshold: (guardianCount && guardianCount > 0)
177 ? { need: thresholdFor(guardianCount), of: guardianCount } : null,
178 proposal: proposals.find((p) => p.feature === g.feature) || undefined,
179 waiting: waiting[g.feature] || undefined,
180 // Het kind vroeg hier zelf om (shaer-8ru). Apart van `waiting`: drie
181 // onbekenden die je kind willen volgen is iets anders dan je kind dat
182 // een keer vraagt of muziek aan mag, en een gedeeld getal maakt daar
183 // hetzelfde van.
184 requested: requested[g.feature] || undefined,
185 };
186 });
187}
188
189export function featureColumn(feature) {
190 return Object.prototype.hasOwnProperty.call(FEATURES, feature) ? FEATURES[feature] : null;
191}
192
193/**
194 * Record one guardian's answer and settle if the threshold is now reached.
195 * Returns the tally state so a caller can report it.
196 */
197export function recordGatedVote(slug, feature, guardianUri, value) {
198 const column = featureColumn(feature);
199 if (!column) return { state: 'expired', error: 'unknown_feature' };
200 const all = listGuardians(slug).map((g) => g.other_uri);
201 if (!all.includes(guardianUri)) return { state: 'expired', error: 'not_a_guardian' };
202 // A vote is an answer, whatever it is a vote on (§3.6): the voter is
203 // restored first, so it always counts itself back into the set below.
204 availability.oneAnswer(guardianUri, Date.now());
205 // §3.5: the threshold runs over the AVAILABLE set. Membership is checked
206 // against the full list above: any guardian may answer, and answering is
207 // exactly what brings it back in.
208 const guardians = availability.availableSet(slug, all, Date.now());
209
210 // The window opens with the first answer, and a stale decision starts over:
211 // a proposal from last month should not silently count toward today's.
212 const existing = db.prepare('SELECT MIN(opened_at) AS opened FROM ap_gated_votes WHERE slug = ? AND feature = ?')
213 .get(slug, feature);
214 let openedAt = existing && existing.opened ? new Date(existing.opened).getTime() : Date.now();
215 if (Number.isNaN(openedAt) || Date.now() - openedAt >= GATED_WINDOW_MS) {
216 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
217 openedAt = Date.now();
218 }
219 db.prepare(`INSERT INTO ap_gated_votes (slug, feature, guardian_uri, value, opened_at)
220 VALUES (?,?,?,?,?)
221 ON CONFLICT(slug, feature, guardian_uri) DO UPDATE SET value = excluded.value`)
222 .run(slug, feature, guardianUri, value ? 1 : 0, new Date(openedAt).toISOString());
223
224 const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
225 .all(slug, feature);
226 const result = tallyGatedSetting(votes, guardians, Date.now() - openedAt);
227 if (result.state === 'settled') {
228 db.prepare(`UPDATE sites SET ${column} = ? WHERE slug = ?`).run(result.value ? 1 : 0, slug);
229 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
230 } else if (result.state === 'expired') {
231 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
232 }
233 return { ...result, need: thresholdFor(guardians.length), of: guardians.length };
234}
235
236/**
237 * Wat er blijft hangen als deze gate opengaat (shaer-nf9).
238 *
239 * BARTS ZIN KLOPT NIET LETTERLIJK, en dat is precies waarom dit hier staat. "Een
240 * geopende poort gaat niet meer dicht" is onwaar over de INSTELLING -- shaer-ahy
241 * eist het tegendeel en de code doet het: een voorstel draagt true of false. Maar
242 * het GEVOLG is wel onomkeerbaar. De poort gaat later weer dicht; wat er in de
243 * tussentijd doorheen kwam komt niet terug. Een kind dat iets gezien heeft, heeft
244 * het gezien.
245 *
246 * Dat verschil moet in de tekst, om twee redenen. Een waarschuwing die aantoonbaar
247 * onwaar is neemt de rest van het scherm mee in zijn val zodra iemand het merkt.
248 * En de ware versie is ZWAARDER: "je kunt dit terugdraaien maar niet ongedaan
249 * maken" zet je harder stil dan een verbod dat niet blijkt te kloppen.
250 *
251 * ONBEKEND KRIJGT DE ZWAARSTE TEKST. Een mede-guardian elders kan een feature
252 * voorstellen die onze catalogus niet kent, en dan weten wij niet wat het doet.
253 * Bij twijfel waarschuwen we zwaarder, niet lichter -- de faalstand die hier pijn
254 * doet is een guardian die iets doorlaat omdat het scherm er licht over deed.
255 */
256export function gateConsequence(feature) {
257 const g = GATE_CATALOGUE.find((x) => x.feature === feature);
258 if (!g) return 'unknown';
259 return g.reversible === false ? 'irreversible' : 'reversible';
260}
261
262/**
263 * Zou het antwoord van deze guardian het besluit AFMAKEN (shaer-8vt)?
264 *
265 * De telling is een race naar de drempel: zodra het aantal gehaald is, is het
266 * gevallen. Bij 2 van 3 is de tweede ja dus meteen de beslissing, en bij een
267 * volgverzoek met twee guardians is de EERSTE ja dat al. Wie antwoordt weet dat
268 * niet, en het scherm zei het nergens.
269 *
270 * EEN JA/NEE, GEEN TELLING, en dat is een besluit. Een getal ("1 van 2") reist
271 * mee, veroudert onderweg en leest daarna als een feit; de beschikbare set
272 * schuift bovendien met 3.6 mee. En hoeveel guardians een kind heeft, en wie er
273 * al gestemd heeft, is niet vanzelf iets dat elke mede-guardian hoort te zien.
274 * Een waarschuwing veroudert ook, maar hij CLAIMT niets -- en dat scheelt.
275 *
276 * BIJ TWIJFEL WAARSCHUWEN. De twee fouten zijn niet gelijk: zeggen dat je
277 * beslist terwijl dat niet zo is maakt iemand voorzichtiger dan nodig; niets
278 * zeggen terwijl hij wel beslist laat hem het onwetend doen.
279 */
280export function isDecisive(votes, need) {
281 const v = Number.isFinite(votes) ? votes : 0;
282 const n = Number.isFinite(need) ? need : 1;
283 return (n - v) <= 1;
284}
285
286/** The open decision for a feature, for showing progress ("1 of 2"). */
287export function gatedProgress(slug, feature) {
288 const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
289 .all(slug, feature);
290 // Progress over the available set (§3.5), like the tally itself.
291 const guardians = availability.availableSet(slug, listGuardians(slug).map((g) => g.other_uri), Date.now());
292 return { votes: votes.length, need: thresholdFor(guardians.length), of: guardians.length };
293}
294
295// ── The federated shape (§5.6) ────────────────────────────────────
296// An Offer of a shaer:GatedSetting, answered with Accept/Reject. Parsing lives
297// here so both the inbox and the outbox read it the same way.
298
299/** Read a shaer:GatedSetting object, or null when this is a different Offer. */
300export function parseGatedSetting(object) {
301 if (!object || typeof object !== 'object') return null;
302 const type = Array.isArray(object.type) ? object.type[0] : object.type;
303 if (type !== 'shaer:GatedSetting' && type !== 'GatedSetting') return null;
304 const ward = object['shaer:ward'] || object.ward;
305 const feature = object['shaer:feature'] || object.feature;
306 const value = object['shaer:value'] !== undefined ? object['shaer:value'] : object.value;
307 if (typeof ward !== 'string' || typeof feature !== 'string') return null;
308 return { ward, feature, value: value === true || value === 1 || value === 'true' };
309}
310
311/** Build the Offer a guardian sends to the ward's server. */
312export function buildGatedOffer(offerId, actor, ward, feature, value) {
313 return {
314 id: offerId,
315 type: 'Offer',
316 actor,
317 to: [ward],
318 object: {
319 type: 'shaer:GatedSetting',
320 'shaer:ward': ward,
321 'shaer:feature': feature,
322 'shaer:value': !!value,
323 },
324 };
325}
326
327// ── The guardian-side copy (the missing leg of §5.6) ──────────────
328// A proposal addressed to the ward's server reaches only the proposer and the
329// ward. The other guardians never learn it exists, so a threshold of two can
330// never be met and every proposal expires unanswered. The ward's server
331// therefore FORWARDS it, exactly as it forwards a gated follow (§5.3): each
332// guardian stores a copy it can answer, and the answer travels back to the
333// ward, which tallies.
334
335let _rs = null;
336function rstmts() {
337 if (!_rs) {
338 _rs = {
339 ins: db.prepare(`INSERT INTO ap_gated_reviews (id, guardian_slug, ward_uri, ward_inbox, proposer, feature, value, decisive)
340 VALUES (?,?,?,?,?,?,?,?)
341 ON CONFLICT(guardian_slug, id) DO UPDATE SET value = excluded.value, ward_inbox = excluded.ward_inbox, decisive = excluded.decisive`),
342 get: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
343 bySlug: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? ORDER BY created_at DESC'),
344 del: db.prepare('DELETE FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
345 delAll: db.prepare('DELETE FROM ap_gated_reviews WHERE id = ?'),
346 };
347 }
348 return _rs;
349}
350
351export function recordGatedReview(guardianSlug, r) {
352 // decisive ontbreekt bij een oudere server -> 1, want bij twijfel waarschuwen.
353 rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.proposer || null, r.feature, r.value ? 1 : 0, r.decisive === false ? 0 : 1);
354 return rstmts().get.get(guardianSlug, r.id);
355}
356export function getGatedReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
357export function listGatedReviews(guardianSlug) { return rstmts().bySlug.all(guardianSlug); }
358export function removeGatedReview(guardianSlug, id) { rstmts().del.run(guardianSlug, id); }
359/** Drop every guardian's copy once the decision has settled or lapsed. */
360export function clearGatedReviews(id) { rstmts().delAll.run(id); }
361
362export function rememberGatedOffer(offerId, slug, feature, value, proposer) {
363 try {
364 db.prepare('INSERT OR REPLACE INTO ap_gated_offers (offer_id, slug, feature, value, proposer) VALUES (?,?,?,?,?)')
365 .run(offerId, slug, feature, value ? 1 : 0, proposer || null);
366 } catch { /* non-fatal */ }
367}
368
369export function recallGatedOffer(offerId) {
370 try { return db.prepare('SELECT * FROM ap_gated_offers WHERE offer_id = ?').get(offerId) || null; }
371 catch { return null; }
372}
373
374// ── The proposer's own record (5.6) ───────────────────────────────
375// "Where did my proposal go?" had no answer: the status was a button caption
376// that did not survive a refresh. The ward's server tallies elsewhere, so the
377// proposer keeps its own row and the ward's server ANSWERS the Offer when the
378// decision settles: Accept when it settled on the proposed value, Reject when
379// it settled on the opposite. An open row past the window renders as expired,
380// because an expired decision settles on nothing and nobody writes home.
381
382export function recordSent(offerId, guardianSlug, wardUri, feature, value) {
383 try {
384 db.prepare(`INSERT OR REPLACE INTO ap_gated_sent (offer_id, guardian_slug, ward_uri, feature, value)
385 VALUES (?,?,?,?,?)`).run(offerId, guardianSlug, wardUri, feature, value ? 1 : 0);
386 } catch { /* non-fatal */ }
387}
388
389export function recallSent(offerId) {
390 try { return db.prepare('SELECT * FROM ap_gated_sent WHERE offer_id = ?').get(offerId) || null; }
391 catch { return null; }
392}
393
394/**
395 * De stand van een gate zoals DEZE guardian hem kent.
396 *
397 * Er zijn geen lokale accounts: elke ward woont op een andere server, dus de
398 * kolom op onze eigen sites-tabel is voor een ward altijd leeg. Wat een guardian
399 * wel heeft is de UITSLAG van besluiten -- een geaccepteerd voorstel met waarde
400 * true betekent dat de poort openging.
401 *
402 * Geeft { value, decided }:
403 * decided true we hebben een aangenomen besluit gezien; value is die waarde
404 * decided false we hebben er geen; value is de standaard voor een ward (uit)
405 *
406 * Dat verschil hoort zichtbaar te blijven. "Uit" en "voor zover wij weten uit"
407 * zijn niet hetzelfde, en het tweede is wat we meestal hebben.
408 *
409 * BEKEND GAT: dit ziet alleen onze EIGEN voorstellen. Antwoordde je op dat van
410 * een mede-guardian, dan komt de uitslag wel binnen (gated_outcome) maar wordt
411 * hij niet bewaard -- handshake.js legt alleen vast voor sent-rijen die van ons
412 * zijn. Een gate die een ander heeft geopend leest hier dus als "uit". Dat is de
413 * onveilige kant en het hoort gerepareerd te worden.
414 */
415export function knownSetting(guardianSlug, wardUri, feature) {
416 try {
417 const r = db.prepare(`SELECT value FROM ap_gated_sent
418 WHERE guardian_slug = ? AND ward_uri = ? AND feature = ? AND status = 'accepted'
419 ORDER BY created_at DESC LIMIT 1`).get(guardianSlug, wardUri, feature);
420 if (r) return { value: !!r.value, decided: true };
421 } catch { /* val terug op de standaard */ }
422 return { value: false, decided: false };
423}
424
425export function settleSent(offerId, outcome) {
426 try { db.prepare('UPDATE ap_gated_sent SET status = ? WHERE offer_id = ?').run(outcome, offerId); } catch { /* non-fatal */ }
427}
428
429/** The latest proposal per feature this guardian sent to this ward. */
430export function listSent(guardianSlug, wardUri) {
431 try {
432 return db.prepare(`SELECT * FROM ap_gated_sent WHERE guardian_slug = ? AND ward_uri = ?
433 GROUP BY feature HAVING MAX(created_at) ORDER BY created_at DESC`).all(guardianSlug, wardUri);
434 } catch { return []; }
435}
436
437/**
438 * What a sent row means on a screen. Pure, so the rule is testable: an answer
439 * wins, and silence past the window is not "still running", it is over.
440 */
441export function sentStatus(row, now) {
442 if (!row) return null;
443 if (row.status === 'accepted' || row.status === 'rejected') return row.status;
444 const opened = new Date(String(row.created_at).includes('T') ? row.created_at : `${row.created_at}Z`.replace(' ', 'T')).getTime();
445 if (Number.isFinite(opened) && now - opened >= GATED_WINDOW_MS) return 'expired';
446 return 'open';
447}
448
449export default {
450 GATE_CATALOGUE, gateRows, knownSetting,
451 tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, gateConsequence, isDecisive, GATED_WINDOW_MS,
452 parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer,
453 recordGatedReview, getGatedReview, listGatedReviews, removeGatedReview, clearGatedReviews,
454 recordSent, recallSent, settleSent, listSent, sentStatus,
455};
Note: See TracBrowser for help on using the repository browser.