Skip to content

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_ttlttl_seconds outside [60, 604800].
  • 409 invitation_already_pending — a pending row already exists for (domain_id, external_subject). The existing id is in the Problem detail so the operator can revoke the stale row before reissuing.
  • 422 too_many_initial_tuplesinitial_tuples exceeded the 32-entry cap.
  • 422 invitation_object_out_of_scope — an initial_tuples[].object did not match domain:<this-id> / project:<uuid> / group:<uuid>.
  • 422 invalid_caveat_context — an initial_tuples[].caveat_context failed 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 to expired.
  • 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 const bootstrap.InvitationsExpireReconcileProbeName — operators grep /readyz output 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 through bootstrap.RegisterInvitationsExpireReconcileProbe in cmd/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 /readyz to 503 in the same shape as the BootstrapToken, NSK, and audit reconcile probes.
  • The steady-state goroutine (the Sweep closure on InvitationsWiring) ticks every PLEXSPHERE_INVITATIONS_EXPIRE_TICK (default DefaultInvitationsExpireTick = 60s — see the file-level DECISION block in invitations_factory_prod.go for the cadence rationale) and calls service.ExpirePending(ctx, time.Now().UTC()) on each tick. The closure exits when its context is cancelled and uses a time.Ticker so a slow Postgres tick does not pile up parallel sweeps. Per-tick errors are logged via boot.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:

  1. Resolve (DomainID, ExternalSubject) from the id_token via idp/claims.Map.
  2. repo.AcceptInvitationCAS(ctx, tx, domainID, externalSubject, userID, now) runs an UPDATE … WHERE status='pending' AND expires_at > now and stamps (status='accepted', accepted_at, accepted_user_id) atomically.
  3. The tenancy.InvitationAccepted outbox event is appended inside the same transaction so a downstream projector can write the initial_tuples into SpiceDB; the handler emits an invitation.accept audit row alongside the user.signed_in audit 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 if expires_at has 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.InvitationAccepted is the durable trigger for the internal/identity/invitations/consumer package's projector: see internal/identity/invitations/consumer/ for the SpiceDB write-through that turns TupleObjects into 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 accepted exactly 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 = 32 is enforced by normalizeInitialTuples in invitation.go. A larger array surfaces on the HTTP surface as 422 too_many_initial_tuples .
  • Object scope is closed. Object MUST start with one of domain: / project: / group: and carry a non-empty token after the prefix. The domain:<id> form additionally MUST reference this Invitation's parent Domain — cross-Domain or platform-scoped objects (e.g. platform:, tenant:) are rejected as 422 invitation_object_out_of_scope so 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 through json.Marshal / json.Unmarshal so values that cannot be serialised losslessly (channels, functions, NaN/Inf) are rejected at construction time as 422 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.

OperationHTTP surfaceAudit relationAudit outcomeOutbox event
Identity listGET /v1/domains/{id}/identitiesidentity.listsuccess (per page) / failure (4xx)(none — read)
Identity getGET /v1/domains/{id}/identities/{principalId}identity.readsuccess / failure(none — read)
Invitation createPOST /v1/domains/{id}/invitationsinvitation.createsuccess / failure (invalid_ttl, invitation_already_pending, too_many_initial_tuples, invitation_object_out_of_scope, invalid_caveat_context)tenancy.InvitationCreated
Invitation listGET /v1/domains/{id}/invitationsinvitation.listsuccess (per page) / failure (invalid_status, invalid_cursor, invalid_limit)(none — read)
Invitation getGET /v1/domains/{id}/invitations/{invitationId}invitation.readsuccess / failure (invalid_invitation_id, invitation_not_found)(none — read)
Invitation revokeDELETE /v1/domains/{id}/invitations/{invitationId}invitation.revokesuccess / failure (invitation_already_accepted, invitation_already_expired, invitation_not_found)tenancy.InvitationRevoked
Invitation accept (JIT)OIDC GET /v1/auth/callback forkinvitation.acceptsuccess / failuretenancy.InvitationAccepted
Invitation expirebackground sweeper (repo.ExpirePending)invitation.expiresuccess (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 atTest
Identity listing (kind filter, page-by-page audit)internal/transport/http/v1/identities/list.gointernal/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.gointernal/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 + HydrateInvitationinternal/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.gotests/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 writeinvitations/events constructors + repo append helpersinternal/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.gointernal/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}.gointernal/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 upsertinternal/transport/http/v1/auth/callback.go + repo.AcceptInvitationCAS + consumer projectorinternal/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.gointernal/transport/http/v1/invitations/errors_test.go + internal/transport/http/v1/identities/list_test.go
Audit emission on every served operationservices/invitation_service.go + transport/http/v1/identities/wiring.gotests/integration/identity_invitations_audit_test.go
Performance SLOs (list p99 < target, create p99 < target)repo indexes (invitations_domain_status_created_idx) + handler hot-path budgetstests/integration/identity_invitations_perf_test.go
Documentation + traceability (this file present, every event and Problem code mentioned)this documenttests/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 through domain_id and through every initial_tuples[].object.
  • ./rebac.md — sibling bounded-context reference for the SpiceDB-backed authorisation layer. The internal/identity/invitations/consumer projector writes the consumed initial_tuples into SpiceDB on tenancy.InvitationAccepted so the granted relations land inside the same logical sign-in window.
  • ../../reference/api/invitations.md — endpoint reference for the /v1/domains/{id}/invitations surface: per-operation table, request/response schemas, error taxonomy, audit emission table.
  • ../../reference/api/identities.md — endpoint reference for the /v1/domains/{id}/identities surface: 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}/identities and /v1/domains/{id}/invitations.
  • ../../../internal/identity/invitations/ — the bounded-context package: aggregate, types, events, repository, services, consumer.