Events
:::info Source
Sourced from services/content-service/EVENT_SCHEMAS.md in the documentation repo.
:::
Companion: 04 Event-Driven Architecture · APPLICATION_LOGIC
All events conform to the platform EventEnvelope (04 §4). This document specifies the payloads for content-service events.
1. Stream
- Stream name:
CONTENT - Retention class:
regulated(180 days hot JetStream, 7 years cold S3) - Partition key:
playPackageIdfor package events;bundleIdfor bundle events;courseVersionIdfor export events. - DLQ:
CONTENT.dlq
2. Events Published
| Subject | Version | Description | Retention |
|---|---|---|---|
content.play_package.built.v1 | 1 | A PlayPackage has been successfully built | regulated |
content.play_package.revoked.v1 | 1 | A PlayPackage has been revoked | regulated |
content.play_package.bundle.published.v1 | 1 | A new offline bundle is available | regulated |
content.play_package.bundle.revoked.v1 | 1 | A bundle has been revoked | regulated |
content.export.completed.v1 | 1 | A SCORM/HTML/xAPI export has finished | operational |
content.import.completed.v1 | 1 | A SCORM import has finished | operational |
content.bundle.tamper_detected.v1 | 1 | Client reported a hash mismatch | audit |
ai.embedding.indexed.v1 | 1 | Pre-warm / embedding index hints associated with an offline bundle build (EP-19 / US-98; stub until full RAG pipeline) | regulated |
3. Events Consumed
| Subject | Source | Trigger |
|---|---|---|
authoring.course_draft.published.v1 | authoring-service | BuildPlayPackage |
enrollment.created.v1 | enrollment-service | CreateOfflineBundle (for user's devices) |
identity.device.bound_for_offline.v1 | identity-service | CreateOfflineBundle (for active enrollments) |
marketplace.license.revoked.v1 | marketplace-service | RevokePlayPackage for affected courses |
gdpr.subject_request.received.v1 | platform | Erase subject's bundles + licenses |
4. Event: content.play_package.built.v1
4.1 Subject
content.play_package.built.v1
4.2 Payload Schema
interface PlayPackageBuiltV1 {
playPackageId: PlayPackageId;
tenantId: TenantId;
courseVersionId: CourseVersionId;
courseId: CourseId;
locale: Locale;
builtAt: ISODate;
builtFrom: { draftVersion: number; commitHash: string };
hash: SHA256;
signatureKid: string; // KID of signing key (for verification)
manifestSummary: {
moduleCount: number;
lessonCount: number;
blockCount: number;
assetCount: number;
totalSizeBytes: number;
durationMinutes: number;
navigation: 'linear' | 'tree' | 'branching';
hasAssistant: boolean;
};
formats: {
offlineBundleSupported: boolean;
scorm12Ready: boolean;
scorm2004Ready: boolean;
html5Ready: boolean;
xapiReady: boolean;
};
}
4.3 JSON Schema
{
"$id": "schemas://content/play_package/built/v1",
"type": "object",
"required": ["playPackageId", "tenantId", "courseVersionId", "courseId", "locale", "builtAt", "hash", "signatureKid", "manifestSummary", "formats"],
"properties": {
"playPackageId": { "type": "string", "pattern": "^ppk_[0-9A-HJKMNP-TV-Z]{26}$" },
"tenantId": { "type": "string", "pattern": "^ten_[0-9A-HJKMNP-TV-Z]{26}$" },
"courseVersionId": { "type": "string", "pattern": "^cv_[0-9A-HJKMNP-TV-Z]{26}$" },
"courseId": { "type": "string", "pattern": "^crs_[0-9A-HJKMNP-TV-Z]{26}$" },
"locale": { "type": "string", "pattern": "^[a-z]{2,3}(-[A-Z]{2})?$" },
"builtAt": { "type": "string", "format": "date-time" },
"builtFrom": {
"type": "object",
"required": ["draftVersion", "commitHash"],
"properties": {
"draftVersion": { "type": "integer", "minimum": 1 },
"commitHash": { "type": "string", "pattern": "^[a-f0-9]{8,64}$" }
}
},
"hash": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" },
"signatureKid": { "type": "string" },
"manifestSummary": {
"type": "object",
"required": ["moduleCount", "lessonCount", "blockCount", "assetCount", "totalSizeBytes", "durationMinutes", "navigation", "hasAssistant"],
"properties": {
"moduleCount": { "type": "integer", "minimum": 0 },
"lessonCount": { "type": "integer", "minimum": 0 },
"blockCount": { "type": "integer", "minimum": 0 },
"assetCount": { "type": "integer", "minimum": 0 },
"totalSizeBytes": { "type": "integer", "minimum": 0 },
"durationMinutes": { "type": "integer", "minimum": 0 },
"navigation": { "enum": ["linear", "tree", "branching"] },
"hasAssistant": { "type": "boolean" }
}
},
"formats": {
"type": "object",
"required": ["offlineBundleSupported", "scorm12Ready", "scorm2004Ready", "html5Ready", "xapiReady"],
"properties": {
"offlineBundleSupported": { "type": "boolean" },
"scorm12Ready": { "type": "boolean" },
"scorm2004Ready": { "type": "boolean" },
"html5Ready": { "type": "boolean" },
"xapiReady": { "type": "boolean" }
}
}
},
"additionalProperties": false
}
4.4 Consumers
- catalog-service: update catalog entry with new version
- search-service: reindex course
- notification-service: notify authors of successful build
- analytics-service: build metrics
5. Event: content.play_package.revoked.v1
5.1 Payload Schema
interface PlayPackageRevokedV1 {
playPackageId: PlayPackageId;
tenantId: TenantId;
courseVersionId: CourseVersionId;
locale: Locale;
revokedAt: ISODate;
revokedBy: { actorType: 'user' | 'system' | 'admin'; actorId: string };
reason: 'content_error' | 'license_revoked' | 'gdpr_erasure' | 'security' | 'admin_request';
cascadedBundleIds: BundleId[]; // bundles also revoked
notes?: string;
}
5.2 Consumers
- catalog-service: mark course version as revoked
- search-service: remove from search index
- sync-service: propagate revocation to devices
- analytics-service: record revocation
- notification-service: alert affected learners (if tenant policy)
6. Event: content.play_package.bundle.published.v1
6.1 Payload Schema
interface BundlePublishedV1 {
bundleId: BundleId;
playPackageId: PlayPackageId;
tenantId: TenantId;
enrollmentId: EnrollmentId;
userId: UserId;
deviceId: DeviceId;
builtAt: ISODate;
expiresAt: ISODate;
sizeBytes: number;
sha256: SHA256;
signatureKid: string;
encryption: { alg: 'AES-256-GCM'; kid: string };
license: {
features: {
aiTutor: boolean;
assessments: boolean;
certificate: boolean;
copyDownloadable: boolean;
};
};
downloadUrl: string; // signed URL for sync-service to relay
}
6.2 Consumers
- sync-service: register bundle for device delta sync
- notification-service: push notification "Course ready offline"
- analytics-service: bundle creation metrics
7. Event: content.play_package.bundle.revoked.v1
7.1 Payload Schema
interface BundleRevokedV1 {
bundleId: BundleId;
playPackageId: PlayPackageId;
tenantId: TenantId;
enrollmentId: EnrollmentId;
userId: UserId;
deviceId: DeviceId;
revokedAt: ISODate;
reason: 'package_revoked' | 'license_revoked' | 'tamper_detected' | 'device_unbound' | 'gdpr_erasure' | 'admin_request';
cascadeSource?: {
type: 'package_revocation';
playPackageId: PlayPackageId;
};
}
7.2 Consumers
- sync-service: push revocation to device on next sync
- notification-service: notify user of revocation
- analytics-service: revocation metrics
8. Event: content.export.completed.v1
8.1 Payload Schema
interface ExportCompletedV1 {
exportId: string;
playPackageId: PlayPackageId;
tenantId: TenantId;
courseVersionId: CourseVersionId;
format: 'scorm_1_2' | 'scorm_2004_3rd' | 'scorm_2004_4th' | 'html5' | 'xapi' | 'cmi5';
locale: Locale;
completedAt: ISODate;
zipUrl: string;
sha256: SHA256;
sizeBytes: number;
durationMs: number;
conformanceValidated: boolean;
validationReport?: string; // URL to detailed validation report
}
8.2 Consumers
- notification-service: notify requestor
- analytics-service: export metrics
- marketplace-service: update listing with export availability
9. Event: content.import.completed.v1
9.1 Payload Schema
interface ImportCompletedV1 {
importId: string;
tenantId: TenantId;
status: 'completed' | 'failed';
playPackageId?: PlayPackageId; // only if status='completed'
sourceFile: { sizeBytes: number; sha256: SHA256; originalName: string };
completedAt: ISODate;
durationMs: number;
stages: Array<{
name: string;
status: 'done' | 'failed' | 'skipped';
durationMs: number;
}>;
errors?: Array<{
code: string;
message: string;
stage: string;
}>;
metrics: {
assetCount: number;
totalSizeBytes: number;
scormVersion: 'SCORM_1_2' | 'SCORM_2004' | 'unknown';
};
}
9.2 Consumers
- notification-service: notify importer
- catalog-service: create/update course entry
- analytics-service: import metrics
10. Event: content.bundle.tamper_detected.v1
10.1 Payload Schema
interface TamperDetectedV1 {
bundleId: BundleId;
playPackageId: PlayPackageId;
tenantId: TenantId;
userId: UserId;
deviceId: DeviceId;
detectedAt: ISODate;
reportedAt: ISODate;
expectedHash: SHA256;
actualHash: SHA256;
context: {
locationInBundle?: string;
deviceFingerprint?: string;
playerVersion?: string;
};
autoRevoked: boolean;
reportCount: number; // count of tamper reports for this bundle
}
10.2 Retention
Audit class — 7 years cold retention. Security-critical.
10.3 Consumers
- notification-service: alert security team (severity based on tenant policy)
- analytics-service: tamper rate metrics (per device, per tenant)
- audit-service: append to tamper-evident audit log
11. Event Envelope Example (Built)
Full event as it appears on NATS:
{
"eventId": "01HXYZ…EVT",
"eventType": "content.play_package.built",
"eventVersion": 1,
"schemaUri": "schemas://content/play_package/built/v1#sha256-a1b2…",
"source": {
"service": "content-service",
"instance": "content-svc-7d4f8c-x9r2j",
"commit": "a1b2c3d4"
},
"occurredAt": "2026-04-15T09:00:00.123Z",
"ingestedAt": "2026-04-15T09:00:00.234Z",
"causationId": "01HXYZ…CAUSE",
"correlationId": "01HXYZ…CORR",
"tenantId": "ten_01HXYZ…DEF",
"actor": { "type": "system", "id": "content-service" },
"payload": {
"playPackageId": "ppk_01HXYZ…ABC",
"tenantId": "ten_01HXYZ…DEF",
"courseVersionId": "cv_01HXYZ…GHI",
"courseId": "crs_01HXYZ…JKL",
"locale": "en-US",
"builtAt": "2026-04-15T09:00:00.123Z",
"builtFrom": { "draftVersion": 12, "commitHash": "a1b2c3d4" },
"hash": "sha256:abcdef…",
"signatureKid": "tenant-key-v3",
"manifestSummary": { "...": "..." },
"formats": { "...": "..." }
},
"partitionKey": "ppk_01HXYZ…ABC",
"outbox": {
"dbWriteTs": "2026-04-15T09:00:00.100Z",
"outboxId": "01HXYZ…OUT"
},
"retentionClass": "regulated",
"dataResidency": "us"
}
12. Schema Evolution Policy
12.1 Additive Changes (Same Version)
The following are allowed within v1:
- Adding optional fields
- Adding new enum values at the tail (only if consumers handle unknowns gracefully)
- Relaxing
maxLengthormaximumconstraints
12.2 Breaking Changes (New Version)
Require v2:
- Removing or renaming fields
- Changing field types
- Narrowing enum values
- Making optional fields required
12.3 Dual-Publish Window
On v2 introduction:
- Producer publishes both
v1andv2for 6 months. - Consumers migrate to
v2; CI asserts all consumers have migrated beforev1is dropped. v1deprecation announced inAPI_CONTRACTSrelease notes.
13. Idempotency Keys (Consumer Side)
Content-service's consumer inbox stores eventId (ULID) from envelope. Duplicates are ack'd without re-processing:
CREATE TABLE inbox (
event_id ulid PRIMARY KEY,
subject text NOT NULL,
processed_at timestamptz NOT NULL DEFAULT now(),
result text NOT NULL CHECK (result IN ('ok', 'skipped', 'failed'))
);
14. DLQ Policy
Events that fail schema validation or exceed retry budget (10 attempts) move to CONTENT.dlq.
- Alert fires when DLQ depth > 0 (PagerDuty
content-service-dlq). - Operators use Saga Inspector UI to triage.
- Re-drive supported via admin CLI
content-cli redrive --event-id <ulid>.
15. Testing Contracts
- Producer tests: Every published event has a fixture and is validated against its JSON Schema in CI.
- Consumer tests (for consumed events): Mock producers publish fixture events; content-service asserts correct handling.
- Pact tests: Content-service publishes Pact contracts for all events; consumers verify before merge.