source: Klonkt/src/services/guardianship/gated.js@ 8a474ab

main
Last change on this file since 8a474ab was 8a474ab, checked in by Robin <roboburr@…>, 11 hours ago

Poorten per soort: ook bij het versturen, en film heeft er nu een

Twee gaten die naast elkaar stonden en elkaar versterkten.

gate_images en gate_music golden alleen bij het SERVEREN (shaer-qc9o). Wat een
ward niet te zien kreeg mocht hij wel plaatsen, dus het stond bij iedereen
behalve bij hemzelf. Dat is geen poort meer maar een filter op zijn eigen
scherm, en dat is niet wat een guardian dichtzet. De innamekant weigert nu met
gated_images / gated_music / gated_video, in dezelfde vorm als compose en
messages.

Weigeren en niet wegknippen: stilletjes de bijlage verwijderen publiceert een
bericht dat het kind niet geschreven heeft.

En film had helemaal geen poort (shaer-mxh2), dus de zwaarste soort van de drie
was de enige die altijd door mocht. Nu een gewone rij in de catalogus met een
eigen kolom, aan beide kanten afgedwongen: gateAttachments knipt video weg als
hij dicht staat (ook als hij als AS2-Video zonder mediaType binnenkomt) en de
outbox neemt hem niet aan. shaer:video staat in de caps, zodat de app het
vooraf weet in plaats van het bij de eerste weigering te ontdekken.

De reddingsboei gaat door alle drie heen, zoals door elke andere dichte deur.
Een hulpvraag draagt juist vaak een schermafdruk.

uploadMedia zelf blijft open, en dat is met opzet: de boei gebruikt dezelfde
poot en de server weet daar nog niet waar een upload voor bedoeld is. Een
bijlage die blijft liggen kost een bestand; een boei die vastloopt kost meer.
De weigering valt bij het innemen van de post, en daar is het wel bekend.

Getoetst in test/gates-family.test.js, naast de poorten die er al stonden.
Tegenproef: met de video-regel en de innamelus eruit vallen precies de twee
nieuwe toetsen om.

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