source: Klonkt/src/services/guardianship/gated.js@ 88d7c8f

main
Last change on this file since 88d7c8f was 88d7c8f, checked in by Robin Genis <roboburr@…>, 6 weeks ago

Een gated voorstel bereikte de andere guardians nooit

Op de vloot nagekeken waarom YouTube-voorbeelden bij beta uit blijven: het
voorstel van sound-fabrics staat er, met een ja van een van de drie guardians,
en het is stil verlopen. Niet omdat iemand bezwaar had, maar omdat niemand
anders het ooit gezien heeft.

Het voorstel wordt geadresseerd aan de server van het kind, want die telt en
handhaaft (5.6). Maar daarmee bereikt het alleen de voorsteller en het kind. De
twee guardians op andere servers weten van niets, kunnen dus niet antwoorden, en
een drempel van twee is onhaalbaar. Elk voorstel verloopt na een dag.

De ontbrekende schakel is het doorsturen, precies wat 5.3 al doet voor een
gated follow: de server van het kind kent de gezaghebbende guardian-lijst, dus
die stuurt het voorstel door. Elke guardian bewaart een kopie die hij kan
beantwoorden, en het antwoord reist terug naar het kind, dat telt.

Changed files:
src/config/database.js

  • tabel ap_gated_reviews, de guardian-kopie (zelfde vorm als ap_follow_reviews)

src/services/guardianship/gated.js

  • de kopie-opslag: bewaren, lezen, beantwoorden, opruimen

src/services/guardianship/handshake.js

  • ward-kant: doorsturen naar de andere guardians zodra het voorstel openstaat
  • guardian-kant: de doorgestuurde kopie bewaren om te kunnen antwoorden

src/routes/guardian.js

  • gatedReviews in de dashboardstaat; POST /guardian/api/gated/:id stuurt het antwoord naar de inbox van het kind

src/assets/js/guardian.js, src/assets/css/guardian.css

src/services/i18n.js

  • de teksten in nl, en, de

test/gated-settings.test.js

  • de hele keten: voorstellen, doorsturen naar allebei de anderen, de kopie opslaan, antwoorden, en pas bij twee van drie gaat de gate open

remarks: dit forceert niets; het maakt alleen mogelijk wat de spec al bedoelde.
Twee van de drie guardians moeten nog steeds akkoord gaan, en het venster is
24 uur.

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

  • Property mode set to 100644
