source: Klonkt/src/services/guardianship/offers.js@ 9da8027

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

Een handshake blijft een week open, en faalt daarna onder zijn eigen naam

§4.2 leunt erop dat het uitstel begrensd is: als het venster sluit met de
controle nog onbeslist, faalt de beslissing dicht. Alleen had een
guardianship-offer in Klonkt helemaal geen venster, dus "uitgesteld" was
"voor altijd".

Nu een week. Lang genoeg dat niemand wordt opgejaagd — er moeten een ward, een
kandidaat en alle bestaande guardians antwoorden, en dat zijn mensen — en kort
genoeg dat een vergeten aanbod niet een maand in de wachtrij van een kind staat
alsof het nog een keuze is.

Twee eindtoestanden, want het zijn twee verschillende feiten:
'expired' (niemand heeft geantwoord; zegt niets over de kandidaat) en
'unverified' (iedereen heeft geantwoord, maar de kandidaat was nooit op te
halen). Geen van beide is 'void': de partijen mag niet verteld worden dat de
kandidaat geweigerd is, want dat is niet gebeurd — er heeft alleen nooit iemand
kunnen kijken.

Vervallen gebeurt bij het lezen, zoals een lapse dat ook doet: geen sweeper die
niemand draait. En het lezen van de wachtrij is meteen het moment waarop een
uitgestelde commit opnieuw wordt geprobeerd (§4.2 SHOULD) — nodig, want de
laatste Accept kan al binnen zijn en dan port niemand er ooit nog aan. Niet
awaited: een poll toont wat nu waar is.

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

  • Property mode set to 100644
