Skip to main content

Events

:::info Source Sourced from services/tenant-service/EVENT_SCHEMAS.md in the documentation repo. :::

Blueprint doc 5 of 17. Companion: 04 Event-Driven | APPLICATION_LOGIC


1. Envelope

Every event uses the platform EventEnvelope (Frozen at F01, M0 end):

interface EventEnvelope<T> {
eventId: ULID;
eventType: string; // e.g., 'tenant.org.provisioned'
eventVersion: number; // 1, 2, …
schemaUri: string; // 'schemas://tenant/org/provisioned/v1#sha256-...'
source: { service: 'tenant-service'; instance: string; commit: string };
occurredAt: ISODate;
ingestedAt: ISODate;
causationId?: ULID;
correlationId: ULID;
tenantId: TenantId;
actor: { type: 'user' | 'system' | 'api_key' | 'service_account'; id: string };
payload: T;
partitionKey: string; // usually tenantId
outbox?: { dbWriteTs: ISODate; outboxId: ULID };
retentionClass: 'operational' | 'regulated' | 'audit';
dataResidency: Region;
}

2. NATS Stream Configuration

Stream nameSubjectsRetention (hot)Retention (cold)Partitions
TENANTtenant.>180 days7 years (regulated)16 (by tenantId hash)
TENANT.DLQtenant.dlq.>Until acknowledgedN/A1

All tenant events are regulated retention class because tenant provisioning, membership, and role changes are audited for compliance (SOX, ISO 27001, GDPR).


3. Events Published

3.1 tenant.org.provisioned.v1

Emitted when a new tenant is created.

interface TenantProvisionedPayload {
tenantId: TenantId;
type: 'org' | 'provider' | 'individual' | 'org+provider';
name: string;
slug: string;
homeRegion: Region;
plan: { id: string; addons: string[] };
status: 'trial' | 'active';
ownerEmail: Email;
ownerMembershipId: ULID;
rootOrgUnitId: OrgUnitId;
createdAt: ISODate;
}

Consumers: identity-service (link owner user), notification-service (welcome email), billing-service (plan activation), catalog-service (seed default taxonomies), sync-service (register replicable aggregates), analytics-service.

3.2 tenant.org.settings_updated.v1

interface TenantSettingsUpdatedPayload {
tenantId: TenantId;
changedFields: string[]; // e.g., ['mfaRequired', 'aiTutorEnabled']
previous: Partial<TenantSettings>;
current: Partial<TenantSettings>;
updatedAt: ISODate;
}

Consumers: identity-service (MFA enforcement), ai-gateway-service (aiEnabled), delivery-service (offlineEnabled), notification-service.

3.2a tenant.policy.mfa_changed.v1 (US-5)

Emitted when tenant MFA / passkey policy flags change (e.g. passkeyRequiredForAdmins).

interface TenantPolicyMfaChangedV1 {
tenantId: TenantId;
passkeyRequiredForAdmins: boolean;
changedBy: UserId;
at: ISODate;
}

Consumers: identity-service (login enforcement), analytics-service (admin passkey coverage), audit-service.

3.3 tenant.org.suspended.v1

interface TenantSuspendedPayload {
tenantId: TenantId;
reason: string;
suspendedAt: ISODate;
effectiveAt: ISODate;
}

Consumers: All services (halt service for tenant). API gateway uses this to reject requests.

3.4 tenant.org.closed.v1

interface TenantClosedPayload {
tenantId: TenantId;
closedAt: ISODate;
dataRetentionUntil: ISODate; // post-close retention per plan
}

Consumers: All services (initiate data archival/deletion per retention policy).

3.5 tenant.org.user_invited.v1

interface UserInvitedPayload {
membershipId: ULID;
tenantId: TenantId;
email: Email;
userId?: UserId; // populated if user already registered
roleIds: RoleId[];
orgUnitIds: OrgUnitId[];
invitedBy: UserId;
invitedAt: ISODate;
inviteToken: string; // short-lived signed token for accept link
expiresAt: ISODate;
}

Consumers: notification-service (invite email), identity-service (pre-register if userId null).

3.6 tenant.org.membership_activated.v1

interface MembershipActivatedPayload {
membershipId: ULID;
tenantId: TenantId;
userId: UserId;
roleIds: RoleId[];
orgUnitIds: OrgUnitId[];
activatedAt: ISODate;
trigger: 'invite_accepted' | 'auto_link_on_registration' | 'sso_jit_provisioning';
}

Consumers: notification-service (welcome email), enrollment-service (attach pending assignments), assignment-service (re-evaluate dynamic groups).

3.7 tenant.org.membership_suspended.v1

interface MembershipSuspendedPayload {
membershipId: ULID;
tenantId: TenantId;
userId: UserId;
reason: string;
suspendedBy: UserId;
suspendedAt: ISODate;
}

Consumers: identity-service (revoke sessions), delivery-service (block new play sessions), assignment-service.

3.8 tenant.role.created.v1

interface RoleCreatedPayload {
roleId: RoleId;
tenantId: TenantId;
name: string;
permissions: Permission[];
isSystem: false;
createdBy: UserId;
createdAt: ISODate;
}

3.9 tenant.role.updated.v1

interface RoleUpdatedPayload {
roleId: RoleId;
tenantId: TenantId;
changes: {
name?: { previous: string; current: string };
permissions?: { added: Permission[]; removed: Permission[] };
};
updatedBy: UserId;
updatedAt: ISODate;
}

Consumers: All services (invalidate authz cache for affected tenant).

3.10 tenant.role.deleted.v1