File size: 9.7 KB
RevLine 
[65abc85]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';
[6eab7e9]17import * as availability from './availability.js';
[65abc85]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 = { 'shaer:externalEmbeds': 'external_embeds' };
59export function featureColumn(feature) {
60 return Object.prototype.hasOwnProperty.call(FEATURES, feature) ? FEATURES[feature] : null;
61}
62
63/**
64 * Record one guardian's answer and settle if the threshold is now reached.
65 * Returns the tally state so a caller can report it.
66 */
67export function recordGatedVote(slug, feature, guardianUri, value) {
68 const column = featureColumn(feature);
69 if (!column) return { state: 'expired', error: 'unknown_feature' };
[6eab7e9]70 const all = listGuardians(slug).map((g) => g.other_uri);
71 if (!all.includes(guardianUri)) return { state: 'expired', error: 'not_a_guardian' };
72 // A vote is an answer, whatever it is a vote on (§3.6): the voter is
73 // restored first, so it always counts itself back into the set below.
74 availability.oneAnswer(guardianUri, Date.now());
75 // §3.5: the threshold runs over the AVAILABLE set. Membership is checked
76 // against the full list above: any guardian may answer, and answering is
77 // exactly what brings it back in.
78 const guardians = availability.availableSet(slug, all, Date.now());
[65abc85]79
80 // The window opens with the first answer, and a stale decision starts over:
81 // a proposal from last month should not silently count toward today's.
82 const existing = db.prepare('SELECT MIN(opened_at) AS opened FROM ap_gated_votes WHERE slug = ? AND feature = ?')
83 .get(slug, feature);
84 let openedAt = existing && existing.opened ? new Date(existing.opened).getTime() : Date.now();
85 if (Number.isNaN(openedAt) || Date.now() - openedAt >= GATED_WINDOW_MS) {
86 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
87 openedAt = Date.now();
88 }
89 db.prepare(`INSERT INTO ap_gated_votes (slug, feature, guardian_uri, value, opened_at)
90 VALUES (?,?,?,?,?)
91 ON CONFLICT(slug, feature, guardian_uri) DO UPDATE SET value = excluded.value`)
92 .run(slug, feature, guardianUri, value ? 1 : 0, new Date(openedAt).toISOString());
93
94 const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
95 .all(slug, feature);
96 const result = tallyGatedSetting(votes, guardians, Date.now() - openedAt);
97 if (result.state === 'settled') {
98 db.prepare(`UPDATE sites SET ${column} = ? WHERE slug = ?`).run(result.value ? 1 : 0, slug);
99 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
100 } else if (result.state === 'expired') {
101 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
102 }
103 return { ...result, need: thresholdFor(guardians.length), of: guardians.length };
104}
105
106/** The open decision for a feature, for showing progress ("1 of 2"). */
107export function gatedProgress(slug, feature) {
108 const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
109 .all(slug, feature);
[6eab7e9]110 // Progress over the available set (§3.5), like the tally itself.
111 const guardians = availability.availableSet(slug, listGuardians(slug).map((g) => g.other_uri), Date.now());
[65abc85]112 return { votes: votes.length, need: thresholdFor(guardians.length), of: guardians.length };
113}
114
115// ── The federated shape (§5.6) ────────────────────────────────────
116// An Offer of a shaer:GatedSetting, answered with Accept/Reject. Parsing lives
117// here so both the inbox and the outbox read it the same way.
118
119/** Read a shaer:GatedSetting object, or null when this is a different Offer. */
120export function parseGatedSetting(object) {
121 if (!object || typeof object !== 'object') return null;
122 const type = Array.isArray(object.type) ? object.type[0] : object.type;
123 if (type !== 'shaer:GatedSetting' && type !== 'GatedSetting') return null;
124 const ward = object['shaer:ward'] || object.ward;
125 const feature = object['shaer:feature'] || object.feature;
126 const value = object['shaer:value'] !== undefined ? object['shaer:value'] : object.value;
127 if (typeof ward !== 'string' || typeof feature !== 'string') return null;
128 return { ward, feature, value: value === true || value === 1 || value === 'true' };
129}
130
131/** Build the Offer a guardian sends to the ward's server. */
132export function buildGatedOffer(offerId, actor, ward, feature, value) {
133 return {
134 id: offerId,
135 type: 'Offer',
136 actor,
137 to: [ward],
138 object: {
139 type: 'shaer:GatedSetting',
140 'shaer:ward': ward,
141 'shaer:feature': feature,
142 'shaer:value': !!value,
143 },
144 };
145}
146
[88d7c8f]147// ── The guardian-side copy (the missing leg of §5.6) ──────────────
148// A proposal addressed to the ward's server reaches only the proposer and the
149// ward. The other guardians never learn it exists, so a threshold of two can
150// never be met and every proposal expires unanswered. The ward's server
151// therefore FORWARDS it, exactly as it forwards a gated follow (§5.3): each
152// guardian stores a copy it can answer, and the answer travels back to the
153// ward, which tallies.
154
155let _rs = null;
156function rstmts() {
157 if (!_rs) {
158 _rs = {
159 ins: db.prepare(`INSERT INTO ap_gated_reviews (id, guardian_slug, ward_uri, ward_inbox, proposer, feature, value)
160 VALUES (?,?,?,?,?,?,?)
161 ON CONFLICT(guardian_slug, id) DO UPDATE SET value = excluded.value, ward_inbox = excluded.ward_inbox`),
162 get: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
163 bySlug: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? ORDER BY created_at DESC'),
164 del: db.prepare('DELETE FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
165 delAll: db.prepare('DELETE FROM ap_gated_reviews WHERE id = ?'),
166 };
167 }
168 return _rs;
169}
170
171export function recordGatedReview(guardianSlug, r) {
172 rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.proposer || null, r.feature, r.value ? 1 : 0);
173 return rstmts().get.get(guardianSlug, r.id);
174}
175export function getGatedReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
176export function listGatedReviews(guardianSlug) { return rstmts().bySlug.all(guardianSlug); }
177export function removeGatedReview(guardianSlug, id) { rstmts().del.run(guardianSlug, id); }
178/** Drop every guardian's copy once the decision has settled or lapsed. */
179export function clearGatedReviews(id) { rstmts().delAll.run(id); }
180
[65abc85]181export function rememberGatedOffer(offerId, slug, feature, value) {
182 try {
183 db.prepare('INSERT OR REPLACE INTO ap_gated_offers (offer_id, slug, feature, value) VALUES (?,?,?,?)')
184 .run(offerId, slug, feature, value ? 1 : 0);
185 } catch { /* non-fatal */ }
186}
187
188export function recallGatedOffer(offerId) {
189 try { return db.prepare('SELECT * FROM ap_gated_offers WHERE offer_id = ?').get(offerId) || null; }
190 catch { return null; }
191}
192
193export default {
194 tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, GATED_WINDOW_MS,
195 parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer,
[88d7c8f]196 recordGatedReview, getGatedReview, listGatedReviews, removeGatedReview, clearGatedReviews,
[65abc85]197};
Note: See TracBrowser for help on using the repository browser.