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,
+};