interface RoleDeletedPayload {
roleId: RoleId;
tenantId: TenantId;
deletedBy: UserId;
deletedAt: ISODate;
}

3.11 tenant.org_unit.created.v1

interface OrgUnitCreatedPayload {
orgUnitId: OrgUnitId;
tenantId: TenantId;
parentId?: OrgUnitId;
name: I18nString;
ltreePath: string;
createdAt: ISODate;
}

3.12 tenant.org_unit.moved.v1

interface OrgUnitMovedPayload {
orgUnitId: OrgUnitId;
tenantId: TenantId;
oldParentId?: OrgUnitId;
newParentId?: OrgUnitId;
oldLtreePath: string;
newLtreePath: string;
affectedSubtreeIds: OrgUnitId[]; // descendants whose paths changed
movedAt: ISODate;
}

Consumers: assignment-service (re-evaluate targeting), analytics-service.

3.13 tenant.org_unit.deleted.v1

interface OrgUnitDeletedPayload {
orgUnitId: OrgUnitId;
tenantId: TenantId;
deletedAt: ISODate;
}

3.14 tenant.dynamic_group.evaluated.v1

interface DynamicGroupEvaluatedPayload {
groupId: ULID;
tenantId: TenantId;
name: string;
memberCount: number;
memberIds: UserId[]; // may be paginated via followup events for >10k
addedMembers: UserId[]; // delta since last evaluation
removedMembers: UserId[];
evaluatedAt: ISODate;
queryHash: SHA256; // for dedup / cache invalidation
}

Consumers: assignment-service (re-target assignments), notification-service (segmented campaigns).

3.15 tenant.data_residency.changed.v1

Implementation note (2026-04): until the full data-plane migration ships, outbox payloads use a phase discriminator. Consumers must tolerate unknown phase values.

type DataResidencyChangedPayload =
| {
phase: 'scheduled';
migrationId: string;
tenantId: TenantId;
sourceRegion: Region;
targetRegion: Region;
status: 'scheduled';
scheduledAt: ISODate;
notifyOwner: boolean;
stepsCompleted?: number;
stepsTotal?: number;
}
| {
phase: 'step_completed';
dataPlane: 'pending';
migrationId: string;
tenantId: TenantId;
sourceRegion: Region;
targetRegion: Region;
stepsCompleted: number;
stepsTotal: number;
status: string;
}
| {
phase: 'orchestration_complete_stub';
dataPlane: 'pending';
migrationId: string;
tenantId: TenantId;
sourceRegion: Region;
targetRegion: Region;
stepsCompleted: number;
stepsTotal: number;
status: 'succeeded';
}
| {
/** Target end-state once cross-region copy + cutover are implemented */
tenantId: TenantId;
oldRegion: Region;
newRegion: Region;
migrationId: ULID;
startedAt: ISODate;
completedAt: ISODate;
dataClassesMigrated: string[];
};

Consumers: All services (update routing tables, invalidate regional caches). Stub phases (orchestration_complete_stub, dataPlane: 'pending') must not be treated as finished residency until homeRegion and regional stores are verified.


4. Events Consumed

4.1 identity.user.registered.v1

interface UserRegisteredPayload {
userId: UserId;
email: Email;
registeredAt: ISODate;
homeTenantId?: TenantId; // if registered via tenant signup flow
}

Handler: Query memberships with status='invited' matching email; transition to active and emit membership_activated events.

4.2 billing.subscription.changed.v1

interface SubscriptionChangedPayload {
tenantId: TenantId;
subscriptionId: string;
planId: string;
addons: string[];
status: 'active' | 'past_due' | 'canceled' | 'trialing';
previousStatus: 'active' | 'past_due' | 'canceled' | 'trialing';
effectiveAt: ISODate;
}

Handler: Update Tenant.plan; if status='canceled' and no grace period, suspend tenant; invalidate feature-flag cache; emit settings_updated.

4.3 gdpr.subject_request.received.v1

interface GDPRSubjectRequestPayload {
requestId: ULID;
userId: UserId;
requestType: 'export' | 'erasure' | 'rectification';
scope: 'all' | 'specific';
tenantIds?: TenantId[]; // scoped to specific tenants if set
deadline: ISODate;
}

Handler: For erasure, anonymize memberships (PII null); for export, compile tenant-service slice; emit gdpr.subject_request.slice_completed.v1 with service slice identifier.


5. Event Ordering & Partitioning

  • Partition key: tenantId for all tenant-* events. Guarantees FIFO per tenant.
  • Critical ordering:
    • tenant.org.provisioned.v1 MUST precede any other event for a new tenant.
    • tenant.org.user_invited.v1 MUST precede membership_activated.v1 for same membership.
    • tenant.role.created.v1 MUST precede any role assignment events referencing it.

6. Schema Evolution Rules

Per platform event-driven spec (doc 04 §10):

  1. Additive changes only within a version (add optional fields).
  2. Breaking changes require new vN and dual-publish window ≥ 1 milestone.
  3. Never rename or remove fields in published versions.
  4. Enum narrowing forbidden. Enum widening (new values) allowed if consumers handle unknowns gracefully.
  5. Schema registered in /event-schemas/tenant/ Git repo with content-hash URI.
  6. CI blocks PRs that modify frozen schemas without a version bump.

7. Dead Letter Queue

Events that fail schema validation or fail consumer processing after configured retries (default 5) land in tenant.dlq.{eventType}. Operators:


8. Replay Semantics

All tenant events are replayable. Projections (e.g., authz cache, membership projections in other services) can be rebuilt from seq=0 by resetting the durable consumer cursor. Idempotency is enforced via the platform inbox pattern (unique eventId constraint).