| [6eab7e9] | 1 | /**
|
|---|
| 2 | * Guardian availability (FEP-633c §3.6): away, dormant, and the lapse.
|
|---|
| 3 | *
|
|---|
| 4 | * The port of the Shaer test daemon's availability.rs, validated there first
|
|---|
| 5 | * (shaer-8z7): same states, same rules, same refusals. Guardianship demands
|
|---|
| 6 | * attention; `shaer:guardians` is a public claim about safety, and a guardian
|
|---|
| 7 | * who no longer answers makes it untrue. It also quietly breaks the §3.5
|
|---|
| 8 | * arithmetic: a majority of a set with absent members can be unreachable.
|
|---|
| 9 | *
|
|---|
| 10 | * Three states per (ward, guardian), and one rule above everything else:
|
|---|
| 11 | * ONE ANSWER RESTORES EVERYTHING, at any moment up to and including a
|
|---|
| 12 | * running lapse. Neither away nor dormant is misconduct; neither leaves a
|
|---|
| 13 | * mark.
|
|---|
| 14 | *
|
|---|
| 15 | * Time is always a parameter here, never read from a clock inside the rules,
|
|---|
| 16 | * so a fourteen-day window is a number in a test and not a wait.
|
|---|
| 17 | */
|
|---|
| 18 | import db from '../../config/database.js';
|
|---|
| 19 | import { listGuardians, removeRelation } from './relations.js';
|
|---|
| 20 |
|
|---|
| 21 | /** Deployment numbers (§3.6.2 keeps them out of the spec on purpose: any
|
|---|
| 22 | * number written there would punish exactly the long-term ill). Matched to
|
|---|
| 23 | * the daemon's defaults so the two backends behave the same under test. */
|
|---|
| 24 | export const POLICY = {
|
|---|
| 25 | requestTtlMs: 7 * 24 * 3600 * 1000, // how long a request may sit unanswered
|
|---|
| 26 | missesForDormant: 3, // how many missed requests make dormant
|
|---|
| 27 | };
|
|---|
| 28 |
|
|---|
| 29 | /** The lapse window. Irreversible per §3.5, so it always runs in full. */
|
|---|
| 30 | export const LAPSE_WINDOW_MS = 14 * 24 * 3600 * 1000;
|
|---|
| 31 |
|
|---|
| 32 | /** Marker detection: the away declaration rides a direct note (§2.4). */
|
|---|
| 33 | export function isAway(object) {
|
|---|
| 34 | return !!object && (object['shaer:away'] === true || object.away === true);
|
|---|
| 35 | }
|
|---|
| 36 |
|
|---|
| 37 | /** AS2 endTime → epoch ms. A number passes through; a string goes through
|
|---|
| 38 | * Date.parse (which reads ISO 8601, offsets included). null when absent or
|
|---|
| 39 | * unreadable: an absence without an end is refused, never guessed. */
|
|---|
| 40 | export function parseEndTime(v) {
|
|---|
| 41 | if (typeof v === 'number' && Number.isFinite(v)) return v;
|
|---|
| 42 | if (typeof v !== 'string' || !v.trim()) return null;
|
|---|
| 43 | const t = Date.parse(v);
|
|---|
| 44 | return Number.isNaN(t) ? null : t;
|
|---|
| 45 | }
|
|---|
| 46 |
|
|---|
| 47 | // ── Attention (the per-guardian state) ─────────────────────────────────────
|
|---|
| 48 |
|
|---|
| 49 | function attentionRow(wardSlug, guardianUri) {
|
|---|
| 50 | return db.prepare('SELECT * FROM ap_guardian_attention WHERE ward_slug = ? AND guardian_uri = ?')
|
|---|
| 51 | .get(wardSlug, guardianUri) || { ward_slug: wardSlug, guardian_uri: guardianUri, state: 'active', away_until: null };
|
|---|
| 52 | }
|
|---|
| 53 |
|
|---|
| 54 | /** What the stored state means at `now`: an away past its end is simply
|
|---|
| 55 | * active again, silently (§3.6.1). */
|
|---|
| 56 | export function effective(wardSlug, guardianUri, now) {
|
|---|
| 57 | const row = attentionRow(wardSlug, guardianUri);
|
|---|
| 58 | if (row.state === 'away') return (row.away_until && now < row.away_until) ? 'away' : 'active';
|
|---|
| 59 | return row.state;
|
|---|
| 60 | }
|
|---|
| 61 |
|
|---|
| 62 | /** The away end, when there is a running one (for display). */
|
|---|
| 63 | export function awayUntil(wardSlug, guardianUri, now) {
|
|---|
| 64 | const row = attentionRow(wardSlug, guardianUri);
|
|---|
| 65 | return (row.state === 'away' && row.away_until && now < row.away_until) ? row.away_until : null;
|
|---|
| 66 | }
|
|---|
| 67 |
|
|---|
| 68 | /** Declare absence with an end (§3.6.1). The declaration is itself an
|
|---|
| 69 | * answer, so it first restores: declaring away while dormant clears the
|
|---|
| 70 | * dormancy, without a mark. Declaring away is the responsible act. */
|
|---|
| 71 | export function declareAway(wardSlug, guardianUri, untilMs) {
|
|---|
| 72 | db.prepare('DELETE FROM ap_attention_requests WHERE ward_slug = ? AND guardian_uri = ?').run(wardSlug, guardianUri);
|
|---|
| 73 | db.prepare(`INSERT INTO ap_guardian_attention (ward_slug, guardian_uri, state, away_until)
|
|---|
| 74 | VALUES (?,?, 'away', ?)
|
|---|
| 75 | ON CONFLICT(ward_slug, guardian_uri) DO UPDATE SET state = 'away', away_until = excluded.away_until`)
|
|---|
| 76 | .run(wardSlug, guardianUri, untilMs);
|
|---|
| 77 | }
|
|---|
| 78 |
|
|---|
| 79 | /** A directly addressed request went out to this guardian (a §3.5 decision
|
|---|
| 80 | * naming them, or an explicit check-in). Requests during a declared absence
|
|---|
| 81 | * are not recorded: away MUST NOT count as evidence (§3.6.1). */
|
|---|
| 82 | export function recordRequest(wardSlug, guardianUri, requestId, now) {
|
|---|
| 83 | if (effective(wardSlug, guardianUri, now) === 'away') return;
|
|---|
| 84 | db.prepare(`INSERT OR IGNORE INTO ap_attention_requests (ward_slug, guardian_uri, request_id, asked_at)
|
|---|
| 85 | VALUES (?,?,?,?)`).run(wardSlug, guardianUri, requestId, now);
|
|---|
| 86 | }
|
|---|
| 87 |
|
|---|
| 88 | /** Missed requests: unanswered ones older than the policy TTL. */
|
|---|
| 89 | export function misses(wardSlug, guardianUri, now) {
|
|---|
| 90 | const r = db.prepare(`SELECT COUNT(*) AS n FROM ap_attention_requests
|
|---|
| 91 | WHERE ward_slug = ? AND guardian_uri = ? AND asked_at <= ?`)
|
|---|
| 92 | .get(wardSlug, guardianUri, now - POLICY.requestTtlMs);
|
|---|
| 93 | return r ? r.n : 0;
|
|---|
| 94 | }
|
|---|
| 95 |
|
|---|
| 96 | /** Promote to dormant when the evidence says so. Returns true only on the
|
|---|
| 97 | * transition itself: THAT is the moment the notification duty of §3.6.2
|
|---|
| 98 | * fires (protocol AND the §6 handle), and it is the caller's job — wired
|
|---|
| 99 | * through onDormant below so every call site notifies the same way. */
|
|---|
| 100 | export function observe(wardSlug, guardianUri, now) {
|
|---|
| 101 | if (effective(wardSlug, guardianUri, now) !== 'active') return false;
|
|---|
| 102 | if (misses(wardSlug, guardianUri, now) < POLICY.missesForDormant) return false;
|
|---|
| 103 | db.prepare(`INSERT INTO ap_guardian_attention (ward_slug, guardian_uri, state, away_until)
|
|---|
| 104 | VALUES (?,?, 'dormant', NULL)
|
|---|
| 105 | ON CONFLICT(ward_slug, guardian_uri) DO UPDATE SET state = 'dormant', away_until = NULL`)
|
|---|
| 106 | .run(wardSlug, guardianUri);
|
|---|
| 107 | notifyDormant(wardSlug, guardianUri);
|
|---|
| 108 | return true;
|
|---|
| 109 | }
|
|---|
| 110 |
|
|---|
| 111 | /** The notification duty of §3.6.2, wired once (ActivityPubService). The
|
|---|
| 112 | * one-answer rule is worthless to someone who does not know an answer is
|
|---|
| 113 | * wanted; the §6 handle exists for precisely this moment. */
|
|---|
| 114 | let _onDormant = null;
|
|---|
| 115 | export function wireAvailability({ onDormant } = {}) { _onDormant = onDormant || null; }
|
|---|
| 116 | function notifyDormant(wardSlug, guardianUri) {
|
|---|
| 117 | try { if (_onDormant) _onDormant(wardSlug, guardianUri); } catch { /* best-effort */ }
|
|---|
| 118 | }
|
|---|
| 119 |
|
|---|
| 120 | /**
|
|---|
| 121 | * One answer restores everything (§3.6). Any activity from an actor that
|
|---|
| 122 | * guards someone on this server restores it to active for those wards and
|
|---|
| 123 | * cancels any lapse running against it, up to the last moment of the window.
|
|---|
| 124 | * Returns what changed, so a caller can log or announce it.
|
|---|
| 125 | */
|
|---|
| 126 | export function oneAnswer(guardianUri, now) {
|
|---|
| 127 | if (!guardianUri) return { restored: [], cancelledLapses: [] };
|
|---|
| 128 | const restored = [];
|
|---|
| 129 | for (const row of db.prepare(`SELECT ward_slug, state FROM ap_guardian_attention WHERE guardian_uri = ?`).all(guardianUri)) {
|
|---|
| 130 | if (row.state !== 'active') restored.push(row.ward_slug);
|
|---|
| 131 | }
|
|---|
| 132 | const hadRequests = db.prepare('SELECT DISTINCT ward_slug FROM ap_attention_requests WHERE guardian_uri = ?').all(guardianUri);
|
|---|
| 133 | for (const r of hadRequests) if (!restored.includes(r.ward_slug)) restored.push(r.ward_slug);
|
|---|
| 134 | db.prepare("UPDATE ap_guardian_attention SET state = 'active', away_until = NULL WHERE guardian_uri = ?").run(guardianUri);
|
|---|
| 135 | db.prepare('DELETE FROM ap_attention_requests WHERE guardian_uri = ?').run(guardianUri);
|
|---|
| 136 |
|
|---|
| 137 | const cancelledLapses = [];
|
|---|
| 138 | for (const l of db.prepare('SELECT * FROM ap_lapses WHERE target_uri = ? AND cancelled = 0 AND applied = 0').all(guardianUri)) {
|
|---|
| 139 | if (lapseOutcome(l, now) === 'open') {
|
|---|
| 140 | db.prepare('UPDATE ap_lapses SET cancelled = 1 WHERE id = ?').run(l.id);
|
|---|
| 141 | cancelledLapses.push({ id: l.id, wardSlug: l.ward_slug, wardUri: l.ward_uri, set: JSON.parse(l.set_json) });
|
|---|
| 142 | }
|
|---|
| 143 | }
|
|---|
| 144 | return { restored, cancelledLapses };
|
|---|
| 145 | }
|
|---|
| 146 |
|
|---|
| 147 | /** The available set of §3.5: the guardians minus away and dormant members.
|
|---|
| 148 | * Observation (and thus the dormancy promotion) happens here, so reading the
|
|---|
| 149 | * set is what moves the clock's consequences. */
|
|---|
| 150 | export function availableSet(wardSlug, guardianUris, now) {
|
|---|
| 151 | return guardianUris.filter((g) => {
|
|---|
| 152 | observe(wardSlug, g, now);
|
|---|
| 153 | return effective(wardSlug, g, now) === 'active';
|
|---|
| 154 | });
|
|---|
| 155 | }
|
|---|
| 156 |
|
|---|
| 157 | /** The guardians queue items (§3.6.1: never public, owner-only): the real
|
|---|
| 158 | * size of the ward's safety net. Same shape the daemon serves. */
|
|---|
| 159 | export function statusesFor(wardSlug, guardianUris, now) {
|
|---|
| 160 | return guardianUris.map((g) => {
|
|---|
| 161 | observe(wardSlug, g, now);
|
|---|
| 162 | const running = db.prepare(`SELECT id FROM ap_lapses WHERE ward_slug = ? AND target_uri = ? AND cancelled = 0 AND applied = 0`)
|
|---|
| 163 | .get(wardSlug, g);
|
|---|
| 164 | return {
|
|---|
| 165 | id: g,
|
|---|
| 166 | 'shaer:availability': effective(wardSlug, g, now),
|
|---|
| 167 | 'shaer:awayUntil': awayUntil(wardSlug, g, now),
|
|---|
| 168 | 'shaer:lapse': running && lapseOutcome(db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(running.id), now) === 'open' ? running.id : null,
|
|---|
| 169 | };
|
|---|
| 170 | });
|
|---|
| 171 | }
|
|---|
| 172 |
|
|---|
| 173 | // ── The lapse (§3.6.3): release in absentia ────────────────────────────────
|
|---|
| 174 |
|
|---|
| 175 | /** Read a shaer:Lapse object, or null when this is a different Offer. */
|
|---|
| 176 | export function parseLapse(object) {
|
|---|
| 177 | if (!object || typeof object !== 'object') return null;
|
|---|
| 178 | const type = Array.isArray(object.type) ? object.type[0] : object.type;
|
|---|
| 179 | if (type !== 'shaer:Lapse' && type !== 'Lapse') return null;
|
|---|
| 180 | const ward = object['shaer:ward'] || object.ward;
|
|---|
| 181 | const target = typeof object.object === 'string' ? object.object : (object.object && object.object.id);
|
|---|
| 182 | return (typeof ward === 'string' && typeof target === 'string') ? { ward, target } : null;
|
|---|
| 183 | }
|
|---|
| 184 |
|
|---|
| 185 | /** Strict majority of the set (§3.5 default). */
|
|---|
| 186 | export function lapseThreshold(setSize) { return Math.floor(setSize / 2) + 1; }
|
|---|
| 187 |
|
|---|
| 188 | /** Pure outcome: cancelled beats everything; the window always runs in full
|
|---|
| 189 | * (§3.5, irreversible), then a strict majority completes, else it fails
|
|---|
| 190 | * closed. */
|
|---|
| 191 | export function lapseOutcome(row, now) {
|
|---|
| 192 | if (!row) return null;
|
|---|
| 193 | if (row.cancelled) return 'cancelled';
|
|---|
| 194 | if (now - row.opened_at < row.window_ms) return 'open';
|
|---|
| 195 | const accepts = JSON.parse(row.accepts_json).length;
|
|---|
| 196 | return accepts >= lapseThreshold(JSON.parse(row.set_json).length) ? 'completed' : 'failed';
|
|---|
| 197 | }
|
|---|
| 198 |
|
|---|
| 199 | /**
|
|---|
| 200 | * Open a lapse on this server (we host the ward). Refusals mirror the
|
|---|
| 201 | * daemon's, status for status:
|
|---|
| 202 | * - not_a_guardian: the target does not guard this ward
|
|---|
| 203 | * - would_emancipate: removing the last guardian is §3.4, never a lapse
|
|---|
| 204 | * - not_dormant: a lapse opens only against a guardian already dormant
|
|---|
| 205 | * - not_in_available_set: only an available co-guardian proposes
|
|---|
| 206 | */
|
|---|
| 207 | export function openLapse({ id, wardSlug, wardUri, target, openedBy, now, windowMs = LAPSE_WINDOW_MS }) {
|
|---|
| 208 | const guardians = listGuardians(wardSlug).map((g) => g.other_uri);
|
|---|
| 209 | if (!guardians.includes(target)) return { error: 'not_a_guardian' };
|
|---|
| 210 | if (guardians.length <= 1) return { error: 'would_emancipate' };
|
|---|
| 211 | observe(wardSlug, target, now);
|
|---|
| 212 | if (effective(wardSlug, target, now) !== 'dormant') return { error: 'not_dormant' };
|
|---|
| 213 | const set = availableSet(wardSlug, guardians, now).filter((g) => g !== target);
|
|---|
| 214 | if (!set.includes(openedBy)) return { error: 'not_in_available_set' };
|
|---|
| 215 | // The proposal carries the proposer's own accept (§3.1's one-step clause,
|
|---|
| 216 | // exactly as §5.6 applies it).
|
|---|
| 217 | db.prepare(`INSERT INTO ap_lapses (id, ward_slug, ward_uri, target_uri, opened_by, set_json, accepts_json, opened_at, window_ms)
|
|---|
| 218 | VALUES (?,?,?,?,?,?,?,?,?)`)
|
|---|
| 219 | .run(id, wardSlug, wardUri, target, openedBy, JSON.stringify(set), JSON.stringify([openedBy]), now, windowMs);
|
|---|
| 220 | return { lapse: db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(id), set, threshold: lapseThreshold(set.length) };
|
|---|
| 221 | }
|
|---|
| 222 |
|
|---|
| 223 | /** Record a vote from a set member. Answers from outside the snapshot are
|
|---|
| 224 | * refused, not counted: a stranger cannot make up the majority. */
|
|---|
| 225 | export function lapseVote(id, actor, accept, now) {
|
|---|
| 226 | const row = db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(id);
|
|---|
| 227 | if (!row) return null;
|
|---|
| 228 | const outcome = lapseOutcome(row, now);
|
|---|
| 229 | if (outcome !== 'open') return { error: outcome === 'cancelled' ? 'cancelled' : 'closed' };
|
|---|
| 230 | const set = JSON.parse(row.set_json);
|
|---|
| 231 | if (!set.includes(actor)) return { error: 'not_in_set' };
|
|---|
| 232 | const accepts = new Set(JSON.parse(row.accepts_json));
|
|---|
| 233 | const rejects = new Set(JSON.parse(row.rejects_json));
|
|---|
| 234 | if (accept) { rejects.delete(actor); accepts.add(actor); }
|
|---|
| 235 | else { accepts.delete(actor); rejects.add(actor); }
|
|---|
| 236 | db.prepare('UPDATE ap_lapses SET accepts_json = ?, rejects_json = ? WHERE id = ?')
|
|---|
| 237 | .run(JSON.stringify([...accepts]), JSON.stringify([...rejects]), id);
|
|---|
| 238 | return { outcome: 'open', accepts: accepts.size, threshold: lapseThreshold(set.length) };
|
|---|
| 239 | }
|
|---|
| 240 |
|
|---|
| 241 | /**
|
|---|
| 242 | * Evaluate a lapse at `now`, executing the removal exactly once when the
|
|---|
| 243 | * window has closed with a majority. The refusal to empty shaer:guardians
|
|---|
| 244 | * stands as a second lock under this one: even a completed lapse must not
|
|---|
| 245 | * take the last guardian (that is emancipation, §3.4).
|
|---|
| 246 | */
|
|---|
| 247 | export function settleLapse(id, now) {
|
|---|
| 248 | const row = db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(id);
|
|---|
| 249 | if (!row) return null;
|
|---|
| 250 | const outcome = lapseOutcome(row, now);
|
|---|
| 251 | if (outcome !== 'completed' || row.applied) return { outcome, applied: !!row.applied, row };
|
|---|
| 252 | if (listGuardians(row.ward_slug).length <= 1) {
|
|---|
| 253 | return { outcome, applied: false, refused: 'would_emancipate', row };
|
|---|
| 254 | }
|
|---|
| 255 | removeRelation(row.ward_slug, 'ward', row.target_uri);
|
|---|
| 256 | db.prepare('UPDATE ap_lapses SET applied = 1 WHERE id = ?').run(id);
|
|---|
| 257 | return { outcome, applied: true, row };
|
|---|
| 258 | }
|
|---|
| 259 |
|
|---|
| 260 | /** The offers-queue items for running lapses this account is a party to:
|
|---|
| 261 | * the ward itself, or a co-located guardian in the set. Same shape as the
|
|---|
| 262 | * daemon's, so the Shaer clients render them as-is. */
|
|---|
| 263 | export function lapseQueueItems(slug, me, now) {
|
|---|
| 264 | const items = [];
|
|---|
| 265 | for (const row of db.prepare('SELECT * FROM ap_lapses WHERE applied = 0 AND cancelled = 0').all()) {
|
|---|
| 266 | settleLapse(row.id, now); // reads are where lazy completion happens
|
|---|
| 267 | if (lapseOutcome(row, now) !== 'open') continue;
|
|---|
| 268 | const set = JSON.parse(row.set_json);
|
|---|
| 269 | if (row.ward_slug !== slug && !set.includes(me)) continue;
|
|---|
| 270 | const accepts = JSON.parse(row.accepts_json);
|
|---|
| 271 | const rejects = JSON.parse(row.rejects_json);
|
|---|
| 272 | items.push({
|
|---|
| 273 | id: row.id,
|
|---|
| 274 | type: 'Offer',
|
|---|
| 275 | actor: row.opened_by,
|
|---|
| 276 | object: { type: 'shaer:Lapse', 'shaer:ward': row.ward_uri, object: row.target_uri },
|
|---|
| 277 | 'shaer:set': set,
|
|---|
| 278 | 'shaer:accepts': accepts.length,
|
|---|
| 279 | 'shaer:threshold': lapseThreshold(set.length),
|
|---|
| 280 | 'shaer:myVote': accepts.includes(me) || rejects.includes(me),
|
|---|
| 281 | 'shaer:outcome': 'open',
|
|---|
| 282 | 'shaer:closesAt': row.opened_at + row.window_ms,
|
|---|
| 283 | });
|
|---|
| 284 | }
|
|---|
| 285 | return items;
|
|---|
| 286 | }
|
|---|
| 287 |
|
|---|
| 288 | export function getLapse(id) { return db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(id); }
|
|---|
| 289 |
|
|---|
| 290 | export default {
|
|---|
| 291 | POLICY, LAPSE_WINDOW_MS, isAway, parseEndTime, effective, awayUntil, declareAway,
|
|---|
| 292 | recordRequest, misses, observe, oneAnswer, availableSet, statusesFor, wireAvailability,
|
|---|
| 293 | parseLapse, lapseThreshold, lapseOutcome, openLapse, lapseVote, settleLapse, lapseQueueItems, getLapse,
|
|---|
| 294 | };
|
|---|