Domain Events
Polaris emits PSR-14 events on the framework’s Happen dispatcher for every
significant identity action. Events decouple side effects (sending emails/SMS,
writing the audit log, host integrations) from the domain services that raise
them. A host subscribes listeners via listeners:list / its config; Polaris
ships the audit-log, metrics, and notification listeners and lets hosts add
their own.
Every event class lives in Univeros\Polaris\Event\ and exposes its dotted
name as a NAME constant. The payload columns below are the constructor
properties, in order.
Conventions
Section titled “Conventions”- Event names are dotted, past-tense:
resource.action. - Each event is an immutable readonly DTO carrying ids and non-secret context
only. Events that participate in a secret-bearing flow (
user.registered,user.password_reset_requested,member.invited) do carry the one-time token so the notification listener can send it, but the audit listener never writes it: the audit metadata is an explicit whitelist. - Events are dispatched after the domain transaction commits (so listeners
never act on rolled-back state), except
*_failedevents, which are informational. ipandua(user agent) come from the request’sClientContext. The user agent is sanitized and bounded at the edge byClientContextMiddleware.
Catalog
Section titled “Catalog”Identity & lifecycle
Section titled “Identity & lifecycle”| Event | Payload (ids + context) | Typical listeners |
|---|---|---|
user.registered | userId, email, verificationToken* | send verification, audit |
user.email_verified | userId, email | audit |
user.logged_in | userId, sid, ip, ua, amr | audit |
user.login_failed | userId, ip, ua, reason | audit, alerting |
user.locked | userId, ip, until | audit, alerting, notify user |
user.password_changed | userId, method (reset|change) | audit, notify user |
user.password_reset_requested | userId, email, resetToken* | send reset, audit |
user.disabled / user.enabled | userId, actorUserId | audit |
user.deleted | userId (tombstone), actorUserId | audit |
* secret: delivered by the notification listener, never written to the audit log.
Notes:
user.logged_infires once per completed login: on the password-only path, or after the MFA gate clears.amrrecords the methods (["pwd"]or["pwd","otp"]). It carries no org context; a session is scoped to an org later viaauth.org_switched.user.login_failedfires only for a known, active, not-currently-locked account (no account-existence oracle).reasonis currently alwaysinvalid_credentials.user.lockedcarriesuntil, the lock expiry instant.
Tokens & sessions
Section titled “Tokens & sessions”| Event | Payload | Listeners |
|---|---|---|
auth.token_refreshed | userId, familyId | audit (debug) |
auth.refresh_reuse_detected | userId, familyId, ip, ua | audit + alerting |
auth.sessions_revoked | userId, ip, count, reason | audit |
auth.org_switched | userId, fromOrgId, toOrgId | audit |
Notes:
familyIdis the session id (thesidclaim): one refresh-token family is one session.auth.sessions_revokedis emitted by logout-all, withcount(revoked sessions) andreason. The password reset/change flows also revoke sessions (logout everywhere) but emit onlyuser.password_changed; subscribe to that event when you need to observe credential-change revocations.
MFA / OTP
Section titled “MFA / OTP”| Event | Payload | Listeners |
|---|---|---|
mfa.enrolled | userId, factorId, type | audit, notify user |
mfa.factor_removed | userId, factorId | audit, notify user |
mfa.verified | userId, factorId (null for recovery) | audit |
mfa.verify_failed | userId, factorId, type | audit, alerting |
mfa.step_up_completed | userId, sessionId | audit |
otp.sent | userId, factorId, channel | audit (rate-watch) |
otp.verify_failed | userId, factorId, attemptsLeft | audit, alerting |
mfa.recovery_regenerated | userId | audit, notify user |
mfa.recovery_used | userId, remaining | audit, notify user |
Notes:
mfa.enrolledfires when the user confirms their first factor (the moment MFA starts protecting the account), with the factortype(totp/sms/email).mfa.verify_failedis the gate-level signal (login MFA and step-up): it carries the attemptedfactorIdand itstype;typeisrecoveryon the factor-less recovery-code path and null for an unknown or unconfirmed factor (the audit row discloses nothing the generic API failure does not).otp.verify_failedis the channel-level signal for sms/email codes and carriesattemptsLeft, the remaining attempt budget for the live challenge.- A recovery batch is always
RecoveryCodeService::COUNT(10) codes, somfa.recovery_regeneratedneeds no count.
Organizations & RBAC
Section titled “Organizations & RBAC”| Event | Payload | Listeners |
|---|---|---|
org.created | orgId, slug, ownerUserId | audit |
org.updated | orgId, actorUserId | audit |
org.deleted | orgId, actorUserId | audit, alerting |
member.invited | orgId, email, invitedBy, inviteToken* | send invite, audit |
member.joined | orgId, userId, email | audit |
member.roles_changed | orgId, userId, roleSlugs, actorUserId | audit |
member.status_changed | orgId, userId, status, actorUserId | audit |
member.removed | orgId, userId, actorUserId | audit |
role.created | orgId, roleId, actorUserId | audit |
role.updated | orgId, roleId, actorUserId | audit |
role.deleted | orgId, roleId, actorUserId | audit |
* secret: delivered by the notification listener, never written to the audit log.
Notes:
member.status_changedcovers suspension and reactivation;statusissuspendedoractive.org.deletedis the soft delete (status=suspended); it is idempotent and fires once.
Listener wiring
Section titled “Listener wiring”// Polaris ships these; hosts may add more.bin/altair listeners:list --format=json # inspect what's subscribedbin/altair listeners:show auth.refresh_reuse_detectedAuditLogListenerwrites one append-onlyauth_audit_logrow per event: the event name, the actor and org context,ip/user_agentwhere the event carries them, and a whitelisted metadata blob. Unknown events are ignored, and a failed audit write is logged and swallowed (fail-open: audit loss must not break the user-facing operation).MetricsListenerincrements thepolaris.auth.eventscounter for every Polaris event, with the event name as an attribute.NotificationListenersubscribes to the user-facing ones (user.registered,user.password_reset_requested,member.invited,user.locked, themfa.*lifecycle events) and calls theOtpMailerInterface/SmsSenderInterfaceports.- Hosts add listeners (e.g. push
user.registeredto a CRM, alert onauth.refresh_reuse_detected) without touching Polaris.
The notification content/templates are the host’s concern via the
OtpMailerInterfacetemplate name + context; Polaris defines the event and the data, not the copy.