Relationships

Some access decisions depend on a connection between two people: a guardian who reads their ward’s records, a manager who edits their team’s reports, a member who sees their group’s content. Maravilla models those connections as typed relation edges between users and lets policies traverse them with the RELATES operator.

There are two halves:

  • Relation types — the kinds of connection your app supports (STEWARDS, MANAGES, MEMBER_OF, …). You declare these in maravilla.config.ts (or the admin UI). They’re versioned with your code.
  • Relation edges — the actual connections between specific users (alice STEWARDS bob). You create and query these at runtime with platform.auth.addRelation / removeRelation / listRelations. They’re tied to individual users, so they live in the database, not your repo.

Declaring relation types

Relation types go in the relations block of your config:

// maravilla.config.ts
import { defineConfig } from '@maravilla-labs/platform/config';

export default defineConfig({
  auth: {
    relations: [
      {
        relation_name: 'STEWARDS',
        title: 'Stewards',
        category: 'family',
        implies_stewardship: true,   // edges of this type grant stewardship rights
      },
      {
        relation_name: 'MANAGES',
        title: 'Manages',
        category: 'work',
        inverse_relation_name: 'REPORTS_TO',
      },
      {
        relation_name: 'MEMBER_OF',
        title: 'Member of',
        category: 'work',
        bidirectional: true,         // A↔B; traversal works in both directions
      },
    ],
  },
});
FieldMeaning
relation_nameUppercase identifier referenced from policies via VIA '<name>'. Required.
titleHuman-readable label for the admin UI. Required.
descriptionOptional longer description.
categoryGrouping for the admin UI, e.g. "family", "work".
icon, colorOptional UI affordances.
inverse_relation_nameName of the inverse relation type, if one exists (e.g. MANAGESREPORTS_TO).
implies_stewardshipWhen true, an edge of this type grants the source user stewardship rights over the target.
requires_minorWhen true, the relation can only target users flagged as minors.
bidirectionalWhen true, the relation is symmetric — A→B implies B→A, and traversal follows the edge either way.

Declaring relations upserts the types by relation_name. Types created in the admin UI but not in your config are kept, never auto-deleted.

Creating relation edges at runtime

Relation types are declarative; the edges between users are data. Manage them from your deployed code:

// Make `guardianId` a steward of `wardId`.
const edge = await platform.auth.addRelation({
  from_user_id: guardianId,
  to_user_id: wardId,
  relation_type: 'STEWARDS',       // a relation name OR a relation-type id (rlt_…)
  metadata: { added_by: 'enrollment-flow' },
});

// List the edges touching a user.
const outgoing = await platform.auth.listRelations({
  user_id: guardianId,
  direction: 'outgoing',           // 'outgoing' | 'incoming' | 'both' (default 'both')
});

// Remove an edge by its endpoints and relation-type id.
await platform.auth.removeRelation(guardianId, wardId, edge.relation_type_id);

A returned Relation carries:

interface Relation {
  id: string;
  from_user_id: string;
  to_user_id: string;
  relation_type: string;       // the type's name, e.g. "STEWARDS"
  relation_type_id: string;    // the type's id, "rlt_…"
  metadata?: Record<string, any> | null;
  created_at: number;
}

Traversing relations in a policy

The RELATES operator asks the relation graph whether a path exists between two users. The shape is:

<object> RELATES <subject> VIA '<relation_name>' [DEPTH a..b] [DIRECTION OUTGOING|INCOMING|ANY]
  • <object> is the anchor — usually a field on the record, like node.owner.
  • <subject> is the related party — usually auth.user_id.
  • VIA '<relation_name>' names the relation type. It must be a single-quoted relation name (or an array of names: VIA ['STEWARDS', 'MANAGES']).
  • DEPTH a..b bounds the traversal to between a and b hops (inclusive). Omitted, it defaults to 1..1 (direct edges only).
  • DIRECTION is OUTGOING (default), INCOMING, or ANY.
# Allow when the caller is a direct steward of whoever owns the record
node.owner RELATES auth.user_id VIA 'STEWARDS'
# Allow up to two hops away — e.g. steward-of-a-steward
node.owner RELATES auth.user_id VIA 'STEWARDS' DEPTH 1..2
# A manager (or their manager) can act on a report's record
node.owner RELATES auth.user_id VIA 'MANAGES' DEPTH 1..3 DIRECTION INCOMING

Traversal is a bounded breadth-first search over the edge graph. Deep traversals cost more per check, so keep DEPTH small — the runtime caps it at 6 hops regardless of what you write.

The typed builder

Rather than writing the RELATES clause by hand, the policy builder emits the correct single-quoted form:

import { defineConfig, relatesVia, ownsIt } from '@maravilla-labs/platform/config';

export default defineConfig({
  auth: {
    relations: [
      { relation_name: 'STEWARDS', title: 'Stewards', implies_stewardship: true },
    ],
    resources: [
      {
        name: 'health_records',
        title: 'Health records',
        type: 'database',
        actions: ['read', 'write'],
        // owner OR a steward (1–2 hops) of the owner
        policy: ownsIt().or(relatesVia('STEWARDS', { depth: [1, 2] })),
      },
    ],
  },
});

