iam-service — Event Schemas
All events follow the platform event subject grammar: melmastoon.<service>.<aggregate>.<verb-past-tense>.v<n>. Schemas are committed to event-schemas/melmastoon/iam/<aggregate>/<verb>.v<n>.json and validated in CI on every PR. Any breaking change requires a new vN.
1. Common Envelope
Every event is wrapped in the standard envelope (Pub/Sub message attributes + payload).
interface MelmastoonEvent<P> {
eventId: string; // ULID
subject: string; // melmastoon.iam.…
eventVersion: number; // 1
occurredAt: string; // RFC 3339
publishedAt: string; // RFC 3339
producer: 'iam-service';
producerVersion: string; // semver
tenantId: string | null; // null only for platform-scoped events
partitionKey: string; // for ordering (usually userId or tenantId)
traceparent: string; // W3C
correlationId: string;
causationId?: string; // event that caused this one
idempotencyKey: string; // for downstream consumer dedup
retentionClass: 'regulated' | 'security' | 'operational' | 'analytics';
payload: P;
}
2. Subjects + Retention + Partition
| Subject | Retention | Partition Key |
|---|---|---|
melmastoon.iam.user.registered.v1 | regulated (7y) | userId |
melmastoon.iam.user.email_verified.v1 | regulated | userId |
melmastoon.iam.user.login_succeeded.v1 | operational (90d) | userId |
melmastoon.iam.user.login_failed.v1 | security (1y) | userId|email_hash |
melmastoon.iam.user.locked.v1 | security | userId |
melmastoon.iam.user.unlocked.v1 | security | userId |
melmastoon.iam.user.mfa_enrolled.v1 | regulated | userId |
melmastoon.iam.user.mfa_removed.v1 | regulated | userId |
melmastoon.iam.user.erased.v1 | regulated | userId |
melmastoon.iam.session.refreshed.v1 | operational | sessionId |
melmastoon.iam.session.revoked.v1 | security | sessionId |
melmastoon.iam.password.reset_requested.v1 | security | userId |
melmastoon.iam.password.reset_completed.v1 | security | userId |
melmastoon.iam.password.changed.v1 | security | userId |
melmastoon.iam.device.registered.v1 | operational | deviceId |
melmastoon.iam.device.trusted.v1 | regulated | deviceId |
melmastoon.iam.device.bound_for_offline.v1 | regulated | deviceId |
melmastoon.iam.device.revoked.v1 | security | deviceId |
melmastoon.iam.apikey.issued.v1 | regulated | apiKeyId |
melmastoon.iam.apikey.revoked.v1 | security | apiKeyId |
melmastoon.iam.external_identity.linked.v1 | regulated | userId |
melmastoon.iam.external_identity.unlinked.v1 | regulated | userId |
3. Published Events — Payload + Schema + Example
3.1 melmastoon.iam.user.registered.v1
interface UserRegisteredV1 {
userId: string;
tenantId: string | null;
userType: 'staff' | 'guest' | 'platform_admin';
primaryEmail: string;
emailHash: string; // sha256(tenantSalt + email)
registrationMethod: 'password' | 'oidc' | 'saml' | 'magic_link' | 'invite';
invitedBy?: string; // userId of inviter (when applicable)
registeredAt: string;
}
{
"$id": "https://schemas.melmastoon.io/iam/user/registered/v1.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["userId", "userType", "primaryEmail", "emailHash", "registrationMethod", "registeredAt"],
"properties": {
"userId": { "type": "string", "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$" },
"tenantId": { "type": ["string", "null"], "pattern": "^ten_[0-9A-HJKMNP-TV-Z]{26}$" },
"userType": { "type": "string", "enum": ["staff", "guest", "platform_admin"] },
"primaryEmail": { "type": "string", "format": "email" },
"emailHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
"registrationMethod": { "type": "string", "enum": ["password", "oidc", "saml", "magic_link", "invite"] },
"invitedBy": { "type": "string", "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$" },
"registeredAt": { "type": "string", "format": "date-time" }
},
"additionalProperties": false
}
{
"eventId": "01HZJ4...XYZ",
"subject": "melmastoon.iam.user.registered.v1",
"eventVersion": 1,
"occurredAt": "2026-04-22T10:00:00Z",
"publishedAt": "2026-04-22T10:00:00.180Z",
"producer": "iam-service",
"producerVersion": "1.4.2",
"tenantId": "ten_01HZ8X...",
"partitionKey": "usr_01HZ8XW...",
"traceparent": "00-3ab...-abc...-01",
"correlationId": "req_01HZ...",
"idempotencyKey": "01HZJ4...XYZ",
"retentionClass": "regulated",
"payload": {
"userId": "usr_01HZ8XW...",
"tenantId": "ten_01HZ8X...",
"userType": "staff",
"primaryEmail": "front-desk@hotel-pamir.example",
"emailHash": "a8f5...c1",
"registrationMethod": "password",
"registeredAt": "2026-04-22T10:00:00Z"
}
}
3.2 melmastoon.iam.user.login_succeeded.v1
interface UserLoginSucceededV1 {
userId: string;
sessionId: string;
tenantId: string | null;
deviceId: string | null;
amr: Array<'pwd' | 'totp' | 'webauthn' | 'magic_link' | 'oidc' | 'saml' | 'recovery_code'>;
acr: 'fresh-auth' | 'mfa-strong' | 'standard';
ipMasked: string;
userAgentHash: string;
riskScore?: number; // 0-100 from adaptive MFA
riskReasons?: string[];
occurredAt: string;
}
3.3 melmastoon.iam.user.login_failed.v1
interface UserLoginFailedV1 {
userId?: string; // absent if email unknown
emailHash: string;
tenantId: string | null;
reason:
| 'invalid_password'
| 'unknown_user'
| 'account_locked'
| 'mfa_invalid'
| 'mfa_ticket_expired'
| 'sso_assertion_invalid'
| 'breached_credential_match'
| 'tenant_disabled';
ipMasked: string;
userAgentHash: string;
failedAttemptsAfter?: number;
occurredAt: string;
}
3.4 melmastoon.iam.user.locked.v1
interface UserLockedV1 {
userId: string;
tenantId: string | null;
reason: 'lockout' | 'admin' | 'breached_credential' | 'compromised_session' | 'tenant_deleted';
lockedUntil: string | null; // null = manual unlock required
lockedByUserId?: string; // when reason='admin'
occurredAt: string;
}
3.5 melmastoon.iam.user.mfa_enrolled.v1
interface UserMFAEnrolledV1 {
userId: string;
tenantId: string | null;
factorId: string;
factorType: 'totp' | 'webauthn' | 'recovery_codes' | 'sms';
label: string;
enrolledAt: string;
}
3.6 melmastoon.iam.session.refreshed.v1
interface SessionRefreshedV1 {
sessionId: string;
userId: string;
tenantId: string | null;
deviceId: string | null;
generation: number;
ipMasked: string;
refreshedAt: string;
}
3.7 melmastoon.iam.session.revoked.v1
interface SessionRevokedV1 {
sessionId: string;
userId: string;
tenantId: string | null;
deviceId: string | null;
reason:
| 'logout'
| 'rotation_reuse'
| 'admin_revoke'
| 'tenant_deleted'
| 'user_locked'
| 'device_revoked'
| 'password_changed'
| 'idle_timeout'
| 'family_overflow';
familyId: string;
revokedAt: string;
}
{
"$id": "https://schemas.melmastoon.io/iam/session/revoked/v1.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["sessionId", "userId", "reason", "familyId", "revokedAt"],
"properties": {
"sessionId": { "type": "string", "pattern": "^ses_[0-9A-HJKMNP-TV-Z]{26}$" },
"userId": { "type": "string", "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$" },
"tenantId": { "type": ["string", "null"] },
"deviceId": { "type": ["string", "null"] },
"reason": { "type": "string", "enum": ["logout","rotation_reuse","admin_revoke","tenant_deleted","user_locked","device_revoked","password_changed","idle_timeout","family_overflow"] },
"familyId": { "type": "string" },
"revokedAt": { "type": "string", "format": "date-time" }
},
"additionalProperties": false
}
3.8 melmastoon.iam.device.registered.v1
interface DeviceRegisteredV1 {
deviceId: string;
userId: string;
tenantId: string;
platform: 'electron-desktop' | 'mobile-android' | 'mobile-ios' | 'web';
fingerprintHash: string;
trustedAtRegistration: boolean;
registeredAt: string;
}
3.9 melmastoon.iam.device.bound_for_offline.v1
interface DeviceBoundForOfflineV1 {
deviceId: string;
userId: string;
tenantId: string;
publicKeyJwk: { kty: 'OKP'; crv: 'Ed25519'; x: string };
certificateSerial: string;
issuingKid: string; // tenant CA kid
issuedAt: string;
expiresAt: string; // ≤ issuedAt + 7d
}
{
"$id": "https://schemas.melmastoon.io/iam/device/bound_for_offline/v1.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["deviceId","userId","tenantId","publicKeyJwk","certificateSerial","issuingKid","issuedAt","expiresAt"],
"properties": {
"deviceId": { "type": "string", "pattern": "^dev_[0-9A-HJKMNP-TV-Z]{26}$" },
"userId": { "type": "string", "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$" },
"tenantId": { "type": "string", "pattern": "^ten_[0-9A-HJKMNP-TV-Z]{26}$" },
"publicKeyJwk": {
"type": "object",
"required": ["kty","crv","x"],
"properties": {
"kty": { "const": "OKP" },
"crv": { "const": "Ed25519" },
"x": { "type": "string" }
},
"additionalProperties": false
},
"certificateSerial": { "type": "string" },
"issuingKid": { "type": "string" },
"issuedAt": { "type": "string", "format": "date-time" },
"expiresAt": { "type": "string", "format": "date-time" }
},
"additionalProperties": false
}
3.10 melmastoon.iam.apikey.issued.v1
interface APIKeyIssuedV1 {
apiKeyId: string;
userId: string;
tenantId: string;
prefix: string; // first 8 chars
scopes: string[];
propertyIds: string[];
expiresAt: string | null;
issuedAt: string;
}
3.11 melmastoon.iam.apikey.revoked.v1
interface APIKeyRevokedV1 {
apiKeyId: string;
userId: string;
tenantId: string;
reason: 'self_revoke' | 'admin_revoke' | 'rotation' | 'tenant_deleted' | 'compromise_detected';
revokedAt: string;
}
3.12 melmastoon.iam.password.reset_requested.v1
interface PasswordResetRequestedV1 {
userId: string;
tenantId: string | null;
resetTokenHash: string; // sha256, server retains hash only
expiresAt: string;
requestedAt: string;
}
3.13 melmastoon.iam.password.reset_completed.v1
interface PasswordResetCompletedV1 {
userId: string;
tenantId: string | null;
sessionsRevoked: number;
completedAt: string;
}
3.14 melmastoon.iam.user.erased.v1
interface UserErasedV1 {
userId: string;
tenantId: string | null;
subjectRequestId: string; // GDPR DSR id
rowsAnonymized: number;
erasedAt: string;
}
4. Consumed Events
4.1 melmastoon.tenant.created.v1
interface TenantCreatedV1 {
tenantId: string;
ownerEmail: string;
ownerName: string;
tier: 'starter' | 'pro' | 'enterprise';
region: 'me' | 'eu' | 'us' | 'ap';
createdAt: string;
}
Handler: handleTenantCreated (see APPLICATION_LOGIC §17.1). Provisions a pending_verification super-tenant-admin User and dispatches register-magic-link email. Idempotent on eventId.
4.2 melmastoon.tenant.deleted.v1
interface TenantDeletedV1 {
tenantId: string;
reason: 'self_serve' | 'non_payment' | 'admin' | 'gdpr';
deletedAt: string;
}
Handler: revoke all sessions, all API keys, disable all users in tenant. Emits melmastoon.iam.session.revoked.v1 (×N) + melmastoon.iam.apikey.revoked.v1 (×M).
4.3 melmastoon.tenant.guest.erasure_requested.v1
interface GuestErasureRequestedV1 {
subjectRequestId: string;
tenantId: string;
userId: string;
requestedBy: 'subject' | 'admin' | 'regulator';
legalBasis: 'gdpr_article_17' | 'ccpa_1798_105' | 'pdpl';
requestedAt: string;
}
Handler: handleGuestErasure (APPLICATION_LOGIC §17.3). Anonymizes user, deletes credentials/sessions/devices/MFA/API-keys/external-identities. Emits melmastoon.iam.user.erased.v1.
5. Outbox & Inbox
| Aspect | Detail |
|---|---|
| Outbox table | iam.outbox (see DATA_MODEL §3). |
| Inbox table | iam.inbox — dedup window 30 d. |
| Relay worker | iam-worker polls outbox every 200 ms; publishes to Pub/Sub topic melmastoon.iam.<aggregate>. |
| Ordering | Per-partitionKey ordering via Pub/Sub ordered delivery + monotonic outbox.sequence. |
| Idempotency | Consumers must dedup by eventId. |
| Retention on bus | 7 days; cold storage in BigQuery for regulated+security. |
6. Schema Evolution Rules
| Change | Allowed | How |
|---|---|---|
| Add optional payload field | ✅ same v1 | Schema bump; consumers tolerate. |
| Add required payload field | ❌ | Mint v2, dual-publish ≥ 1 milestone. |
| Remove field | ❌ | Mint v2; deprecation period. |
| Rename field | ❌ | Mint v2; dual-write old + new. |
| Tighten enum | ❌ | Mint v2. |
| Loosen enum | ✅ | Schema bump; document. |
Change partitionKey derivation | ❌ | New event subject. |
CI gate: event-schema-check validates every PR against committed schemas, computes diff, and refuses incompatible breaks on vN.
7. DLQ Policy
| Stage | Action |
|---|---|
| Publish failure | Outbox row stays; exponential backoff (cap 5 min). |
| Consumer reject | Pub/Sub retries with exp backoff; after 5 attempts → DLQ topic melmastoon.iam.<aggregate>.dlq. |
| DLQ alert | IamDLQNonEmpty (page-on-call after 5 min). |
| Replay tool | /internal/replay/event (mTLS + admin scope). |
8. Observability per Event
Every emitted event also bumps:
iam_outbox_publish_total{topic}(counter)iam_outbox_lag_seconds{topic}(gauge)- Trace span
iam.outbox.publishwithevent.id,event.subject,event.partition_key.