The previous slice (0010 — Manual Enrollment) let the Instructor grant a course to people who already have an account. This one closes the other half: bringing in people who don’t. A coach migrating their existing customers, a partner sending a cohort, a buyer from an off-platform sale — none of them should have to register first and then be enrolled.
And here’s where the plan changed. The 0010 post foreshadowed this item as “the Profile aggregate, Invited → Active via magic link, account creation, the GDPR decline path.” We grilled that design before writing code — and threw most of it out.
The design we didn’t build
The obvious model: when you invite [email protected], pre-create an unconfirmed shell account, attach a Profile, record an Invited Enrollment, and email a magic-link accept token. It works, but every piece drags a cost behind it:
- The existing magic-link token is valid 15 minutes — fine for “log me in,” absurd for “accept this invite next week.” So invites need a longer-lived token, which means an expiry, which means a resend path.
- The
userstable fills with never-confirmed shells. - The Enrollment aggregate grows an
Invited/Activestatus it otherwise never needed. - And a forwarded accept-link lets whoever clicks it take the seat.
The model we built — entitlement, not shell
An invite creates no account, no Profile, no token. It’s a standing Invitation keyed by (email, course), plus the display name the Instructor supplied and who issued it. It does not expire. The invite email carries an ordinary, non-expiring link to normal sign-up — it grants nothing by itself.
The security rests on email confirmation, not the link. An Invitation is claimed only when an account for that exact email is confirmed (by any route). At that moment each matching Invitation becomes an active Enrollment, a Profile is created and seeded with the supplied name, and the Invitation is consumed.
The payoff is that access is email-bound by construction: forwarding the invite to a friend gives them nothing — a different address that signs up has no Invitation, so no access. No tokens, no expiry, no resend, no shell accounts. This is recorded as ADR 0009 in the repo, and it also let us delete the Invited/Active status entirely — every Enrollment is simply active.
What was built
On the “Manually enroll students” screen of a Published course, the Instructor can now:
- Add a person by name + email, one at a time, into a live preview
- See each row classified as will enroll (existing account), will invite (no account yet), already enrolled, already invited, or invalid (missing name, malformed email, or a duplicate in the batch) — with running valid / skip / invalid counts
- Remove a row or clear the list
- Send — disabled at zero valid rows — and land on a summary of enrolled / invited / skipped / new total
Existing accounts are enrolled immediately and notified (the 0010 path). Everyone else gets an Invitation and a sign-up invitation email. Both paths are idempotent: re-sending to an already-enrolled person, or re-inviting an already-invited email, is a silent skip — no duplicate, no second email.
Add → classify → send
sequenceDiagram
actor Instructor
participant LV as EnrollLive
participant Enr as Forgia.Enrollments
participant DB as Repo
participant Mail as UserNotifier
Instructor->>LV: type name + email, Enter
LV->>Enr: preview_status(course, name, email, batch)
Enr->>DB: confirmed account? enrolled? invited?
Enr-->>LV: :will_enroll | :will_invite | :already_enrolled | :already_invited | {:invalid, reason}
LV-->>Instructor: row appended, counts updated, fields cleared
Instructor->>LV: click "Send to N people"
loop each sendable row
alt confirmed account, not enrolled
LV->>Enr: manual_enroll → enroll/2 + enrollment notification
else no confirmed account
LV->>Enr: manual_enroll → create Invitation + invitation email
end
end
LV-->>Instructor: summary (enrolled / invited / skipped / before→after)
Classification (preview_status/4) and the write (manual_enroll/4) both live in Forgia.Enrollments; EnrollLive stays thin. The invitation email contains only a /users/register link — no token — so there is nothing to leak or expire.
Claim on confirmation
The other half of the model lives nowhere near the enroll screen. It fires whenever an account becomes confirmed — which, in this passwordless app, is exactly Accounts.login_user_by_magic_link/1:
sequenceDiagram
actor Person
participant Auth as Accounts (confirm)
participant Enr as Forgia.Enrollments
participant DB as Repo
Person->>Auth: confirm account for [email protected]
Auth->>Enr: claim_invitations(user)
Enr->>DB: load all Invitations for that email
loop each Invitation
Enr->>DB: enroll/2 (idempotent)
Enr->>DB: ensure Profile, seeded with the invited name
Enr->>DB: delete the Invitation (consumed)
end
Enr-->>Person: lands enrolled, profile named
Because the claim is gated on confirming that email, a forwarded invite is harmless — there’s no Invitation for the forwarder’s address.
Domain model
erDiagram
User {
int id
string email
datetime confirmed_at
}
Profile {
int id
int user_id
string name
}
Course {
int id
string title
string status
string visibility
}
Enrollment {
int id
int user_id
int course_id
}
Invitation {
int id
string email
string name
int course_id
int invited_by_id
}
User ||--o| Profile : "has one"
User ||--o{ Enrollment : "enrolled via"
Course ||--o{ Enrollment : "has"
Course ||--o{ Invitation : "pending grant"
User ||--o{ Invitation : "issued by"
The Invitation is the pending grant; once claimed it disappears, replaced by an Enrollment and a Profile. The Profile holds personal data (name) in a separate aggregate from the authentication account — kept from ADR 0008, so personal data can be erased without touching enrollment history. There is still no status column on Enrollment: with the entitlement model there’s no Invited state to store, so the field 0010 deferred never needed to land.
Screenshots
Live preview with every classification

Five rows, five outcomes: "will enroll" (existing account), "will invite" (no account yet), and three invalid — malformed email, missing name, and a duplicate in the batch. The summary counts 2 valid / 0 skip / 3 invalid; the button reads "Send to 2 people".
Success summary

After sending: 1 enrolled, 1 invited, 0 skipped, total 0 → 1. Enrolled and invited are counted separately, because they mean different things — one has access now, the other will the moment they confirm.
Two different emails

The existing account gets a plain "you've been enrolled" notification; the newcomer gets an invitation with a non-expiring sign-up link — and no magic token anywhere.
Renders at 320px

The Studio sidebar collapses and the chrome tightens so the whole flow stays usable on a narrow phone — a fix that came straight out of verification (see below).
Acceptance criteria
- Open "Manually enroll students" from a Published course, showing course context
- Add a person by name + email into a live preview, one at a time
- Each row flagged will enroll / will invite / already enrolled / already invited / invalid, with running counts
- Remove a row or clear the list; Send disabled at zero valid rows
- Success summary shows enrolled / invited / skipped / new total
- Reject manual enrollment into a Draft course; allow any visibility of a Published course
- Existing confirmed account → Enrollment + notification email (the 0010 path)
- No confirmed account → a standing, non-expiring Invitation + an invitation email; no account, no Profile yet
- Idempotent: re-enroll and re-invite are skips — no duplicate row, no second email
- On confirming an account, every Invitation for that exact email is claimed → Enrollment + seeded Profile, Invitation consumed
- Email-bound: a different address that signs up gets no access; a name for an existing account is ignored (no silent rename)
- Only the Instructor can reach and use the screen; keyboard-navigable and renders at 320px
Key decisions
Entitlement over shell-account (ADR 0009) — Modelling the pending grant as a standing Invitation keyed by (email, course) — rather than a pre-created unconfirmed account — removed tokens, expiry, resend machinery, and shell-account clutter in one move. Security rests on email confirmation, so a forwarded invite is worthless to anyone but the invited address.
The claim hook lives at confirmation, not at invite — claim_invitations/1 runs inside login_user_by_magic_link, the single point where an account becomes confirmed in this passwordless app. Invite and claim are fully decoupled: someone can be invited today and sign up organically months later and still land enrolled.
Profile created at claim, GDPR deferred — The structural Profile split (ADR 0008) stays, but the decline/erasure tooling it was meant to enable is explicitly out of scope for now (PDR 0001 addendum). We kept data minimization — name + email only — without building the erasure surface yet.
A responsive bug the test suite couldn’t see — 266 green tests said the feature worked; running it at 320px said otherwise. The Studio sidebar never collapsed on mobile (the design system hides it below 1000px, but the rule targeted a class the app didn’t use), so the form was clipped. Found by screenshotting the real screen during verification, fixed in CSS, re-verified. “Tests pass” and “works in the browser” remain different claims — and the second one is the one that ships.
Next up: PBI 0150 — Bulk paste & personal note (CSV import with a 40-row cap, and a transient note from the Instructor on the invitation).