Events
:::info Source
Sourced from services/authoring-service/05-EVENT_SCHEMAS.md in the documentation repo.
:::
Companion: 04 Event-Driven Architecture · 03 Application Logic
1. NATS Stream Configuration
| Attribute | Value |
|---|---|
| Stream name | AUTHORING |
| Subjects | authoring.> |
| Retention class | operational (30d hot JetStream, 13mo cold S3 archive) |
| Replicas | 3 (multi-AZ) |
| Storage | file (persistent) |
| Max age | 30 days |
| Max bytes | 500 GB |
| DLQ subject | AUTHORING.dlq |
2. Subject Naming
All events conform to the platform grammar: {service}.{aggregate}.{event}.v{N}
3. Events Published
3.1 CourseDraft Lifecycle
authoring.course_draft.created.v1
Emitted when a new draft is created.
interface CourseDraftCreatedPayload {
draftId: CourseDraftId;
tenantId: TenantId;
title: I18nString;
defaultLocale: Locale;
createdBy: UserId;
createdAt: ISODate;
}
Consumers: analytics-service, search-service, sync-service
authoring.course_draft.updated.v1
Emitted on any metadata update (title, locale, collaborators, state).
interface CourseDraftUpdatedPayload {
draftId: CourseDraftId;
tenantId: TenantId;
draftVersion: number;
changes: {
title?: I18nString;
defaultLocale?: Locale;
collaborators?: UserId[];
state?: DraftState;
};
updatedBy: UserId;
updatedAt: ISODate;
}
Consumers: analytics-service, sync-service
authoring.course_draft.published.v1
Saga step 1. Signals content-service to begin building the PlayPackage.
interface CourseDraftPublishedPayload {
draftId: CourseDraftId;
tenantId: TenantId;
draftVersion: number;
snapshot: CourseDraftSnapshot; // Full draft content for packaging
publishedBy: UserId;
sagaId: string;
}
interface CourseDraftSnapshot {
id: CourseDraftId;
title: I18nString;
defaultLocale: Locale;
modules: ModuleSnapshot[];
}
Consumers: content-service (primary), analytics-service, search-service
authoring.course_draft.forked.v1
Emitted when a published course is forked to a new editable draft.
interface CourseDraftForkedPayload {
newDraftId: CourseDraftId;
sourceDraftId: CourseDraftId;
tenantId: TenantId;
forkedBy: UserId;
forkedAt: ISODate;
}
Consumers: analytics-service, sync-service
3.2 Block Lifecycle
authoring.block.added.v1
interface BlockAddedPayload {
draftId: CourseDraftId;
tenantId: TenantId;
lessonId: LessonId;
block: Block;
addedBy: UserId;
occurredAt: ISODate;
}
authoring.block.updated.v1
interface BlockUpdatedPayload {
draftId: CourseDraftId;
tenantId: TenantId;
blockId: BlockId;
lessonId: LessonId;
changes: Partial<Block>; // only changed fields
updatedBy: UserId;
occurredAt: ISODate;
}
authoring.block.removed.v1
interface BlockRemovedPayload {
draftId: CourseDraftId;
tenantId: TenantId;
blockId: BlockId;
lessonId: LessonId;
removedBy: UserId;
occurredAt: ISODate;
}
authoring.block.ai_generated.v1
Emitted when AI generates a block (status=draft_ai).
interface BlockAIGeneratedPayload {
draftId: CourseDraftId;
tenantId: TenantId;
lessonId: LessonId;
blockId: BlockId;
block: Block;
aiProvenance: AIProvenance;
jobId: string;
occurredAt: ISODate;
}
Consumers: ai-gateway-service (audit), analytics-service (acceptance rate)
authoring.draft.ai.block_generated.v1 (EPIC-AIT-001 / US-60)
Rollup emitted once per successful structured generation request after all draft_ai text blocks for that gateway completionId are persisted (outbox row written outside per-block transactions).
interface DraftAiBlockGeneratedRollupPayload {
draftId: CourseDraftId;
lessonId: LessonId;
tenantId: TenantId;
blockIds: BlockId[];
completionId: string;
generatedBy: UserId;
occurredAt: ISODate;
}
Consumers: analytics-service, ai-gateway-service (audit / correlation)
authoring.draft.ai.generation.aborted.v1 (EPIC-AIT-001 / US-60)
Emitted when a partial set of draft_ai blocks from a single generation session is rolled back (e.g. client disconnect during SSE). Payload lists blocks that were removed.
interface DraftAiGenerationAbortedPayload {
draftId: CourseDraftId;
tenantId: TenantId;
blockIds: BlockId[];
abortedBy: UserId;
occurredAt: ISODate;
}
Consumers: analytics-service
authoring.block.reviewed.v1
Emitted when a user accepts or rejects an AI block.
interface BlockReviewedPayload {
draftId: CourseDraftId;
tenantId: TenantId;
blockId: BlockId;
decision: 'accepted' | 'rejected';
reviewedBy: UserId;
reviewedAt: ISODate;
originalProvenance: AIProvenance;
}
Consumers: ai-gateway-service (HITL record), analytics-service
3.3 Publish Saga
authoring.publish_saga.step_completed.v1
Emitted at each saga step transition.
interface PublishSagaStepCompletedPayload {
sagaId: string;
draftId: CourseDraftId;
tenantId: TenantId;
step: 'building' | 'cataloging' | 'bundling' | 'ready';
completedAt: ISODate;
triggeringEventId: ULID;
}
authoring.publish_saga.failed.v1
interface PublishSagaFailedPayload {
sagaId: string;
draftId: CourseDraftId;
tenantId: TenantId;
failedStep: string;
reason: 'timeout' | 'content_build_failed' | 'catalog_rejected' | 'bundle_failed' | 'internal_error';
detail?: string;
failedAt: ISODate;
}
authoring.publish_saga.compensated.v1
interface PublishSagaCompensatedPayload {
sagaId: string;
draftId: CourseDraftId;
tenantId: TenantId;
compensatedStep: string;
compensationAction: string;
compensatedAt: ISODate;
}
4. Events Consumed
4.1 Advance Publish Saga
| Subject | Handler | Action |
|---|---|---|
content.play_package.built.v1 | PublishSagaOrchestrator | Advance saga: building → cataloging |
catalog.course_version.published.v1 | PublishSagaOrchestrator | Advance saga: cataloging → bundling |
content.play_package.bundle.published.v1 | PublishSagaOrchestrator | Advance saga: bundling → ready; draft → published_idle |
4.2 Media Ready
| Subject | Handler | Action |
|---|---|---|
media.asset.ready.v1 | MediaReadyHandler | Mark blocks referencing the asset as media-ready; re-evaluate publish readiness |
Expected payload shape:
interface MediaAssetReadyPayload {
assetId: MediaAssetId;
tenantId: TenantId;
variants: AssetVariant[];
readyAt: ISODate;
}
4.3 AI Completion
| Subject | Handler | Action |
|---|---|---|
ai.completion.finished.v1 | AIJobManager | Attach AI completion result to pending draft block; create AIBlock with status=draft_ai |
Expected payload shape:
interface AICompletionFinishedPayload {
jobId: string;
tenantId: TenantId;
callerService: 'authoring';
callerRef: { draftId: CourseDraftId; lessonId?: LessonId; blockId?: BlockId };
completion: {
text?: string;
structured?: JSONValue;
};
provenance: AIProvenance;
finishedAt: ISODate;
}
4.4 GDPR Subject Request
| Subject | Handler | Action |
|---|---|---|
gdpr.subject_request.received.v1 | GDPRHandler | Anonymize or delete user data in drafts (based on request type) |
Expected payload shape:
interface GDPRSubjectRequestPayload {
requestId: string;
tenantId: TenantId;
userId: UserId;
requestType: 'access' | 'deletion' | 'rectification' | 'portability';
submittedAt: ISODate;
dueBy: ISODate;
}
5. Envelope Example (Full)
{
"eventId": "01HW5Q2X3Y4Z5A6B7C8D9EFGHJ",
"eventType": "authoring.block.ai_generated",
"eventVersion": 1,
"schemaUri": "schemas://authoring/block/ai_generated/v1#sha256-aee9...",
"source": {
"service": "authoring-service",
"instance": "authoring-7f8b9c-xyz",
"commit": "a1b2c3d"
},
"occurredAt": "2026-04-15T10:23:45.123Z",
"ingestedAt": "2026-04-15T10:23:45.142Z",
"causationId": "01HW5Q1AAAAAAAAAAAAAAAAAAAA",
"correlationId": "01HW5Q0000000000000000000000",
"tenantId": "tnt_01H...",
"actor": { "type": "user", "id": "usr_01H..." },
"payload": {
"draftId": "drf_01H...",
"tenantId": "tnt_01H...",
"lessonId": "lsn_01H...",
"blockId": "blk_01H...",
"block": { "kind": "quiz", "status": "draft_ai", "...": "..." },
"aiProvenance": {
"model": "claude-sonnet-4-20250514",
"promptId": "assessment/quiz_from_lesson",
"promptVersion": "1.0.0",
"traceId": "00-abc123-def456-01",
"local": false,
"generatedAt": "2026-04-15T10:23:44.000Z"
},
"jobId": "aij_01H...",
"occurredAt": "2026-04-15T10:23:45.123Z"
},
"partitionKey": "drf_01H...",
"outbox": {
"dbWriteTs": "2026-04-15T10:23:45.100Z",
"outboxId": "01HW5Q2X3Y4Z5A6B7C8D9EFGHJ"
},
"retentionClass": "operational",
"dataResidency": "us"
}
6. Outbox Table Schema
CREATE TABLE authoring.outbox (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
occurred_at timestamptz NOT NULL DEFAULT now(),
tenant_id uuid NOT NULL,
topic text NOT NULL,
partition_key text NOT NULL,
envelope jsonb NOT NULL,
published_at timestamptz,
attempts int NOT NULL DEFAULT 0,
last_error text,
next_attempt_at timestamptz
);
CREATE INDEX outbox_unpublished_idx ON authoring.outbox (occurred_at) WHERE published_at IS NULL;
CREATE INDEX outbox_retry_idx ON authoring.outbox (next_attempt_at) WHERE published_at IS NULL AND attempts > 0;
Outbox relay: polls every 200ms, publishes to NATS, marks row published on 2xx ack. Retry with exponential backoff (200ms, 1s, 5s, 30s, 5min, abandoned → DLQ).
7. Inbox Table Schema (for consumed events)
CREATE TABLE authoring.event_inbox (
event_id uuid PRIMARY KEY,
topic text NOT NULL,
tenant_id uuid NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz,
handler_name text NOT NULL,
last_error text
);
Idempotency guarantee: event_id PRIMARY KEY prevents duplicate processing.
8. Schema Registry Integration
All event schemas published to the platform schema registry at build time:
schemas://authoring/course_draft/created/v1
schemas://authoring/course_draft/updated/v1
schemas://authoring/course_draft/published/v1
schemas://authoring/course_draft/forked/v1
schemas://authoring/block/added/v1
schemas://authoring/block/updated/v1
schemas://authoring/block/removed/v1
schemas://authoring/block/ai_generated/v1
schemas://authoring/draft/ai/block_generated/v1
schemas://authoring/draft/ai/generation_aborted/v1
schemas://authoring/block/reviewed/v1
schemas://authoring/publish_saga/step_completed/v1
schemas://authoring/publish_saga/failed/v1
schemas://authoring/publish_saga/compensated/v1
Each schema URI includes a content-hash. Consumers validate incoming events against the registered schema; mismatch → DLQ + alert.
9. Schema Evolution Policy
| Change type | Versioning |
|---|---|
| Add optional field | Same version (additive) |
| Add required field | New version (v2), dual-publish window |
| Remove field | New version (v2), deprecation notice |
| Change field type | New version (v2), dual-publish window |
| Rename field | New version (v2), dual-publish window |
Dual-publish window: minimum 30 days. Old consumers drained before v1 is retired.
10. DLQ Handling
Events that fail validation or handler processing (after retries) move to AUTHORING.dlq. An on-call alert fires within 1 minute of first DLQ message. Operations runbook covers replay via manual review.
11. Event Ordering Guarantees
- Per-draft ordering: guaranteed via
partitionKey = draftId. All events for a single draft are delivered in order. - Cross-draft ordering: not guaranteed. Consumers must tolerate out-of-order delivery across drafts.
- Saga events: ordered per-saga via
partitionKey = sagaId.