source: Klonkt/src/services/guardianship/outgoing.js@ fa33214

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

FEP-633c §5.3 andersom: een ward vraagt eerst of het iemand mag volgen

Uitgaande follows gingen ongehinderd de deur uit; de guardians kregen achteraf
een bericht (1a2f206). Dat is informeren, niet gaten — de deur staat al open als
het bericht aankomt. Bead shaer-p729, ontwerp in
docs/ward-outbound-follows-design.md.

De regel: per geval goedkeuring, met twee uitzonderingen die geen gunst zijn
maar dezelfde beslissing die al genomen is. Je eigen guardian volgen is geen
vraag. En iemand die de ward al volgt DOOR DE POORT heen is door een guardian
bij naam goedgekeurd; die vraag nog eens stellen leert mensen alleen om de vraag
niet meer te lezen.

Daarvoor moet je weten wie er door de poort kwam, dus ap_followers krijgt
gate_approved, gezet bij acceptGatedFollow. Iedereen die al volgde toen die
kolom erbij kwam wordt eenmalig gegrandfatherd (Barts besluit): exact vanaf nu,
in plaats van met terugwerkende kracht wantrouwig tegen wat er al was.

Eigen tabel, want ap_pending_follows is gesleuteld met de ward als DOEL. Eigen
wachtrij (outgoingFollows), want een guardian moet "iemand wil je ward volgen"
kunnen onderscheiden van "je ward wil iemand volgen" — de AS2-test ving netjes
dat de nieuwe term aangemeld moest worden. En een tegengehouden follow reist als
derde uitkomst naar de app (state: awaiting_guardian), zodat Shaer "wacht op
toestemming" kan tonen in plaats van een tegel die er al volgend uitziet.

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

  • Property mode set to 100644
File size: 5.3 KB
Line 
1/**
2 * Guardianship (FEP-633c §5.3, the other direction) — gating a ward's OWN
3 * follows. Bead shaer-p729; the design is in docs/ward-outbound-follows-design.md,
4 * and the spec question it answers is shaer-yeo5.
5 *
6 * The inbound gate in `follows.js` decides who may follow a ward. This one
7 * decides who a ward may follow. Until now that went out unchecked: the
8 * guardians got a note afterwards (1a2f206), which is informing, not gating —
9 * the door is already open by the time the message arrives.
10 *
11 * The rule (Barts besluit): every outgoing follow waits for a guardian, EXCEPT
12 * where the target already follows the ward through the gate. A guardian
13 * already said yes to that person; asking the same question twice only teaches
14 * people to stop reading the question.
15 */
16import db from '../../config/database.js';
17
18let _s = null;
19function stmts() {
20 if (!_s) {
21 _s = {
22 ins: db.prepare(`INSERT OR IGNORE INTO ap_pending_outgoing_follows
23 (id, ward_slug, target_uri, target_inbox, target_name, target_handle, target_icon, quorum, created_at)
24 VALUES (?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
25 get: db.prepare('SELECT * FROM ap_pending_outgoing_follows WHERE id = ?'),
26 byTarget: db.prepare('SELECT * FROM ap_pending_outgoing_follows WHERE ward_slug = ? AND target_uri = ?'),
27 byWard: db.prepare("SELECT * FROM ap_pending_outgoing_follows WHERE ward_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
28 approve: db.prepare('INSERT OR IGNORE INTO ap_outgoing_follow_approvals (follow_id, guardian_uri, decision, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)'),
29 answers: db.prepare('SELECT guardian_uri, decision FROM ap_outgoing_follow_approvals WHERE follow_id = ?'),
30 setStatus: db.prepare('UPDATE ap_pending_outgoing_follows SET status = ? WHERE id = ?'),
31 del: db.prepare('DELETE FROM ap_pending_outgoing_follows WHERE id = ?'),
32 delByTarget: db.prepare('DELETE FROM ap_pending_outgoing_follows WHERE ward_slug = ? AND target_uri = ?'),
33 gateApproved: db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ? AND gate_approved = 1'),
34 };
35 }
36 return _s;
37}
38
39/**
40 * Does this target already follow the ward, with a guardian's blessing?
41 *
42 * Only a gate-approved follower counts. A follower a free actor picked up
43 * before it was ever a ward was never seen by a guardian, so following them
44 * back is a new question, not a settled one. (Rows that predate the marker are
45 * grandfathered at migration; see config/database.js.)
46 */
47export function isMutual(wardSlug, targetUri) {
48 return !!stmts().gateApproved.get(wardSlug, targetUri);
49}
50
51/** Record an outgoing follow awaiting guardian approval. */
52export function recordPending(wardSlug, f) {
53 stmts().ins.run(
54 f.id, wardSlug, f.target, f.inbox || null,
55 f.name || null, f.handle || null, f.icon || null, f.quorum || 'any',
56 );
57 return stmts().byTarget.get(wardSlug, f.target);
58}
59
60export function getPending(id) { return stmts().get.get(id); }
61export function findFor(wardSlug, targetUri) { return stmts().byTarget.get(wardSlug, targetUri); }
62
63/** Outgoing follows this ward is waiting on — the guardian's queue. */
64export function listForWard(wardSlug) { return stmts().byWard.all(wardSlug); }
65
66/**
67 * A guardian's answer. Same shape and the same quorum arithmetic as the
68 * inbound gate, so the two directions cannot drift apart in how they count:
69 * a single reject denies outright, approvals accumulate toward the quorum.
70 */
71export function decide(id, guardianUri, decision, guardiansOfWard) {
72 const follow = stmts().get.get(id);
73 if (!follow || follow.status !== 'pending') return { outcome: 'gone', follow };
74 stmts().approve.run(id, guardianUri, decision === 'reject' ? 'reject' : 'approve');
75 const rows = stmts().answers.all(id);
76 if (rows.some((r) => r.decision === 'reject')) {
77 stmts().setStatus.run('denied', id);
78 return { outcome: 'rejected', follow };
79 }
80 const approvers = new Set(rows.filter((r) => r.decision === 'approve').map((r) => r.guardian_uri));
81 const guardians = (guardiansOfWard || []).filter(Boolean);
82 const enough = follow.quorum === 'all'
83 ? guardians.length > 0 && guardians.every((g) => approvers.has(g))
84 : approvers.size >= 1; // 'any' (default)
85 if (enough) {
86 stmts().setStatus.run('approved', id);
87 return { outcome: 'approved', follow };
88 }
89 return { outcome: 'waiting', follow };
90}
91
92/** The ward changed its mind, or blocked the target: the request is gone. */
93export function withdraw(wardSlug, targetUri) { stmts().delByTarget.run(wardSlug, targetUri); }
94export function remove(id) { stmts().del.run(id); }
95
96/** One request as the queue item the Shaer clients parse, mirroring the
97 * inbound gated-follow item so a dashboard can render both side by side. */
98export function queueItem(o, me) {
99 const rows = stmts().answers.all(o.id);
100 return {
101 id: o.id,
102 type: 'Follow',
103 actor: o.ward_slug,
104 object: o.target_uri,
105 'shaer:direction': 'outgoing',
106 'shaer:target': o.target_uri,
107 'shaer:targetHandle': o.target_handle || undefined,
108 'shaer:quorum': o.quorum || 'any',
109 'shaer:approvals': rows.filter((r) => r.decision === 'approve').length,
110 'shaer:myVote': rows.some((r) => r.guardian_uri === me),
111 published: o.created_at,
112 };
113}
114
115export default {
116 isMutual, recordPending, getPending, findFor, listForWard, decide, withdraw, remove, queueItem,
117};
Note: See TracBrowser for help on using the repository browser.