Appearance
Identity and Invitations — pending → accepted/revoked/expired
This document is the authoritative bounded-context reference for the identity-listing surface and the Invitation aggregate that ship under internal/identity/invitations, internal/transport/http/v1/identities, and internal/transport/http/v1/invitations. It covers the Invitation state machine and its absorbing terminal states, the operator runbook for staging / monitoring / revoking / sweeping pending invitations and accepting them just-in-time during OIDC sign-in, the initial-tuple contract the Invitation projects into SpiceDB on acceptance, the audit + outbox emission table, and the invariant- to-test matrix that ties every requirement to at least one automated test.
For the bounded-context siblings see ./idp.md (per-Domain IdP binding, User, UserSession, ServiceIdentity, APIToken aggregates under internal/identity/{idp,users,services,tokens,authn} — the JIT acceptance fork in the OIDC sign-in callback documented below), ./tenancy.md (Domain, Project, Resource, Node aggregates that the Invitation references through domain_id and through every initial_tuples[].object), and ./rebac.md (the SpiceDB-backed authorisation layer the projector writes the consumed initial_tuples into when the Invitation transitions to accepted).
For the persisted schema, see internal/platform/db/migrations/0015_identity_invitations.sql. For the OpenAPI contract, see api/openapi/plexsphere-v1.yaml under /v1/domains/{id}/identities and /v1/domains/{id}/invitations.
State machine
The Invitation aggregate's Status walks monotonically through four states. pending is the initial state every fresh row enters; the three terminal states (accepted, revoked, expired) are absorbing — once entered, no transition out of them is permitted. The aggregate's Accept / Revoke / Expire methods return one of ErrAlreadyAccepted / ErrAlreadyRevoked / ErrAlreadyExpired when called against a non-pending row, and the SQL CAS predicates in internal/identity/invitations/repo/invitation_repo.go enforce the same monotonicity under concurrent transactions.
text
+-----------+
| pending | initial state, expires_at > now()
+-----+-----+
|
+----------------+----------------+
| | |
Accept(now) Revoke(now) Expire(now)
OIDC sign-in operator DEL sweeper now>=expires_at
(REQ-008) (REQ-007) (REQ-009)
| | |
v v v
+-------------+ +-------------+ +-------------+
| accepted | | revoked | | expired |
+-------------+ +-------------+ +-------------+
absorbing absorbing absorbing
(no exits) (no exits) (no exits)Cross-aggregate uniqueness is enforced at the SQL layer by the partial unique index invitations_pending_domain_subject_uq on (domain_id, external_subject) WHERE status='pending' — at most one pending row per (Domain, external_subject) pair, while any number of historical accepted / revoked / expired rows for the same pair may coexist so an operator can reissue an invitation after a typo or a TTL miss without erasing the audit trail. A repository INSERT that races a sibling pending row surfaces as 409 invitation_already_pending and the existing row's id flows through the Problem detail so the operator can revoke the stale one.
The aggregate is hydrated through HydrateInvitation in internal/identity/invitations/invitation.go; the constructor enforces the status-stamp coupling so a half-written row that flipped status='accepted' without stamping accepted_at cannot sneak past the aggregate boundary.
Runbook
This runbook walks an operator through staging an Invitation, monitoring pending invitations, revoking, the background expiry sweeper, and the JIT acceptance fork in the OIDC sign-in callback. Every step references the audit relation it stamps onto the audit sink and the outbox event it appends in the same transaction .
1. Stage an Invitation
POST /v1/domains/{id}/invitations with an InvitationCreateRequest body carrying external_subject (the IdP sub hint, ≤256 chars), ttl_seconds (clamped to [60, 604800]; default 86400), and an optional initial_tuples array (≤32 entries, each scoped to domain:<this-id> / project:<uuid> / group:<uuid>). The handler emits an invitation.create audit row and the service appends a tenancy.InvitationCreated outbox event in the same transaction .
Failure modes the surface returns:
400 invalid_body— body is not valid JSON.400 invalid_ttl—ttl_secondsoutside[60, 604800].409 invitation_already_pending— a pending row already exists for(domain_id, external_subject). The existing id is in the Problemdetailso the operator can revoke the stale row before reissuing.422 too_many_initial_tuples—initial_tuplesexceeded the 32-entry cap.422 invitation_object_out_of_scope— aninitial_tuples[].objectdid not matchdomain:<this-id>/project:<uuid>/group:<uuid>.422 invalid_caveat_context— aninitial_tuples[].caveat_contextfailed the JSON-encodability round-trip.
A failed Create still emits an invitation.create audit row with outcome=failure so the audit stream captures the rejection .
2. Monitor pending invitations
GET /v1/domains/{id}/invitations?status=pending returns the cursor-paginated page of currently-pending invitations attached to the Domain. The handler stamps an invitation.list audit row per served page; it does NOT emit an outbox event (read-side operations never write to the outbox). The status query parameter accepts pending / accepted / revoked / expired / all (default all); a value outside that closed set surfaces as 400 invalid_status. limit is clamped to [1, 200] (default 50), cursor is the opaque HMAC-signed continuation token from the previous page.
Per-row reads use GET /v1/domains/{id}/invitations/{invitationId} which emits an invitation.read audit row on every served byte. A caller that supplies a non-UUID path receives 400 invalid_invitation_id; a caller addressing a row in a different Domain or a deleted row receives 404 invitation_not_found .
3. Revoke a pending invitation
DELETE /v1/domains/{id}/invitations/{invitationId} flips a pending row to revoked via the SQL CAS predicate status='pending' and stamps revoked_at = now() atomically. On success the handler emits an invitation.revoke audit row and the service appends a tenancy.InvitationRevoked outbox event in the same transaction.
The CAS surfaces the absorbing-state guarantee on the wire:
409 invitation_already_accepted— the row was consumed by the OIDC sign-in callback before the DELETE landed.409 invitation_already_expired— the sweeper had already flipped the row toexpired.404 invitation_not_found— the row never existed in this Domain or was hard-deleted out of band.
Revoking an already-revoked row is idempotent at the repository layer — the CAS no-ops without raising and the handler returns 204 No Content so the operator runbook can be replayed safely .
4. Background expiry sweeper
The expiry sweeper is a background goroutine that periodically calls service.ExpirePending(ctx, now) (which delegates to repo.ExpirePending(ctx, now)) to flip every pending row whose expires_at <= now() to expired, stamping expired_at = now() atomically. For each affected row the repo appends a tenancy.InvitationExpired outbox event in the same transaction and the service emits a single sweep-level invitation.expire audit row carrying the rolled-up item_count so a 1000-row sweep produces ONE audit row at the service boundary.
The aggregate's Expire method does NOT itself check that expires_at has elapsed — the sweeper SQL keys on the predicate and the aggregate trusts the caller. This split keeps the aggregate pure (no time source) while the SQL layer remains the single source of truth for "which rows are due to flip".
Production wiring
The composition root in cmd/plexsphere/invitations_factory_prod.go builds two parallel sweep paths off the same *invservices.InvitationService:
- The
invitations-expire/readyz probe (the literal probe name is exported as the constbootstrap.InvitationsExpireReconcileProbeName— operators grep/readyzoutput for this string, so the const is the canonical anchor and a future rename must update both this paragraph and the const in lockstep) is registered throughbootstrap.RegisterInvitationsExpireReconcileProbeincmd/plexsphere/app.go. It runs the sweep once at boot (so an elapsed pending row left behind by a previous outage flips before the listener binds) and re-runs it on every probe tick. A Postgres outage that prevents the sweep flips/readyzto 503 in the same shape as the BootstrapToken, NSK, and audit reconcile probes. - The steady-state goroutine (the
Sweepclosure onInvitationsWiring) ticks everyPLEXSPHERE_INVITATIONS_EXPIRE_TICK(defaultDefaultInvitationsExpireTick = 60s— see the file-level DECISION block ininvitations_factory_prod.gofor the cadence rationale) and callsservice.ExpirePending(ctx, time.Now().UTC())on each tick. The closure exits when its context is cancelled and uses atime.Tickerso a slow Postgres tick does not pile up parallel sweeps. Per-tick errors are logged viaboot.Logger.Warn("invitations expire sweep failed", …)rather than propagated — the /readyz probe is the load-bearing health signal, the steady-state ticker is the lag-vs-ttl floor.
A nil InvitationsWiring (operator has not opted into production wiring by setting PLEXSPHERE_DSN) skips both arms; an unconfigured binary boots without the sweep, and a re-staged invitation for a recently-expired (domain_id, external_subject) pair surfaces 409 invitation_already_pending until an operator manually clears the row.
5. JIT acceptance through OIDC sign-in
When a User signs in via the per-Domain OIDC binding (see ./idp.md §"Sign a test user in"), the callback at internal/transport/http/v1/auth/callback.go forks: if a pending Invitation matches (domain_id, external_subject) resolved from the verified id_token's sub claim, the User upsert and the Invitation acceptance run as one CAS UPDATE inside one transaction. The sequence is:
- Resolve
(DomainID, ExternalSubject)from the id_token viaidp/claims.Map. repo.AcceptInvitationCAS(ctx, tx, domainID, externalSubject, userID, now)runs anUPDATE … WHERE status='pending' AND expires_at > nowand stamps(status='accepted', accepted_at, accepted_user_id)atomically.- The
tenancy.InvitationAcceptedoutbox event is appended inside the same transaction so a downstream projector can write theinitial_tuplesinto SpiceDB; the handler emits aninvitation.acceptaudit row alongside theuser.signed_inaudit row already produced by the IdP runbook.
Failure modes:
- The CAS returns "no rows affected" if the row has already been consumed (
accepted/revoked/expired) or ifexpires_athas elapsed inside the transaction. The sign-in handler treats this as "no JIT acceptance happened on this sign-in" — the User upsert still completes so an established User who happens to have no live invitation is not blocked from signing in. - A consumer-side replay of
tenancy.InvitationAcceptedis the durable trigger for theinternal/identity/invitations/consumerpackage's projector: seeinternal/identity/invitations/consumer/for the SpiceDB write-through that turnsTupleObjectsinto relation tuples.
Chosen ACID contract for accept-during-sign-in
The OIDC callback runs Users.UpsertWithBinding and the InvitationConsumer.AcceptIfPending call in two independent transactions, executed sequentially in that order. The repo layer's per-method txBeginner (UpsertWithBinding opens its own tx, repo.AcceptDuringSignIn opens its own) makes a shared single-tx posture infeasible without a unit-of-work facade that the surface explicitly defers.
The contract is convergent — not strictly atomic — and the following invariants hold under the two-tx split:
- User upsert is idempotent on
(DomainID, ExternalSubject). Repeated upserts converge on the same row. - Invitation accept is gated by a CAS predicate (
status='pending' AND expires_at > now()). Repeated attempts converge on at-most-one acceptance. - Crash-after-upsert-before-accept leaves the User row durable and the Invitation row untouched. The next sign-in reattempts the accept path on the same pending row; the CAS guarantees exactly-once consumption.
- Crash-during-accept leaves the User row durable and the Invitation row untouched (the inner tx rolls back). A retry drives the row to
acceptedexactly once.
Forensic-trail caveat. The tenancy.InvitationAccepted outbox row and the User row may land in different tx_xids. Downstream consumers that reason about the "single tx" boundary MUST join by (invitation_id, accepted_user_id) rather than by tx_xid. The audit pipeline already does this; the projector at internal/identity/invitations/consumer/ does not depend on a shared tx_xid either.
Future tightening. The single-tx_xid posture originally sketched for this surface is the unit-of-work facade track. Until it lands the contract documented in this section is what the surface advertises; a reviewer hitting the DECISION block in internal/transport/http/v1/auth/callback.go finds the same trade-off pinned to the source.
Initial tuples
InitialTuple (defined in internal/identity/invitations/types.go) is the immutable list of relation grants the Invitation will project into SpiceDB atomically when the Invitation transitions to accepted. The aggregate stores the tuples on the row as JSON and the persistence layer round-trips them through the initial_tuples jsonb column on plexsphere.invitations.
Three caps and shape rules apply at the aggregate boundary:
- Cap of 32 entries.
MaxInitialTuples = 32is enforced bynormalizeInitialTuplesininvitation.go. A larger array surfaces on the HTTP surface as422 too_many_initial_tuples. - Object scope is closed.
ObjectMUST start with one ofdomain:/project:/group:and carry a non-empty token after the prefix. Thedomain:<id>form additionally MUST reference this Invitation's parent Domain — cross-Domain or platform-scoped objects (e.g.platform:,tenant:) are rejected as422 invitation_object_out_of_scopeso an operator typo cannot leak access outside the Domain the Invitation addresses. - Caveat context is JSON-encodable losslessly.
CaveatContext(when non-nil) is round-tripped throughjson.Marshal/json.Unmarshalso values that cannot be serialised losslessly (channels, functions, NaN/Inf) are rejected at construction time as422 invalid_caveat_context. nil and the empty map are equivalent and both encoded as JSON null on the wire.
The tuples are write-only state — they are consumed atomically on acceptance and never queried after — which is why the schema stores them as a single jsonb column rather than a child table (see the DECISION block in 0015_identity_invitations.sql). The projector that writes the tuples into SpiceDB on tenancy.InvitationAccepted is documented under ./rebac.md; the projector reads the denormalised TupleObjects field from the tenancy.InvitationAccepted event (see also tenancy.InvitationCreated.TupleObjects for the create-time wire shape).
Audit & outbox contract
Every operation on the Invitation surface (and the sibling identity- listing surface) stamps exactly one audit row and, for write operations, appends exactly one outbox event in the same transaction. The table below pins the relation / outcome / event contract; the relation strings live as constants in internal/identity/invitations/services/invitation_service.go and internal/transport/http/v1/identities/wiring.go so a code reader can grep AuditRelationInvitation* / auditRelationIdentity* back to the doc.
| Operation | HTTP surface | Audit relation | Audit outcome | Outbox event |
|---|---|---|---|---|
| Identity list | GET /v1/domains/{id}/identities | identity.list | success (per page) / failure (4xx) | (none — read) |
| Identity get | GET /v1/domains/{id}/identities/{principalId} | identity.read | success / failure | (none — read) |
| Invitation create | POST /v1/domains/{id}/invitations | invitation.create | success / failure (invalid_ttl, invitation_already_pending, too_many_initial_tuples, invitation_object_out_of_scope, invalid_caveat_context) | tenancy.InvitationCreated |
| Invitation list | GET /v1/domains/{id}/invitations | invitation.list | success (per page) / failure (invalid_status, invalid_cursor, invalid_limit) | (none — read) |
| Invitation get | GET /v1/domains/{id}/invitations/{invitationId} | invitation.read | success / failure (invalid_invitation_id, invitation_not_found) | (none — read) |
| Invitation revoke | DELETE /v1/domains/{id}/invitations/{invitationId} | invitation.revoke | success / failure (invitation_already_accepted, invitation_already_expired, invitation_not_found) | tenancy.InvitationRevoked |
| Invitation accept (JIT) | OIDC GET /v1/auth/callback fork | invitation.accept | success / failure | tenancy.InvitationAccepted |
| Invitation expire | background sweeper (repo.ExpirePending) | invitation.expire | success (per row) | tenancy.InvitationExpired |
Identity-listing surfaces additionally surface 400 invalid_kind (when kind is not user / service-identity), 400 invalid_principal_id (when {principalId} is not a non-zero UUID), and 404 identity_not_found (when the principal does not exist in this Domain). Each of these triggers the identity.list or identity.read audit relation with outcome=failure so the audit stream captures the rejection.
Every Problem detail on this surface carries a structured trailer so a reviewer can grep production logs back to the originating requirement.
Outbox payload security invariant
The outbox event payloads for the four invitation events (tenancy.InvitationCreated, tenancy.InvitationAccepted, tenancy.InvitationRevoked, tenancy.InvitationExpired) are pseudonym-only: NO event payload field carries the plaintext IdP subject (external_subject). The outbox event stream — and every JetStream subscriber, archiver, and SIEM consumer that reads it — sits outside the per-Domain pseudonymisation boundary that the audit context protects. Any consumer that needs to render the originating subject joins plexsphere.invitations by invitation_id and applies audit.Pseudonymise itself; the per-Domain pepper that audit.Pseudonymise reads through stays inside the boundary.
A future contributor adding a new payload field to ANY of the four invitation events MUST satisfy this rule — see the SECURITY INVARIANT block on internal/identity/invitations/events/events.go and the regression gate TestNewInvitationCreated_PayloadOmitsPlaintextExternalSubject in the sibling test file. The external_subject plaintext is deliberately excluded from the tenancy.InvitationCreated constructor signature so a callsite cannot accidentally re-introduce it.
Invariant Matrix
Every invariant the Invitation context enforces is backed by at least one automated test. When a row lists multiple enforcement layers, the later layers are belt-and-braces — the earlier one is authoritative.
| Invariant (REQ-id) | Enforced at | Test |
|---|---|---|
| Identity listing (kind filter, page-by-page audit) | internal/transport/http/v1/identities/list.go | internal/transport/http/v1/identities/list_test.go + tests/integration/identity_listing_test.go |
| Identity by-id read (404 hides existence side-channel under 403) | internal/transport/http/v1/identities/get.go | internal/transport/http/v1/identities/get_test.go + tests/integration/identity_listing_test.go |
| Invitation aggregate invariants (DomainID non-zero, ExternalSubject trimmed/≤256, InitialTuples ≤32, ExpiresAt > CreatedAt, status-stamp coupling) | invitations.NewInvitation + HydrateInvitation | internal/identity/invitations/invitation_test.go |
Migration + schema (plexsphere.invitations, partial unique index invitations_pending_domain_subject_uq, status CHECK, expires_after_created CHECK, status-stamp pair CHECKs) | internal/platform/db/migrations/0015_identity_invitations.sql + internal/identity/invitations/repo/invitation_repo.go | tests/integration/identity_invitations_migration_test.go + internal/identity/invitations/repo/invitation_repo_test.go |
Outbox events (tenancy.InvitationCreated, tenancy.InvitationAccepted, tenancy.InvitationRevoked, tenancy.InvitationExpired) appended in same transaction as the aggregate write | invitations/events constructors + repo append helpers | internal/identity/invitations/events/events_test.go + internal/identity/invitations/repo/invitation_repo_test.go |
POST /v1/domains/{id}/invitations create path (TTL clamping, initial-tuple validation, audit + outbox emission, 409 invitation_already_pending) | internal/transport/http/v1/invitations/create.go + services/invitation_service.go | internal/transport/http/v1/invitations/create_test.go + internal/identity/invitations/services/invitation_service_test.go |
GET list / GET by-id / DELETE revoke surface (cursor paging, status filter, 404 invitation_not_found, 409 invitation_already_*) | internal/transport/http/v1/invitations/{list,get,delete}.go | internal/transport/http/v1/invitations/list_test.go + internal/transport/http/v1/invitations/get_test.go + internal/transport/http/v1/invitations/delete_test.go |
JIT acceptance + repo.AcceptInvitationCAS atomic with User upsert | internal/transport/http/v1/auth/callback.go + repo.AcceptInvitationCAS + consumer projector | internal/transport/http/v1/auth/callback_test.go + internal/identity/invitations/consumer/consumer_test.go + tests/e2e/identity/invitations/chainsaw-test.yaml |
| Expiry sweeper + concurrent CAS (no double-flip across racing transactions) | repo.ExpirePending + per-row CAS predicates WHERE status='pending' | internal/identity/invitations/repo/invitation_repo_concurrency_test.go |
HTTP / Problem mapping for every Invitation code (invalid_kind, invalid_principal_id, identity_not_found, invalid_status, invalid_invitation_id, invalid_ttl, too_many_initial_tuples, invitation_object_out_of_scope, invalid_caveat_context, invitation_already_pending, invitation_already_accepted, invitation_already_expired, invitation_not_found) | internal/transport/http/v1/invitations/errors.go + internal/transport/http/v1/identities/list.go | internal/transport/http/v1/invitations/errors_test.go + internal/transport/http/v1/identities/list_test.go |
| Audit emission on every served operation | services/invitation_service.go + transport/http/v1/identities/wiring.go | tests/integration/identity_invitations_audit_test.go |
| Performance SLOs (list p99 < target, create p99 < target) | repo indexes (invitations_domain_status_created_idx) + handler hot-path budgets | tests/integration/identity_invitations_perf_test.go |
| Documentation + traceability (this file present, every event and Problem code mentioned) | this document | tests/docs/invitations_doc_drift_test.go |
Cross-references
./idp.md— sibling bounded-context reference for the per-Domain IdP binding, User, UserSession, ServiceIdentity, and APIToken aggregates. The OIDC sign-in callback's JIT acceptance fork that consumes a pending Invitation atomically with the User upsert is documented under "Sign a test user in" ../tenancy.md— sibling bounded-context reference for the Domain, Project, Resource, Node aggregates the Invitation references throughdomain_idand through everyinitial_tuples[].object../rebac.md— sibling bounded-context reference for the SpiceDB-backed authorisation layer. Theinternal/identity/invitations/consumerprojector writes the consumedinitial_tuplesinto SpiceDB ontenancy.InvitationAcceptedso the granted relations land inside the same logical sign-in window.../../reference/api/invitations.md— endpoint reference for the/v1/domains/{id}/invitationssurface: per-operation table, request/response schemas, error taxonomy, audit emission table.../../reference/api/identities.md— endpoint reference for the/v1/domains/{id}/identitiessurface: per-operation table, request/response schemas, error taxonomy, audit emission table.../../how-to/identity/invite-an-operator.md— operator how-to: stage an invitation, hand the operator the sign-in URL, verify acceptance through the list + audit surfaces, revoke if needed.../../../api/openapi/plexsphere-v1.yaml— OpenAPI contract for/v1/domains/{id}/identitiesand/v1/domains/{id}/invitations.../../../internal/identity/invitations/— the bounded-context package: aggregate, types, events, repository, services, consumer.