Primitive spec

Terminus

The identity primitive. Terminus owns people, accounts, groups, memberships, and consents — the "who" and "what relationship" behind every booking, message, and ledger entry. It is the consent and entitlement choke point other primitives call before acting on a customer.

What it owns

Terminus is the source of truth for any non-physical entity that has identity and relationship: customers, families, businesses, teams, care relationships, tier programs. It owns persons, accounts, groups, memberships, consents, preferences, and tags — the identity records. Authentication itself (credentials, sessions, tokens, MFA) lives in Mannix; each Person carries a mannixPrincipalId backlink to its global principal. A stylist's name and consents live here; her current location lives in Trantor.

Two service methods do most of the cross-primitive work. checkEntitlement resolves tier-membership gates — Seldon calls it against every BookingAccessRule attached to an Offer's TimeScheme. assertConsent is the opt-in choke point that Speaker calls before any outbound communication. There is no path to bypass either.

Concepts

Person
A human. Single canonical row across the platform. Carries name, contact handles (email, phone), preferences, and tags, plus a mannixPrincipalId backlink to the global Mannix principal that authenticates them. Other primitives reference Persons by opaque id (per_*); they do not duplicate Person attributes.
Account
A tenant-scoped account handle on a Person — separating Person from Account lets one human hold several (an individual account, a corporate one) without duplicating identity. Authentication is no longer Terminus's job: credentials, sessions, and tokens moved to Mannix, and a Person's mannixPrincipalId ties the tenant-scoped record to its global principal. Terminus keeps the relationship and entitlement; Mannix proves who is calling.
RankedGroup
One generalised model for every "Person belongs to a thing" relationship: household, family, corporate, team, joint_account, care, tier, ad_hoc. Two tables: Group (kind + name + metadata) and GroupMember (person + role + rank + validity window + status). Rank gives ordered traversal (head of household first, primary signer first).
Tier membership
A specialisation of RankedGroup, not a separate entity. A Gold program is Group(kind=tier, name="Gold"); each enrolled customer is one GroupMember(status=active) with optional validFrom/validUntil bounds. Tier-gated booking access works through the same entitlement check as any other group membership.
TierDefinition
The structured policy that sits beside a tier Group — entry rules, default validity window, and the auto-enrolment evaluator that flips eligible customers into active GroupMember rows automatically. Lets a tier program carry inspectable policy rather than implicit conventions.
Consent
A Person's recorded opt-in or opt-out for a specific scope (marketing email, transactional SMS, third-party data sharing). assertConsent resolves the live consent state — Speaker calls it on every outbound, with no override. Consent changes are append-only; the history is the audit trail.
TerminusDirectory
The service injected by Seldon and Speaker. Three methods: resolveOrCreatePerson (find by handle or create), checkEntitlement (does this person have an active GroupMember row in the named tier or group?), assertConsent (is the named scope currently consented for this person?).
Typed Person attributes
Composable custom customer fields, no schema migration. A tenant defines a PersonAttributeDefinition (name + valueTypeSTRING, INTEGER, BOOLEAN, or STRING_ARRAY — plus required and label); values live in a validated attributes bag on the Person. Mark a definition promoted and its values mirror into an index for fast lookup — the same index Magnifico audiences filter on (membership number, dietary tags, allergy notes). Definitions retire without destroying historical values.
Employee spine (STAFF)
Employees are Persons with a staff-kind group membership, not a parallel entity. A roster + profile read model backs the Demerzel /dashboard/team page, and an employee provisioner drives the lifecycle — hire / re-role / suspend / terminate — in one operation each: hiring creates the Person, the staff membership, and the Mannix-side operator access together, and termination unwinds them together. Per-operator console roles resolve from the same membership.
GDPR export & erasure
A Person's data can be exported on request, and erasure is anonymize-in-place: identifying fields are blanked while the row and its references survive, so bookings, invoices, and ledger history keep their integrity without keeping the identity.

API surface

All endpoints are versioned under /terminus/v1/. Identity writes are typically driven by application flows (signup, booking, consent capture), but the full CRUD surface is exposed for admin tooling and import.

Terminus endpoints in the Foundation API reference OpenAPI 3.1 schema for Terminus with request/response shapes, parameters, and a try-it client.
MethodPathPurpose
POST / GET/terminus/v1/personsCreate or list Persons.
GET / PATCH / DELETE/terminus/v1/persons/{id}Fetch, modify, or soft-delete a Person.
POST/terminus/v1/persons/resolveResolve or create a Person by handle (email, phone, external id).
POST / GET/terminus/v1/accountsManage tenant-scoped account handles on a Person (authentication itself lives in Mannix).
POST / GET/terminus/v1/groupsCreate or list Groups (any kind).
POST / GET / DELETE/terminus/v1/groups/{id}/membersManage GroupMember rows on a Group.
POST/terminus/v1/entitlement-checksEvaluate a tier or group entitlement for a Person.
POST / GET/terminus/v1/consentsRecord or query consent state for a Person on a scope.
POST/terminus/v1/consents/assertServer-to-server: is this scope currently consented? Returns boolean + version.
POST / GET / DELETE/terminus/v1/person-attribute-definitionsDefine, list, or retire a typed custom attribute for the tenant.
GET / PUT/terminus/v1/persons/{id}/attributesRead or set a Person's typed attributes, validated against the definitions.
POST / GET/terminus/v1/employeesHire an employee (Person + staff membership + operator access in one) or read the roster.
GET/terminus/v1/employees/{personId}An employee's profile read model.
PATCH/terminus/v1/employees/memberships/{membershipId}Re-role, suspend, or terminate a staff membership.

Example: a Gold tier and a member

Create the tier program as a Group:

POST /terminus/v1/groups
Content-Type: application/json
Authorization: Bearer <token>

{
  "kind": "tier",
  "name": "Gold",
  "metadata": { "displayName": "Gold Member" }
}

Enrol a Person with a 12-month window:

POST /terminus/v1/groups/grp_01JAB1…/members
Content-Type: application/json

{
  "personId":   "per_01JAB7…",
  "role":       "member",
  "rank":       0,
  "validFrom":  "2026-01-01T00:00:00Z",
  "validUntil": "2027-01-01T00:00:00Z",
  "status":     "active"
}

Seldon's Booking flow checks tier entitlement at quote time:

POST /terminus/v1/entitlement-checks

{
  "personId": "per_01JAB7…",
  "groupId":  "grp_01JAB1…"
}

→ { "entitled": true, "memberStatus": "active", "validUntil": "2027-01-01T00:00:00Z" }

How it fits with the rest

flowchart LR
  S[Seldon] -- checkEntitlement --> Te(Terminus)
  Sp[Speaker] -- assertConsent --> Te
  Pa[Payments] -. owner .-> Te
  M[Mallow] -. customer / employee .-> Te
  T[Trantor] -. identity for humans .-> Te
  Mx[Mannix] -. principal link .-> Te
            

Seldon stores Person and Account refs on every Booking and calls checkEntitlement against tier-gated BookingAccessRules. Trantor Resources representing humans (stylists, baristas) carry terminusPersonId and only the physical-state delta. Speaker calls assertConsent on every outbound dispatch — the gate is in Speaker, not the caller, so no primitive can route around it. Payments PaymentMethods are owned by terminusPersonId. Mallow references Persons for customer and employee identity on ledger entries. Mannix authenticates the human behind a Person — each Person links to a global Mannix principal by mannixPrincipalId, and "is this email verified?" is a Mannix fact Terminus reads but never sets.