Index: src/services/guardianship/context.js
===================================================================
--- src/services/guardianship/context.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
+++ src/services/guardianship/context.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
@@ -0,0 +1,26 @@
+/**
+ * Guardianship (FEP-633c "Guardians") — JSON-LD vocabulary.
+ *
+ * One source of truth for the shaer namespace and the terms Klonkt emits.
+ * ActivityPubService spreads SHAER_CONTEXT into its AP_CONTEXT term block, so
+ * every outgoing document declares the namespace and strict JSON-LD
+ * processors resolve the terms instead of dropping them.
+ */
+
+/** The term block merged into AP_CONTEXT. */
+export const SHAER_CONTEXT = {
+  // FEP-633c (Guardians): the shaer namespace. helpRequest marks a direct
+  // note as a ward's call for help (spec 5.2.1); ignorable by everyone else.
+  shaer: 'https://ns.klonkt.com/shaer#',
+};
+
+/** The Relationship value in the adoption Offer (FEP-633c §3), both forms. */
+export const GUARDIAN_RELATIONSHIP = 'https://ns.klonkt.com/shaer#Guardian';
+export const GUARDIAN_RELATIONSHIP_COMPACT = 'shaer:Guardian';
+
+/** True when an Offer's relationship names the guardian relation. */
+export function isGuardianRelationship(value) {
+  return value === GUARDIAN_RELATIONSHIP || value === GUARDIAN_RELATIONSHIP_COMPACT;
+}
+
+export default { SHAER_CONTEXT, GUARDIAN_RELATIONSHIP, GUARDIAN_RELATIONSHIP_COMPACT, isGuardianRelationship };
Index: src/services/guardianship/delivery.js
===================================================================
--- src/services/guardianship/delivery.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
+++ src/services/guardianship/delivery.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
@@ -0,0 +1,95 @@
+/**
+ * Guardianship (FEP-633c) — the direct-note delivery leg.
+ *
+ * A direct note (private mention, shaer-tqc) is the ward's call-for-help
+ * carrier: addressed to specific actors only, no Public, no followers
+ * fan-out. Moved here from ActivityPubService (guardianship refactor);
+ * behavior is unchanged.
+ *
+ * This module has NO import back into ActivityPubService: the AP helpers it
+ * needs (actor fetch, key material, delivery, note building) are provided
+ * once via wireDelivery(deps) at ActivityPubService load time.
+ */
+import crypto from 'crypto';
+import db from '../../config/database.js';
+
+const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
+
+let deps = null;
+/** Called once by ActivityPubService with the shared AP helpers. */
+export function wireDelivery(d) { deps = d; }
+
+// Addressing → visibility. Arrays or bare strings; unknown shapes read as the
+// safest bucket they match.
+export function c2sVisibility(object) {
+  const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
+  const to = arr(object.to), cc = arr(object.cc);
+  const isPublic = (x) => x === PUBLIC || x === 'as:Public' || x === 'Public';
+  const isFollowers = (x) => /\/followers\/?$/.test(x);
+  if (to.some(isPublic)) return 'public';
+  if (cc.some(isPublic)) return 'quiet';
+  if (to.some(isFollowers) || cc.some(isFollowers)) return 'friends';
+  if (!to.length && !cc.length) return 'public';   // no addressing at all: legacy client, keep old behavior
+  return 'direct';
+}
+
+// A direct note: a NEW conversation (or a direct reply) addressed to specific
+// actors only. Stored in ap_outbox with visibility 'direct' + the recipient
+// list, delivered to exactly those inboxes: no followers fan-out, no Public,
+// so no boosts and no timelines. The same S2S leg a Mastodon DM takes, so a
+// guardian on any instance receives it as a private mention (the ward
+// call-for-help path).
+export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments, helpRequest }) {
+  const { actorId, fetchActor, deriveHandle, escHtml, linkUrls, linkHashtags,
+          getOutboxRow, buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery } = deps;
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  const list = [...new Set((recipients || []).filter((u) => /^https?:\/\//i.test(String(u || ''))))].slice(0, 8);
+  if (!base || !site || !site.slug || !list.length || !String(text || '').trim()) return null;
+  const me = actorId(base, site.slug);
+  // Resolve every recipient for a mention anchor + a delivery inbox.
+  const resolved = [];
+  for (const uri of list) {
+    const a = await fetchActor(uri).catch(() => null);
+    if (!a || !(a.inbox || (a.endpoints && a.endpoints.sharedInbox))) continue;
+    resolved.push({ uri, inbox: (a.endpoints && a.endpoints.sharedInbox) || a.inbox, handle: deriveHandle(uri), url: a.url || uri });
+  }
+  if (!resolved.length) return null;
+  const mention = resolved.map((r) => {
+    const disp = r.handle && r.handle[0] === '@' ? r.handle : '@' + (r.handle || '');
+    return `<a href="${escHtml(r.url)}" class="u-url mention" data-actor="${escHtml(r.uri)}">${escHtml(disp)}</a> `;
+  }).join('');
+  const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
+  const content = `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
+  const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
+  // Attachments: same rules as deliverReply (own /media/ uploads only,
+  // image/audio/video, max 4) — the help-buoy capture rides this.
+  const media = (Array.isArray(attachments) ? attachments : [])
+    .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
+      && /^(image|audio|video)\//.test(String(a.mediaType || '')))
+    .slice(0, 4)
+    .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
+  const id = crypto.randomUUID();
+  db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, visibility, to_actors, help_request, created_at)
+              VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
+    .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, media.length ? JSON.stringify(media) : null, 'direct', JSON.stringify(resolved.map((r) => r.uri)), helpRequest ? 1 : 0);
+  const row = getOutboxRow(id);
+  const note = buildReplyNote(base, site, row);
+  const create = {
+    '@context': AP_CONTEXT,
+    id: note.id + '#create', type: 'Create', actor: me,
+    published: note.published, to: note.to, cc: note.cc, object: note,
+  };
+  const keys = getOrCreateKeys(site.slug);
+  const keyId = `${me}#main-key`;
+  let delivered = 0;
+  for (const inbox of [...new Set(resolved.map((r) => r.inbox))]) {
+    let ok = false;
+    try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
+    if (ok) delivered++;
+    else enqueueDelivery(site.slug, inbox, create);
+  }
+  console.log('[AP] direct note', site.slug, '→', resolved.length, 'recipient(s), delivered', delivered);
+  return { id, content, delivered };
+}
+
+export default { wireDelivery, c2sVisibility, deliverDirectNote };
Index: src/services/guardianship/handshake.js
===================================================================
--- src/services/guardianship/handshake.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
+++ src/services/guardianship/handshake.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
@@ -0,0 +1,135 @@
+/**
+ * Guardianship (FEP-633c §3) — the adoption handshake.
+ *
+ * Offer(Relationship{subject: ward, relationship: shaer:Guardian, object:
+ * candidate}) travels from the guardian-candidate to the ward; the ward
+ * answers Accept (relation becomes real) or Reject (row disappears). The
+ * shape mirrors the Shaer test daemon, so the iOS/Android clients speak it
+ * unchanged.
+ *
+ * Wired like delivery.js: no import back into ActivityPubService; the AP
+ * helpers arrive once via wireHandshake(deps). `deps.onEvent(slug, ev)` is an
+ * optional hook the Guardian PWA uses for push notifications.
+ */
+import { isGuardianRelationship, GUARDIAN_RELATIONSHIP_COMPACT } from './context.js';
+import * as relations from './relations.js';
+
+let deps = null;
+export function wireHandshake(d) { deps = d; }
+
+const idOf = (v) => (typeof v === 'string' ? v : (v && typeof v === 'object' && typeof v.id === 'string' ? v.id : null));
+
+/** Parse a Relationship object into {ward, candidate} or null. */
+export function parseRelationship(rel) {
+  if (!rel || typeof rel !== 'object') return null;
+  const type = Array.isArray(rel.type) ? rel.type[0] : rel.type;
+  if (type !== 'Relationship') return null;
+  if (!isGuardianRelationship(String(rel.relationship || ''))) return null;
+  const ward = idOf(rel.subject);
+  const candidate = idOf(rel.object);
+  return ward && candidate ? { ward, candidate } : null;
+}
+
+// ── C2S: the local account acts (PWA or Shaer app, via the outbox) ────────
+
+/**
+ * Handle a guardianship activity POSTed to the local outbox. Returns null
+ * when the activity is not ours to handle, else {status, ...} for the route.
+ */
+export async function handleOutbox(site, activity) {
+  const { selfId, deliverTo, deriveHandle } = deps;
+  const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
+  if (!['Offer', 'Accept', 'Reject'].includes(type)) return null;
+  const me = selfId(site.slug);
+
+  if (type === 'Offer') {
+    const rel = parseRelationship(activity.object);
+    if (!rel) return null;                                   // not a guardianship offer
+    // Fixed initiator (FEP resolved B): only the aspirant guardian offers.
+    if (rel.candidate !== me) return { status: 403, error: 'only_the_candidate_offers' };
+    // A ward can never become a guardian (FEP §1).
+    if (relations.listGuardians(site.slug).length) return { status: 403, error: 'a_ward_cannot_guard' };
+    const offerId = `${me}/offers/${Date.now().toString(36)}`;
+    const offer = {
+      id: offerId, type: 'Offer', actor: me, to: [rel.ward],
+      object: { type: 'Relationship', subject: rel.ward, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: me },
+    };
+    relations.recordOffer(site.slug, 'guardian', rel.ward, { handle: deriveHandle(rel.ward), offerId });
+    const delivered = await deliverTo(site, rel.ward, offer).catch(() => false);
+    notify(site.slug, { kind: 'offer_sent', ward: rel.ward });
+    return { status: delivered ? 202 : 502, id: offerId, url: offerId };
+  }
+
+  // Accept / Reject: the local ward answers a pending offer.
+  const obj = activity.object;
+  const offerId = idOf(obj);
+  const rel = parseRelationship(obj && obj.object) || parseRelationship(obj);
+  let row = null;
+  if (offerId) row = relations.findByOfferId(offerId).find((r) => r.slug === site.slug && r.role === 'ward') || null;
+  if (!row && rel) row = relations.getRelation(site.slug, 'ward', rel.candidate) || null;
+  if (!row) return { status: 404, error: 'no_such_offer' };
+
+  const answer = {
+    id: `${me}/answers/${Date.now().toString(36)}`, type, actor: me, to: [row.other_uri],
+    object: row.offer_id || { type: 'Relationship', subject: me, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: row.other_uri },
+  };
+  if (type === 'Accept') {
+    // The committed handle rides in `result` (daemon contract): the guardian
+    // learns where the ward lives.
+    answer.result = `${me}/inbox`;
+    relations.acceptRelation(site.slug, 'ward', row.other_uri);
+  } else {
+    relations.removeRelation(site.slug, 'ward', row.other_uri);
+  }
+  const delivered = await deliverTo(site, row.other_uri, answer).catch(() => false);
+  notify(site.slug, { kind: type === 'Accept' ? 'offer_accepted' : 'offer_rejected', guardian: row.other_uri });
+  return { status: delivered ? 202 : 502, id: answer.id, url: answer.id };
+}
+
+// ── S2S: a remote party acts (arrives in the local inbox) ────────────────
+
+/**
+ * Handle an inbound guardianship activity for local site `site`. Returns
+ * true when consumed (the generic inbox skips it), false otherwise.
+ */
+export async function handleInbox(site, activity) {
+  const { selfId } = deps;
+  const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
+  if (!['Offer', 'Accept', 'Reject'].includes(type)) return false;
+  const me = selfId(site.slug);
+  const actor = idOf(activity.actor);
+
+  if (type === 'Offer') {
+    const rel = parseRelationship(activity.object);
+    if (!rel || rel.ward !== me) return false;
+    // A remote candidate offers to guard the local ward: park it in the queue.
+    relations.recordOffer(site.slug, 'ward', rel.candidate, { handle: deps.deriveHandle(rel.candidate), offerId: idOf(activity) });
+    notify(site.slug, { kind: 'offer_received', candidate: rel.candidate });
+    return true;
+  }
+
+  // Accept / Reject of an offer WE (local guardian) sent.
+  const obj = activity.object;
+  const offerId = idOf(obj);
+  const rel = parseRelationship(obj && obj.object) || parseRelationship(obj);
+  let row = null;
+  if (offerId) row = relations.findByOfferId(offerId).find((r) => r.slug === site.slug && r.role === 'guardian') || null;
+  if (!row && actor) row = relations.getRelation(site.slug, 'guardian', actor) || null;
+  if (!row && rel) row = relations.getRelation(site.slug, 'guardian', rel.ward) || null;
+  if (!row) return false;
+
+  if (type === 'Accept') {
+    relations.acceptRelation(site.slug, 'guardian', row.other_uri);
+    notify(site.slug, { kind: 'ward_accepted', ward: row.other_uri });
+  } else {
+    relations.removeRelation(site.slug, 'guardian', row.other_uri);
+    notify(site.slug, { kind: 'ward_rejected', ward: row.other_uri });
+  }
+  return true;
+}
+
+function notify(slug, ev) {
+  try { if (deps && typeof deps.onEvent === 'function') deps.onEvent(slug, ev); } catch { /* best-effort */ }
+}
+
+export default { wireHandshake, handleOutbox, handleInbox, parseRelationship };
Index: src/services/guardianship/index.js
===================================================================
--- src/services/guardianship/index.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
+++ src/services/guardianship/index.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
@@ -0,0 +1,26 @@
+/**
+ * Guardianship (FEP-633c "Guardians") — the module.
+ *
+ * Klonkt's kid-safety feature as one cohesive unit:
+ *  - context.js:   the shaer JSON-LD namespace + Relationship vocabulary
+ *  - relations.js: ward ↔ guardian relations (ap_guardianships) + actor props
+ *  - handshake.js: the adoption Offer/Accept/Reject over C2S and S2S
+ *  - queues.js:    the owner-only dashboard collections (offers/follows/wards)
+ *  - notes.js:     the shaer:helpRequest flag on direct notes
+ *  - delivery.js:  the direct-note leg a ward's call-for-help rides
+ *
+ * The shared blocklist (Shaer's "in Orbit") intentionally lives NEXT TO this
+ * module in BlocklistService: Klonkt's own Block tab uses it too.
+ *
+ * ActivityPubService wires the AP helpers in once (wireDelivery/wireHandshake)
+ * and delegates; nothing here imports ActivityPubService back.
+ */
+export { SHAER_CONTEXT, GUARDIAN_RELATIONSHIP, GUARDIAN_RELATIONSHIP_COMPACT, isGuardianRelationship } from './context.js';
+export { helpRequestProps, isHelpRequest } from './notes.js';
+export { wireDelivery, c2sVisibility, deliverDirectNote } from './delivery.js';
+export { wireHandshake, handleOutbox as handleGuardianshipOutbox, handleInbox as handleGuardianshipInbox, parseRelationship } from './handshake.js';
+export { offersCollection, followsCollection, wardsCollection } from './queues.js';
+export {
+  listGuardians, listWards, listOffers, isGuardian, getRelation, findByOfferId,
+  recordOffer, acceptRelation, removeRelation, actorProps as guardianshipActorProps,
+} from './relations.js';
Index: src/services/guardianship/notes.js
===================================================================
--- src/services/guardianship/notes.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
+++ src/services/guardianship/notes.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
@@ -0,0 +1,20 @@
+/**
+ * Guardianship (FEP-633c) — note properties.
+ *
+ * The shaer:helpRequest flag (spec 5.2.1): a ward's call for help, only ever
+ * on direct notes. Everyone who does not speak shaer can ignore it.
+ */
+
+/** Extra JSON-LD properties for an outgoing note built from an ap_outbox row. */
+export function helpRequestProps(post) {
+  return (post && post.visibility === 'direct' && post.help_request)
+    ? { 'shaer:helpRequest': true }
+    : {};
+}
+
+/** True when an incoming (C2S or S2S) note object carries the flag. */
+export function isHelpRequest(object) {
+  return !!object && (object['shaer:helpRequest'] === true || object.helpRequest === true);
+}
+
+export default { helpRequestProps, isHelpRequest };
Index: src/services/guardianship/queues.js
===================================================================
--- src/services/guardianship/queues.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
+++ src/services/guardianship/queues.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
@@ -0,0 +1,49 @@
+/**
+ * Guardianship (FEP-633c) — the owner-only dashboard queues.
+ *
+ * Three OrderedCollections on the actor (shaer:queues), same contract as the
+ * Shaer test daemon so the iOS/Android guardian dashboards read them as-is:
+ *  - offers:  pending guardianship offers where I am a party (§3)
+ *  - follows: pending follows for my wards (§5.3) — Klonkt has no gated
+ *             follows yet, so this collection is empty for now
+ *  - wards:   my wards, for the dashboard's wards list
+ */
+import { GUARDIAN_RELATIONSHIP_COMPACT } from './context.js';
+import * as relations from './relations.js';
+
+const collection = (id, items) => ({
+  id, type: 'OrderedCollection', totalItems: items.length, orderedItems: items,
+});
+
+/** Pending offers, reconstructed as Offer activities (either side). */
+export function offersCollection(id, slug, me) {
+  const items = relations.listOffers(slug).map((r) => ({
+    id: r.offer_id || undefined,
+    type: 'Offer',
+    actor: r.role === 'guardian' ? me : r.other_uri,
+    object: {
+      type: 'Relationship',
+      subject: r.role === 'guardian' ? r.other_uri : me,
+      relationship: GUARDIAN_RELATIONSHIP_COMPACT,
+      object: r.role === 'guardian' ? me : r.other_uri,
+    },
+    'shaer:handle': r.other_handle || undefined,
+    published: r.created_at,
+  }));
+  return collection(id, items);
+}
+
+/** Gated follows awaiting guardian approval — not built in Klonkt yet. */
+export function followsCollection(id) {
+  return collection(id, []);
+}
+
+/** The guardian's wards (accepted), with cached handle for display. */
+export function wardsCollection(id, slug) {
+  const items = relations.listWards(slug)
+    .filter((r) => r.status === 'accepted')
+    .map((r) => ({ id: r.other_uri, 'shaer:handle': r.other_handle || undefined, since: r.created_at }));
+  return collection(id, items);
+}
+
+export default { offersCollection, followsCollection, wardsCollection };
Index: src/services/guardianship/relations.js
===================================================================
--- src/services/guardianship/relations.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
+++ src/services/guardianship/relations.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
@@ -0,0 +1,104 @@
+/**
+ * Guardianship (FEP-633c) — the ward ↔ guardian relations (ap_guardianships).
+ *
+ * Every row is one relation seen from a LOCAL site: role 'guardian' means the
+ * site guards `other_uri` (a ward, possibly remote); role 'ward' means
+ * `other_uri` guards the site. A local ward with a local guardian yields two
+ * rows, one per perspective — intentional, each side reads its own.
+ *
+ * The handshake (spec §3): the guardian-candidate — and only the candidate —
+ * Offers a Relationship {subject: ward, relationship: shaer:Guardian,
+ * object: candidate}; the ward Accepts (or Rejects). Status walks
+ * 'offered' → 'accepted'; a Reject deletes the row.
+ */
+import db from '../../config/database.js';
+
+let _s = null;
+function stmts() {
+  if (!_s) {
+    _s = {
+      ins: db.prepare(`INSERT OR IGNORE INTO ap_guardianships (slug, role, other_uri, other_handle, status, offer_id, created_at)
+                       VALUES (?,?,?,?,?,?,CURRENT_TIMESTAMP)`),
+      accept: db.prepare(`UPDATE ap_guardianships SET status='accepted' WHERE slug=? AND role=? AND other_uri=?`),
+      del: db.prepare('DELETE FROM ap_guardianships WHERE slug=? AND role=? AND other_uri=?'),
+      bySlugRole: db.prepare('SELECT * FROM ap_guardianships WHERE slug=? AND role=? ORDER BY created_at DESC'),
+      one: db.prepare('SELECT * FROM ap_guardianships WHERE slug=? AND role=? AND other_uri=?'),
+      byOffer: db.prepare('SELECT * FROM ap_guardianships WHERE offer_id=?'),
+    };
+  }
+  return _s;
+}
+
+// ── Reads ────────────────────────────────────────────────────────────────
+
+/** Accepted guardian URIs of a local ward (feeds shaer:guardians). */
+export function listGuardians(slug) {
+  return stmts().bySlugRole.all(slug, 'ward').filter((r) => r.status === 'accepted');
+}
+
+/** All ward relations of a local guardian (accepted + pending offers). */
+export function listWards(slug) {
+  return stmts().bySlugRole.all(slug, 'guardian');
+}
+
+/** Pending offers where the local site is a party (either side). */
+export function listOffers(slug) {
+  return [...stmts().bySlugRole.all(slug, 'guardian'), ...stmts().bySlugRole.all(slug, 'ward')]
+    .filter((r) => r.status === 'offered');
+}
+
+/** A site is a guardian once it stands in any guardian-side relation. */
+export function isGuardian(slug) {
+  return stmts().bySlugRole.all(slug, 'guardian').length > 0;
+}
+
+export function getRelation(slug, role, otherUri) { return stmts().one.get(slug, role, otherUri); }
+export function findByOfferId(offerId) { return offerId ? stmts().byOffer.all(offerId) : []; }
+
+// ── Writes (the handshake walks through these) ───────────────────────────
+
+/** Record an outgoing/incoming Offer on the local side with `role`. */
+export function recordOffer(slug, role, otherUri, { handle = null, offerId = null } = {}) {
+  stmts().ins.run(slug, role, otherUri, handle, 'offered', offerId);
+  return stmts().one.get(slug, role, otherUri);
+}
+
+/** The ward said yes (or our own offer was accepted): relation becomes real. */
+export function acceptRelation(slug, role, otherUri) {
+  stmts().accept.run(slug, role, otherUri);
+  return stmts().one.get(slug, role, otherUri);
+}
+
+/** Reject / retract / end a relation: the row disappears. */
+export function removeRelation(slug, role, otherUri) {
+  stmts().del.run(slug, role, otherUri);
+  return { ok: true };
+}
+
+// ── Actor document (FEP-633c §2) ─────────────────────────────────────────
+
+/**
+ * The guardianship properties for a local actor doc. `id` is the actor URI.
+ * - shaer:guardians: accepted guardians of this ward (omitted when none)
+ * - shaer:isGuardian: true once the site guards anyone
+ * - shaer:queues: the owner-only dashboard collections (always advertised,
+ *   like `blocked`: clients discover, the routes enforce auth)
+ */
+export function actorProps(id, slug) {
+  const props = {
+    'shaer:queues': {
+      offers: `${id}/queues/offers`,
+      follows: `${id}/queues/follows`,
+      wards: `${id}/queues/wards`,
+    },
+  };
+  const guardians = listGuardians(slug).map((r) => r.other_uri);
+  if (guardians.length) props['shaer:guardians'] = guardians;
+  if (isGuardian(slug)) props['shaer:isGuardian'] = true;
+  return props;
+}
+
+export default {
+  listGuardians, listWards, listOffers, isGuardian, getRelation, findByOfferId,
+  recordOffer, acceptRelation, removeRelation, actorProps,
+};
