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.
The endpoints in question
Section titled “The endpoints in question”Two teacher REST writes survive in apps/api/src/features/teacher-offering/:
POST /workflow/teacher/offerings/:offeringId/roster-invites— invite a learner by email.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 aperson_profile, upsert theoffering_membership(statusactive) + the learner grant. No GoTrue call, no side effect. This path is an ordinary pessimistic/unitexpander already; nothing blocks it. -
New email: the server calls GoTrue
POST /auth/v1/invite(service key) first. GoTrue inserts theauth.usersrow and sends the invite email; the auth-hooks trigger then mints theperson+identity_binding. Only after that does a second DB transaction resolve the trigger-minted person, ensure the profile, and upsert the membership (statusinvited) + 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_idA 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 failure mode both options must answer
Section titled “The failure mode both options must answer”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.
Option A — transactional invite-intent outbox (recommended)
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.
Write path (the /unit expander)
Section titled “Write path (the /unit expander)”- Validates: teacher manages the offering (claims), email shape, no duplicate pending intent for (offering, email).
- Existing-user fast path: if
auth.usersalready 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 incompleted. 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.
The worker
Section titled “The worker”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:
- Re-check
auth.usersby 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, markcompleted. - Otherwise call GoTrue
/invite. On success: resolve the trigger-minted person, materialize membership (+ profile, grant) in one transaction, stampmembership_id, markcompleted. - 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.
- On other failures: retry with backoff; after the cap, mark
failedwithfailure_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.
What the teacher sees
Section titled “What the teacher sees”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-mode table
Section titled “Failure-mode table”| 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
pendingstate 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_membershipbecomes the only client-authored table whose creation path is REST while its mutation path is sync.
Recommendation
Section titled “Recommendation”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.
Decision
Section titled “Decision”Pending (Anton).