Skip to main content

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: playPackageId for package events; bundleId for bundle events; courseVersionId for export events.
  • DLQ: CONTENT.dlq

2. Events Published

SubjectVersionDescriptionRetention
content.play_package.built.v11A PlayPackage has been successfully builtregulated
content.play_package.revoked.v11A PlayPackage has been revokedregulated
content.play_package.bundle.published.v11A new offline bundle is availableregulated
content.play_package.bundle.revoked.v11A bundle has been revokedregulated
content.export.completed.v11A SCORM/HTML/xAPI export has finishedoperational
content.import.completed.v11A SCORM import has finishedoperational
content.bundle.tamper_detected.v11Client reported a hash mismatchaudit
ai.embedding.indexed.v11Pre-warm / embedding index hints associated with an offline bundle build (EP-19 / US-98; stub until full RAG pipeline)regulated

3. Events Consumed

SubjectSourceTrigger
authoring.course_draft.published.v1authoring-serviceBuildPlayPackage
enrollment.created.v1enrollment-serviceCreateOfflineBundle (for user's devices)
identity.device.bound_for_offline.v1identity-serviceCreateOfflineBundle (for active enrollments)
marketplace.license.revoked.v1marketplace-serviceRevokePlayPackage for affected courses
gdpr.subject_request.received.v1platformErase 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 maxLength or maximum constraints

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 v1 and v2 for 6 months.
  • Consumers migrate to v2; CI asserts all consumers have migrated before v1 is dropped.
  • v1 deprecation announced in API_CONTRACTS release 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.