source: Klonkt/src/services/guardianship/gated.js@ 3b43e4c

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

De gate-familie functioneel: acht poorten die echt schakelen (shaer-ahy.1)

Barts opdracht (8-8): "maak ze allemaal maar functioneel." Alle
setting-gates uit de catalogus hebben nu een kolom, zijn voorstelbaar en
beslisbaar via de bestaande tally, en worden ECHT afgedwongen -- bij de
aflevering (wat dicht is wordt nooit geserialiseerd, de regel die de
embeds al hadden) of bij de inname (wat de ward niet mag versturen
weigert de outbox met een eerlijke 403).

externalThreads de thread-kring (shaer-9y2): dicht is de kring van de

guardians met telling, open is alles; per verzoek en
buiten de threadcache om, want een poort die net
dichtging mag niet twee minuten open nawerken

images/music bijlagen gefilterd op mediaType, ook in de thread
quoteCards shaer:quote niet geserialiseerd
customEmoji Emoji-tags en byline-emoji niet geserialiseerd; de

:shortcode: blijft als tekst staan, dat is eerlijk

messages de berichten-poot dicht voor vreemden en vrienden,

maar NOOIT voor het guardian-kanaal, en de outbox
weigert directe berichten -- behalve de reddingsboei:
een poort die het hulpkanaal afsnijdt beschermt
niemand

compose de outbox weigert eigen posts; een antwoord valt

onder het gesprek, niet onder een eigen podium

accountMove de harde weigering van shaer-tge is een gate

geworden met dezelfde standaard: guardians kunnen
hem nu openzetten

De capabilities dragen de hele familie, zodat de app VOORAF weet wat hij
mag aanbieden -- de (+) kaart leest shaer:compose al. De voorstelroute
herschreef een onbekende feature stilletjes naar externalEmbeds; dat is
nu een 400, want een voorstel dat op de verkeerde poort landt mag een
guardian nooit overkomen. Het paneel leest de standen catalogusbreed.

TWEE blijven bewust gepland. publicProfile is niet een veld dat je
wegfiltert maar het hele publieke web-oppervlak (de ontwerpvraag van
shaer-hj0); een half slot leest als een heel slot en dat is gevaarlijker
dan geen. independence draagt gezag over een kind over en zijn vorm
hoort bij shaer-90v beslist te worden, niet hier geimproviseerd.

Zestien nieuwe/aangepaste toetsen, alle 640 groen.

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

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