Skip to content

Roster invite on the sync rail — the GoTrue side-effect design call

Status: design note (not built) — awaiting a decision between Option A and Option B. Companion to wave-2-sync-migration-disposition.md: this expands its one-line offering_membership row (the last convertible REST write family) into a real disposition. Every other convertible surface is on the rail as of Slice D8; what remains REST is bytes, external-standards doors, auth/secret doors, and imperative verbs — and this family, which is blocked on exactly the question this note frames.

Two teacher REST writes survive in apps/api/src/features/teacher-offering/:

  1. POST /workflow/teacher/offerings/:offeringId/roster-invites — invite a learner by email.
  2. POST /workflow/teacher/offerings/:offeringId/memberships/:membershipId/status — membership status transitions (activate / suspend / withdraw).

They are the only client-initiated writes to offering_membership left off the rail. The reads are long converted (the roster shape, Slice C1).

Anatomy of the current invite (what the code actually does)

Section titled “Anatomy of the current invite (what the code actually does)”

createTeacherOfferingRosterInviteWithSql has two structurally different paths:

  • Existing auth user (email already in auth.users): one DB transaction — authz check, resolve the person, ensure a person_profile, upsert the offering_membership (status active) + the learner grant. No GoTrue call, no side effect. This path is an ordinary pessimistic /unit expander already; nothing blocks it.

  • New email: the server calls GoTrue POST /auth/v1/invite (service key) first. GoTrue inserts the auth.users row and sends the invite email; the auth-hooks trigger then mints the person + identity_binding. Only after that does a second DB transaction resolve the trigger-minted person, ensure the profile, and upsert the membership (status invited) + grant.

The ordering on the new-email path is forced by a foreign-key chain, not by style:

email → GoTrue creates auth.users row → trigger mints person → offering_membership.person_id

A membership row for a brand-new invitee cannot exist before the GoTrue call — there is no person_id to point at. Any design that says “write the membership row and queue the email” has the dependency backwards. What can be written first is an invite intent, keyed by email.

The status-transition endpoint (2) is a pure DB write with no side effect. It converts to a pessimistic /unit verb regardless of which option is chosen below, and should not be held hostage to the invite decision.

The dual write is: (GoTrue user + email) and (membership row). Today’s REST does them sequentially with no durable link:

  • GoTrue succeeds, then the process dies before the second transaction → the invitee got an email and an auth account, but no membership. Recovery is manual: the teacher notices and re-invites (the retry then takes the existing-user path and heals). Nothing records that a retry is needed.
  • The reverse order is impossible (the FK chain above), so “membership without email” cannot happen today — but note it becomes possible in a naive outbox design, which is why the intent row below is not a membership row.
Section titled “Option A — transactional invite-intent outbox (recommended)”

A new table, roster_invite (name TBD), owned by the sync registry:

column notes
id client-generated uuid (the /unit entity)
offering_id FK offering
invite_email normalized lowercase
display_name optional
invited_by_person_id stamped from claims
state pending → processing → completed / failed (worker-owned after pending)
failure_reason text, nullable — surfaced to the teacher on failed
membership_id FK, nullable — stamped by the worker on completion
audit + LWW columns as every rail table

Registry posture: readwrite pessimistic, teacher-manage reach (offering fan-out, the roster twin), lazy — the invite ledger is an ops-ish surface; it must not eager-boot.

  • Validates: teacher manages the offering (claims), email shape, no duplicate pending intent for (offering, email).
  • Existing-user fast path: if auth.users already holds the email, the privileged in-tx writer materializes the membership + grant + profile immediately (today’s semantics, ack = member is active) and writes the intent row directly in completed. No worker involvement, no async gap.
  • New-email path: the intent row commits as pending. That single transaction is the whole ack — the domain state (“an invite is owed”) is durable, atomically, with nothing external inside the transaction.

A second lane in the ADR-0049 worker host (the Caliper emission loop) — same shape: a long-lived Bun loop draining a Postgres queue with FOR UPDATE SKIP LOCKED, exponential backoff, a poison-row cap. Per pending row:

  1. Re-check auth.users by email (the race guard: the user may have appeared since the ack — including via a concurrent invite from another offering). If present → materialize membership in a DB transaction, mark completed.
  2. Otherwise call GoTrue /invite. On success: resolve the trigger-minted person, materialize membership (+ profile, grant) in one transaction, stamp membership_id, mark completed.
  3. On GoTrue “already registered” (the race lost between step 1 and 2): fall through to step 1’s materialization. This is what makes the worker idempotent by email uniqueness — re-running any step converges.
  4. On other failures: retry with backoff; after the cap, mark failed with failure_reason.

GoTrue never re-sends an invite for an existing user (it errors instead), so crash-and-retry cannot double-email; the only at-most-once risk is “email sent, crash before completed”, which the next drain heals at step 1 without a second email.

The intent row itself syncs (teacher reach), so the UI renders the invite ledger live off the rail: pending (“invite queued”), completed (and the membership row appears in the roster shape via Electric within the same breath), failed (with reason + a re-invite affordance = a new intent). No polling endpoint, no REST read.

failure today (REST) Option A
crash after GoTrue, before membership email sent, no membership, no record; manual teacher retry impossible to lose: intent row is still pending/processing; next drain materializes, no second email
GoTrue down teacher gets a 5xx, retries by hand ack already succeeded; worker retries with backoff; failed + reason after cap
duplicate invite (same offering+email) second call takes existing-user path (harmless) expander rejects while pending; after completion a re-invite is a status question, not an invite
concurrent invites, two offerings, same new email both call GoTrue; loser gets “already registered” mid-flight and 503s both intents queue; worker serializes; loser’s step-3 fallthrough materializes its membership
teacher offline impossible intent queues offline and flushes later — a genuine capability gain
  • The ack means “invite durably owed”, not “email sent”. The UI must say so (the pending state does).
  • A new table + worker lane (bounded: the 0049 host, drain SQL, and state machine already exist as a pattern to copy).
  • The invite ledger is a new synced surface to review for reach (teacher-scoped, lazy; the email column is the invitee’s own email being typed by the teacher — no new exposure).

Option B — keep the invite as an auth-door REST verb

Section titled “Option B — keep the invite as an auth-door REST verb”

Classify roster-invites with the auth doors (claim tokens, identity-binding reads): it brokers auth-substrate state (creates auth.users rows via service key), so it stays REST; only the side-effect-free membership-status verb converts to /unit.

  • Pro: synchronous semantics — a 200 means the email is out and the membership exists. No new table, no worker lane, no async state to render.
  • Pro: defensible under the campaign taxonomy — this is an auth-substrate mutation, and the campaign already exempts those.
  • Con: the dual-write failure mode stays exactly as it is today (email-sent-no-membership with no durable record), relying on manual teacher retry.
  • Con: the roster surface stays split: reads and status writes on the rail, the invite on REST — two auth paths, two error surfaces, and no offline invites.
  • Con: the one campaign-wide asymmetry it preserves: offering_membership becomes the only client-authored table whose creation path is REST while its mutation path is sync.

Option A. The deciding argument is the failure-mode table: the outbox is not machinery invented to make sync possible — it fixes an unrecorded-divergence window the REST door has today, and the 0049 worker host means the marginal infrastructure is one queue lane, not a new architecture. The async gap is real but honest (pending is the truth: the email is owed, not sent), and the existing-user fast path keeps the common same-org case fully synchronous.

Either way, the membership-status verb converts now as a plain pessimistic /unit — it has no side effect and no reason to wait for this decision.

Pending (Anton).