source: Klonkt/src/services/guardianship/gated.js@ 294207e

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

Een vaste poort zegt "altijd", niet "onbekend"

"Volgverzoeken: onbekend" stond er niet omdat we het niet wisten, maar omdat
shaer:follows geen kolom heeft: vast staat vast, dus er valt niets te bewaren.
De rij viel daardoor door naar "nooit besloten" en las eeuwig als een open
vraag. Er is geen vraag. §5.3 eist dat een Follow naar een ward langs de
guardians gaat.

gateRows geeft nu fixed mee en zet de stand van een vaste poort zelf, uit
fixedValue in de catalogus. adjustable was daar geen vervanging voor: die is
ook false bij geblokkeerd en bij nog-niet-gebouwd, dus een client die alleen dat
ziet weet niet WAAROM er geen knop is.

Dat werd urgent door de tweede volgpoort ernaast: twee rijen over volgen, waarvan
de ene een echte stand toont en de andere voor altijd "onbekend" -- dat leest als
kapot in plaats van als vastgelegd.

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

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