Skip to main content

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

AttributeValue
Stream nameAUTHORING
Subjectsauthoring.>
Retention classoperational (30d hot JetStream, 13mo cold S3 archive)
Replicas3 (multi-AZ)
Storagefile (persistent)
Max age30 days
Max bytes500 GB
DLQ subjectAUTHORING.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

SubjectHandlerAction
content.play_package.built.v1PublishSagaOrchestratorAdvance saga: building → cataloging
catalog.course_version.published.v1PublishSagaOrchestratorAdvance saga: cataloging → bundling
content.play_package.bundle.published.v1PublishSagaOrchestratorAdvance saga: bundling → ready; draft → published_idle

4.2 Media Ready

SubjectHandlerAction
media.asset.ready.v1MediaReadyHandlerMark 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

SubjectHandlerAction
ai.completion.finished.v1AIJobManagerAttach 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

SubjectHandlerAction
gdpr.subject_request.received.v1GDPRHandlerAnonymize 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 typeVersioning
Add optional fieldSame version (additive)
Add required fieldNew version (v2), dual-publish window
Remove fieldNew version (v2), deprecation notice
Change field typeNew version (v2), dual-publish window
Rename fieldNew 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.