File size: 7.7 KB
RevLine 
[780a7c6]1/**
2 * Guardianship (FEP-633c §3) — the multi-party handshake state.
3 *
4 * A faithful port of the Shaer test daemon's `Handshake`, persisted per local
5 * site (so the two implementations behave identically and the clients speak
6 * one contract). One row in ap_guardian_offers per offer this instance is a
7 * party to; the accepts accumulate in ap_guardian_offer_accepts.
8 *
9 * The offer commits only when the guardian-candidate returns the handle
10 * (§3.1.3) after ward + candidate + at least one existing guardian have
11 * accepted (§3.1.2). A single Reject from any party voids it (§3.2). This is
12 * the core safety property: no single party creates a guardianship alone, and
13 * no new guardian is added without an existing guardian's consent.
14 */
15import db from '../../config/database.js';
16
17let _s = null;
18function stmts() {
19 if (!_s) {
20 _s = {
21 insOffer: db.prepare(`INSERT OR IGNORE INTO ap_guardian_offers
22 (offer_id, slug, ward_uri, candidate_uri, existing_guardians, status, ward_handle, candidate_handle, created_at)
23 VALUES (?,?,?,?,?, 'pending', ?, ?, CURRENT_TIMESTAMP)`),
24 getOffer: db.prepare('SELECT * FROM ap_guardian_offers WHERE slug=? AND offer_id=?'),
25 offerAnywhere: db.prepare('SELECT * FROM ap_guardian_offers WHERE offer_id=? LIMIT 1'),
26 setStatus: db.prepare('UPDATE ap_guardian_offers SET status=?, handle=COALESCE(?, handle) WHERE slug=? AND offer_id=?'),
27 listBySlug: db.prepare("SELECT * FROM ap_guardian_offers WHERE slug=? AND status='pending' ORDER BY created_at DESC"),
28 insAccept: db.prepare('INSERT OR IGNORE INTO ap_guardian_offer_accepts (offer_id, slug, party_uri, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)'),
29 accepts: db.prepare('SELECT party_uri FROM ap_guardian_offer_accepts WHERE slug=? AND offer_id=?'),
30 };
31 }
32 return _s;
33}
34
35const parties = (o) => [o.ward_uri, o.candidate_uri, ...JSON.parse(o.existing_guardians || '[]')];
36const isParty = (o, actor) => !!actor && parties(o).includes(actor);
37const acceptsOf = (o) => stmts().accepts.all(o.slug, o.offer_id).map((r) => r.party_uri);
38
39/** ward + candidate + (no existing guardians OR at least one existing) accepted. */
40export function readyToCommit(o) {
41 if (!o || o.status !== 'pending') return false;
42 const acc = new Set(acceptsOf(o));
43 const existing = JSON.parse(o.existing_guardians || '[]');
44 const existingOk = existing.length === 0 || existing.some((g) => acc.has(g));
45 return acc.has(o.ward_uri) && acc.has(o.candidate_uri) && existingOk;
46}
47
48/** Start tracking an offer on `slug` (idempotent). */
49export function start(slug, { offerId, ward, candidate, existingGuardians = [], wardHandle = null, candidateHandle = null }) {
50 stmts().insOffer.run(offerId, slug, ward, candidate, JSON.stringify(existingGuardians || []), wardHandle, candidateHandle);
51 return stmts().getOffer.get(slug, offerId);
52}
53
54export function getOffer(slug, offerId) { return stmts().getOffer.get(slug, offerId); }
55export function findOfferAnywhere(offerId) { return stmts().offerAnywhere.get(offerId); }
56
57/** Record an Accept from one party; ignored if not a party or already resolved. */
58export function recordAccept(slug, offerId, party) {
59 const o = stmts().getOffer.get(slug, offerId);
60 if (!o || o.status !== 'pending' || !isParty(o, party)) return o;
61 stmts().insAccept.run(offerId, slug, party);
62 return stmts().getOffer.get(slug, offerId);
63}
64
65/** A single Reject from any party voids the handshake (§3.2). */
66export function recordReject(slug, offerId, party) {
67 const o = stmts().getOffer.get(slug, offerId);
68 if (!o || o.status !== 'pending' || !isParty(o, party)) return o;
69 stmts().setStatus.run('void', null, slug, offerId);
70 return stmts().getOffer.get(slug, offerId);
71}
72
73/** Commit (only when ready): store the returned handle, mark committed. */
74export function commit(slug, offerId, handle) {
75 const o = stmts().getOffer.get(slug, offerId);
76 if (!o || o.status !== 'pending' || !readyToCommit(o)) return null;
77 stmts().setStatus.run('committed', handle || null, slug, offerId);
78 return stmts().getOffer.get(slug, offerId);
79}
80
[30d0e2c]81/**
82 * How long a guardianship handshake stays open (§3.5). Adding a guardian is a
83 * reversible decision, but not a quick one: the ward, the candidate and every
84 * existing guardian have to answer, and they are people, sometimes on holiday.
85 * A week is long enough that nobody is rushed and short enough that a forgotten
86 * offer does not sit in a child's queue for a month looking like a live choice.
87 */
88export const OFFER_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
89
90/** SQLite writes CURRENT_TIMESTAMP as UTC 'YYYY-MM-DD HH:MM:SS', which
91 * Date.parse reads as LOCAL time — hours out, and enough to expire an offer
92 * early or late. Same correction as ActivityPubService.isoStamp. */
93const stampMs = (v) => {
94 const s = String(v || '');
95 return Date.parse(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/.test(s) ? `${s.replace(' ', 'T')}Z` : s);
96};
97
98export const closesAt = (o) => stampMs(o.created_at) + OFFER_WINDOW_MS;
99
100/**
101 * §3.5 fails closed: once the window has run, a handshake that never completed
102 * is over. WHICH failure it was matters (§4.2), so the two get different
103 * terminal states and neither of them is `void`:
104 *
105 * 'expired' — the parties never all answered. Nothing to say about anyone.
106 * 'unverified' — everyone answered; the candidate could never be read, so
107 * the check never got to run. The parties MUST be told this
108 * and MUST NOT be told the candidate was refused. It was not:
109 * nobody ever managed to look.
110 */
111export function expireIfDue(slug, offerId, now = Date.now()) {
112 const o = stmts().getOffer.get(slug, offerId);
113 if (!o || o.status !== 'pending') return null;
114 const due = closesAt(o);
115 if (!Number.isFinite(due) || due > now) return null;
116 const status = readyToCommit(o) ? 'unverified' : 'expired';
117 stmts().setStatus.run(status, null, slug, offerId);
118 return { ...o, status };
119}
120
121/** Pending offers where `me` is a party — the offers queue (daemon shape).
122 * Reads are where lazy completion happens, as with the lapses (§3.6.3): a
123 * closed window is settled here rather than by a sweeper nobody runs. */
124export function listForParty(slug, me, now = Date.now()) {
125 if (!stmts().listBySlug.get) return [];
126 for (const o of stmts().listBySlug.all(slug)) expireIfDue(slug, o.offer_id, now);
127 return stmts().listBySlug.all(slug).filter((o) => isParty(o, me));
128}
129
130/** Handshakes whose tally is complete but which are not committed: the §4.2
131 * deferred set, waiting on a candidate nobody could dereference. */
132export function listDeferred(slug) {
133 if (!stmts().listBySlug.get) return [];
134 return stmts().listBySlug.all(slug).filter((o) => readyToCommit(o));
[780a7c6]135}
136
137/** One offer as the offers-queue item the Shaer clients parse. */
138export function queueItem(o, me) {
139 const acc = acceptsOf(o).sort();
140 return {
141 id: o.offer_id,
142 type: 'Offer',
143 actor: o.candidate_uri,
144 object: { type: 'Relationship', subject: o.ward_uri, relationship: 'shaer:Guardian', object: o.candidate_uri },
145 'shaer:ward': o.ward_uri,
146 'shaer:candidate': o.candidate_uri,
147 'shaer:existingGuardians': JSON.parse(o.existing_guardians || '[]'),
148 'shaer:acceptedBy': acc,
149 'shaer:needsMyAccept': !acc.includes(me),
150 'shaer:readyToCommit': readyToCommit(o),
151 'shaer:iAmCandidate': me === o.candidate_uri,
152 'shaer:wardHandle': o.ward_handle || undefined,
153 'shaer:candidateHandle': o.candidate_handle || undefined,
154 published: o.created_at,
155 };
156}
157
158export { parties, isParty, acceptsOf };
159export default {
160 start, getOffer, findOfferAnywhere, recordAccept, recordReject, commit,
161 readyToCommit, listForParty, queueItem, parties, isParty, acceptsOf,
[30d0e2c]162 OFFER_WINDOW_MS, closesAt, expireIfDue, listDeferred,
[780a7c6]163};
Note: See TracBrowser for help on using the repository browser.