Skip to main content

iam-service — Event Schemas

Catalog · 04 Event-Driven Architecture · DOMAIN_MODEL

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

SubjectRetentionPartition Key
melmastoon.iam.user.registered.v1regulated (7y)userId
melmastoon.iam.user.email_verified.v1regulateduserId
melmastoon.iam.user.login_succeeded.v1operational (90d)userId
melmastoon.iam.user.login_failed.v1security (1y)userId|email_hash
melmastoon.iam.user.locked.v1securityuserId
melmastoon.iam.user.unlocked.v1securityuserId
melmastoon.iam.user.mfa_enrolled.v1regulateduserId
melmastoon.iam.user.mfa_removed.v1regulateduserId
melmastoon.iam.user.erased.v1regulateduserId
melmastoon.iam.session.refreshed.v1operationalsessionId
melmastoon.iam.session.revoked.v1securitysessionId
melmastoon.iam.password.reset_requested.v1securityuserId
melmastoon.iam.password.reset_completed.v1securityuserId
melmastoon.iam.password.changed.v1securityuserId
melmastoon.iam.device.registered.v1operationaldeviceId
melmastoon.iam.device.trusted.v1regulateddeviceId
melmastoon.iam.device.bound_for_offline.v1regulateddeviceId
melmastoon.iam.device.revoked.v1securitydeviceId
melmastoon.iam.apikey.issued.v1regulatedapiKeyId
melmastoon.iam.apikey.revoked.v1securityapiKeyId
melmastoon.iam.external_identity.linked.v1regulateduserId
melmastoon.iam.external_identity.unlinked.v1regulateduserId

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

AspectDetail
Outbox tableiam.outbox (see DATA_MODEL §3).
Inbox tableiam.inbox — dedup window 30 d.
Relay workeriam-worker polls outbox every 200 ms; publishes to Pub/Sub topic melmastoon.iam.<aggregate>.
OrderingPer-partitionKey ordering via Pub/Sub ordered delivery + monotonic outbox.sequence.
IdempotencyConsumers must dedup by eventId.
Retention on bus7 days; cold storage in BigQuery for regulated+security.

6. Schema Evolution Rules

ChangeAllowedHow
Add optional payload field✅ same v1Schema bump; consumers tolerate.
Add required payload fieldMint v2, dual-publish ≥ 1 milestone.
Remove fieldMint v2; deprecation period.
Rename fieldMint v2; dual-write old + new.
Tighten enumMint v2.
Loosen enumSchema bump; document.
Change partitionKey derivationNew event subject.

CI gate: event-schema-check validates every PR against committed schemas, computes diff, and refuses incompatible breaks on vN.

7. DLQ Policy

StageAction
Publish failureOutbox row stays; exponential backoff (cap 5 min).
Consumer rejectPub/Sub retries with exp backoff; after 5 attempts → DLQ topic melmastoon.iam.<aggregate>.dlq.
DLQ alertIamDLQNonEmpty (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.publish with event.id, event.subject, event.partition_key.