source: Klonkt/src/services/guardianship/gated.js@ 709dc6f

main
Last change on this file since 709dc6f was 709dc6f, checked in by Bart <bart@…>, 4 weeks ago

Twee richtingen, twee poorten: shaer:following naast shaer:follows

In de catalogus stond één rij voor twee mechanismen. Het paneel telde
listReviewsByDirection(slug, 'incoming') en zette dat getal onder
shaer:follows, dus een guardian las "3 wachtend" en wist niet of er drie
vreemden bij zijn kind wilden of dat zijn kind drie keer had gevraagd of het
iemand mocht volgen. Dat zijn niet dezelfde zorg, en sinds shaer-p729 bestaan
ze allebei echt.

Nu twee poorten, en het verschil ertussen is opzet. §5.3 EIST dat een Follow
naar een ward langs de guardians gaat: shaer:follows blijft dus fixed. Over de
andere richting zegt de FEP niets — wat je verder gated is expliciet aan de
implementatie gelaten — dus shaer:following is onze keuze, en dan hoort hij ook
echt losgelaten te kunnen worden. Verstelbaar, met gate_following als kolom en
dezelfde automatiek als de rest: onbeslist is dicht voor een ward en open voor
ieder ander. Een kind dat erin groeit hoeft niet eeuwig te blijven vragen.

Geen eigen ownFollowsAllowed(): wardGateAllowed() is er al en zegt er zelf bij
dat het één implementatie hoort te zijn. Een derde kopie zou precies de tweede
plek zijn die er anders over kan gaan denken.

De labels zijn nog aan de clients: de server geeft de feature-naam door, de
Guardian PWA en Shaer moeten er nog woorden bij kiezen.

Co-Authored-By: Claude Opus 5 <claude@…>

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