relatesVia('NAME') defaults to node.owner RELATES auth.user_id VIA 'NAME'. Override the anchors with { object, subject } and bound the search with { depth: [min, max] }. defineConfig cross-checks that every relation name you reference exists in relations[] — a typo throws at build time.

Stewardship — the built-in family relation

Stewardship is the platform’s built-in guardianship model: one user (the steward) acts on behalf of another (the ward). It backs scenarios like a parent managing a minor’s account or a caregiver managing a dependent’s records.

Stewardship participates in RELATES as a first-class edge. A relation type declared with implies_stewardship: true contributes both its own relation edges and the stewardship rows derived from it — so VIA '<that relation name>' (e.g. VIA 'STEWARDS' once you’ve declared a STEWARDS relation) traverses them together. A RELATES … VIA '<name>' clause matches stewardship through the relation type that created it; stewardship created directly via the API without a relation type isn’t reached by name — manage and query it through platform.auth.stewardship (below).

The stewardship API lives under platform.auth.stewardship:

// Who stewards this user, and who do they steward?
const { stewards, wards } = await platform.auth.stewardship.resolve(userId);

// Grant a scoped override directly (independent of a relation edge).
await platform.auth.stewardship.createOverride({
  steward_id: guardianId,
  ward_id: wardId,
  delegation_mode: 'scoped',
  scoped_permissions: [{ resource: 'health_records', actions: ['read'] }],
  valid_until: Math.floor(Date.now() / 1000) + 30 * 24 * 3600,
});

// Check a single permission without writing a policy.
const ok = await platform.auth.stewardship.checkPermission(
  guardianId, wardId, 'health_records', 'read'
);

// Audit trail of actions taken on behalf of a ward.
const log = await platform.auth.stewardship.listAudit(wardId, { limit: 50 });

An override carries a delegation_mode of 'full' (steward inherits the ward’s access) or 'scoped' (only the listed scoped_permissions), an optional validity window (valid_from / valid_until), and a status (active, suspended, revoked, expired). Expired and revoked overrides stop contributing edges immediately.

A worked example: guardian reads a ward’s records

// maravilla.config.ts
import { defineConfig, ownsIt, isAdmin, relatesVia } from '@maravilla-labs/platform/config';

export default defineConfig({
  auth: {
    relations: [
      { relation_name: 'STEWARDS', title: 'Stewards', category: 'family',
        implies_stewardship: true },
    ],
    resources: [
      {
        name: 'records',
        title: 'Records',
        type: 'database',
        actions: ['read', 'write', 'delete'],
        // The owner, an admin, or a steward of the owner.
        policy: ownsIt().or(isAdmin()).or(relatesVia('STEWARDS', { depth: [1, 1] })),
      },
    ],
  },
});
// In a deployed function: enroll a child and link the guardian.
const child = await platform.auth.createManagedUser({
  profile: { display_name: 'Sam', is_minor: true },
  external_id: `child:${enrollmentId}`,   // idempotent — safe to retry
});

await platform.auth.addRelation({
  from_user_id: guardian.id,
  to_user_id: child.id,
  relation_type: 'STEWARDS',
});

// Now, when the guardian is the bound caller, this returns true:
const canRead = await platform.auth.can('read', 'records', { owner: child.id });

The child here is a managed user — a login-less account used to model someone who never signs in themselves. See the Authentication guide for createManagedUser.

Slugs for stable addressing

Groups, circles, and relation types each carry a tenant-unique slug in addition to their generated id. The API methods that take an id (getGroup, updateRelationType, getCircle, …) accept the slug too, resolved server-side. That lets you address these objects by a stable, human-readable handle that survives across environments — instead of pinning to a generated rlt_… / grp_… id that differs per tenant.

// Both work — the slug is resolved to the underlying id.
await platform.auth.getGroup('grp_a1b2c3');
await platform.auth.getGroup('moderators');

Circles

A circle is a named set of users with per-member relationship labels — a lightweight way to model a household, a care team, or a project group. Members carry a relationship string and an optional is_primary_contact flag.

const circle = await platform.auth.createCircle({ name: 'Smith household' });

await platform.auth.addCircleMember(circle.id, {
  user_id: parent.id,
  relationship: 'parent',
  is_primary_contact: true,
});

const members = await platform.auth.getCircleMembers(circle.id);

In policies, the caller’s circle slugs are available as auth.circles:

auth.circles.contains('smith-household')

API summary

MethodDescription
createRelationType(opts) / listRelationTypes()Manage relation types in code
updateRelationType(idOrSlug, opts) / deleteRelationType(idOrSlug)Update or remove a type
addRelation({ from_user_id, to_user_id, relation_type, metadata? })Create an edge
removeRelation(from, to, relationTypeId)Remove an edge
listRelations({ user_id, direction? })List edges touching a user
stewardship.resolve(userId)Stewards and wards of a user
stewardship.createOverride(opts) / stewardship.revoke(id)Manage stewardship overrides
stewardship.checkPermission(steward, ward, resource, action)One-off stewardship check
stewardship.listAudit(userId, opts?)Audit of on-behalf-of actions
createCircle(opts) / addCircleMember(...) / getCircleMembers(...)Manage circles

Next steps

  • Authorization — Write the policies that traverse these relations
  • Authentication — Register users and create managed (login-less) users
  • Database — Where relation edges and the records they gate live