Domain Model
:::info Source
Sourced from services/content-service/DOMAIN_MODEL.md in the documentation repo.
:::
Companion: SERVICE_OVERVIEW · 12 Data Models · 02 DDD
1. Ubiquitous Language
| Term | Definition |
|---|---|
| PlayPackage | An immutable, signed, runtime-ready projection of a published course version for a specific locale. The primary aggregate root. |
| PackageManifest | The declarative structure embedded in a PlayPackage that describes modules, navigation, prerequisites, AI config, and course metadata. |
| PlayPackageBundle | An encrypted, per-device downloadable artifact derived from a PlayPackage, bound to a specific enrollment and device via a LicenseEnvelope. |
| LicenseEnvelope | A signed, time-bound, device-bound, feature-gated token that authorizes offline playback of a bundle on a specific device. |
| FormatArtifacts | The set of export outputs (SCORM 1.2, SCORM 2004, HTML5, xAPI, offline bundle) generated from a PlayPackage. |
| AssetReference | A pointer to a media asset (owned by media-service) with its SHA-256 hash, size, and MIME type, pinned at build time. |
| NavigationModel | The traversal strategy for a course: linear, tree, or branching. |
| AssistantConfig | AI tutor configuration baked into the manifest at build time (prompt ID, model, features enabled). |
| Tamper Report | A client-reported hash mismatch on a downloaded bundle, indicating potential corruption or tampering. |
| Build | The process of transforming a published course draft into a PlayPackage: resolve assets, assemble manifest, compute hash, sign. |
| Revocation | The permanent, irreversible invalidation of a PlayPackage or Bundle. |
2. Aggregates
2.1 PlayPackage (Primary Aggregate Root)
type PlayPackageId = Branded<string, 'PlayPackageId'>;
interface PlayPackage {
// Identity
id: PlayPackageId;
tenantId: TenantId;
courseVersionId: CourseVersionId;
locale: Locale;
// Content
manifest: PackageManifest;
assets: AssetReference[];
// Build metadata
builtAt: ISODate;
builtFrom: { draftVersion: number; commitHash: string };
// Artifacts
formats: FormatArtifacts;
// Integrity
hash: SHA256; // SHA-256 of all asset hashes concatenated in manifest order
signature: JWS; // signed by tenant signing key
// Lifecycle
status: 'building' | 'built' | 'revoked';
}
Invariants:
hashMUST equal SHA-256 of allassets[].sha256concatenated in manifest module/lesson order.signatureMUST verify against the tenant's current signing key via KMS.- Transition to
revokedis permanent; no return tobuildingorbuilt. (tenantId, courseVersionId, locale)is unique per non-revoked PlayPackage.manifest.versionMUST be'1.0'until schema version bump (freeze F15).
2.2 PlayPackageBundle (Secondary Aggregate Root)
type BundleId = Branded<string, 'BundleId'>;
interface PlayPackageBundle {
// Identity
id: BundleId;
playPackageId: PlayPackageId;
tenantId: TenantId;
// Storage
url: string; // S3/R2 signed URL base
sha256: SHA256; // hash of encrypted blob
signature: JWS; // signature over (bundleId + sha256)
// Encryption
encryption: {
alg: 'AES-256-GCM';
kid: string; // key ID in KMS
};
// License
license: LicenseEnvelope;
// Metadata
builtAt: ISODate;
sizeBytes: number;
// Lifecycle
status: 'available' | 'revoked';
}
Invariants:
sha256MUST equal SHA-256 of the encrypted blob aturl.signatureMUST verify against the tenant signing key.license.bundleIdMUST equalid.encryption.kidmust reference a valid, non-rotated key in KMS.- Transition to
revokedis permanent. - Bundle can only exist for a PlayPackage in
builtstatus.
3. Value Objects
3.1 PackageManifest
interface PackageManifest {
version: '1.0';
course: {
id: CourseId;
versionLabel: SemVer;
title: I18nString;
durationMinutes: number;
};
modules: ModuleManifest[];
assistant?: AssistantConfig;
navigation: NavigationModel;
prerequisites?: PrerequisiteRule[];
}
interface ModuleManifest {
id: string;
title: I18nString;
lessons: LessonManifest[];
durationMinutes: number;
prerequisiteModuleIds?: string[];
}
interface LessonManifest {
id: string;
title: I18nString;
blocks: BlockManifest[];
durationMinutes: number;
assessmentIds?: string[];
}
interface BlockManifest {
id: string;
type: 'text' | 'media' | 'interactive' | 'assessment' | 'embed';
assetRef?: AssetReference;
content?: I18nMarkup;
metadata: Record<string, JSONValue>;
}
type NavigationModel = 'linear' | 'tree' | 'branching';
3.2 LicenseEnvelope
interface LicenseEnvelope {
bundleId: BundleId;
enrollmentId: EnrollmentId;
userId: UserId;
deviceId: DeviceId;
issuedAt: ISODate;
expiresAt: ISODate;
features: {
aiTutor: boolean;
assessments: boolean;
certificate: boolean;
copyDownloadable: boolean;
};
signature: JWS; // signed by tenant key, verifiable offline
}
Invariants:
expiresAtMUST be afterissuedAt.signatureMUST verify against the tenant signing key embedded in the bundle.featuresis immutable after issuance; changes require a new bundle.
3.3 AssetReference
interface AssetReference {
id: MediaAssetId;
sha256: SHA256;
sizeBytes: number;
mime: string;
}
3.4 FormatArtifacts
interface FormatArtifacts {
scorm12?: { zipUrl: string; sha256: SHA256; sizeBytes: number };
scorm2004?: { zipUrl: string; sha256: SHA256; sizeBytes: number };
html5?: { zipUrl: string; sha256: SHA256 };
xapi?: { zipUrl: string; sha256: SHA256 };
offlineBundle?: BundleRef;
}
interface BundleRef {
bundleId: BundleId;
url: string;
}
3.5 AssistantConfig
interface AssistantConfig {
enabled: boolean;
promptId: string;
promptVersion: SemVer;
model: string; // e.g. 'claude-sonnet-4-20250514'
features: {
questionAnswering: boolean;
summarization: boolean;
translation: boolean;
adaptiveHints: boolean;
};
constraints: {
maxTokensPerSession: number;
contextScope: 'lesson' | 'module' | 'course';
groundedOnly: boolean; // must cite from course content
};
}
3.6 PrerequisiteRule
interface PrerequisiteRule {
type: 'course_completion' | 'module_completion' | 'assessment_score' | 'time_gate';
targetId: string;
threshold?: number; // e.g., minimum score percentage
gateDate?: ISODate; // for time_gate type
}
4. State Machines
4.1 PlayPackage Lifecycle
┌──────────┐
build start │ building │
──────────► │ │
└────┬─────┘
│ build success
▼
┌──────────┐
│ built │──────────► (serves bundles, exports)
│ │
└────┬─────┘
│ revoke
▼
┌──────────┐
│ revoked │ (terminal, permanent)
└──────────┘
build failure ──► PlayPackage deleted; error event emitted
(no 'failed' status stored; idempotent retry by re-publish)
Transition Rules:
building → built: Hash computed, signature verified, all assets resolved.built → revoked: Revocation command (admin, license revocation, GDPR). Permanent.building → (deleted): Build failure. Package row deleted. Error event surfaced.- No transition from
revokedto any other state. - No transition from
builtback tobuilding(new version = new PlayPackage).
4.2 PlayPackageBundle Lifecycle
┌────────────┐
create │ available │──────────► (downloadable, synced)
──────────► │ │
└─────┬──────┘
│ revoke
▼
┌────────────┐
│ revoked │ (terminal, permanent)
└────────────┘
Transition Rules:
- Bundle created directly in
availablestatus (encryption + upload complete). available → revoked: License revocation, package revocation, GDPR, admin action.- Revocation cascades: revoking a PlayPackage revokes all its bundles.
5. Domain Events (Internal)
| Event | Trigger | Payload Key Fields |
|---|---|---|
PackageBuildStarted | Build pipeline begins | playPackageId, courseVersionId, locale |
PackageBuildCompleted | Build succeeds | playPackageId, hash, signature |
PackageBuildFailed | Build fails | courseVersionId, locale, errorCode, errorMessage |
PackageRevoked | Revocation command | playPackageId, reason, revokedBy |
BundleCreated | Bundle encryption + upload complete | bundleId, playPackageId, deviceId, enrollmentId |
BundleRevoked | Bundle revocation | bundleId, reason |
TamperDetected | Client hash mismatch report | bundleId, deviceId, expectedHash, actualHash |
ExportCompleted | SCORM/HTML5/xAPI export done | playPackageId, format, zipUrl |
ImportCompleted | SCORM import processed | importId, playPackageId |
6. Aggregate Interaction Diagram
authoring.course_draft.published.v1
│
▼
┌──────────────┐ resolve assets ┌──────────────┐
│ PlayPackage │◄─────────────────────────►│ media-service │
│ (building) │ └──────────────┘
└──────┬───────┘
│ build complete
▼
┌──────────────┐ sign + hash ┌──────────┐
│ PlayPackage │◄────────────────────────────│ KMS │
│ (built) │ └──────────┘
└──────┬───────┘
│ enrollment.created.v1
│ + identity.device.bound_for_offline.v1
▼
┌──────────────────┐ derive key + encrypt ┌──────────┐
│ PlayPackageBundle │◄───────────────────────── │ KMS │
│ (available) │ └──────────┘
│ + LicenseEnvelope │ upload blob ┌──────────┐
│ │──────────────────────────►│ S3/R2 │
└──────────────────┘ └──────────┘
│
▼ content.play_package.bundle.published.v1
┌──────────────┐
│ sync-service │ (registers for device sync)
└──────────────┘
7. Cross-Aggregate Rules
-
PlayPackage revocation cascades to Bundles. When a PlayPackage is revoked, all Bundles referencing it are revoked in the same transaction. Events emitted for each.
-
Bundle cannot outlive its PlayPackage. A Bundle's
playPackageIdmust reference a non-revoked PlayPackage at creation time. -
One active Bundle per (PlayPackage, Enrollment, Device). If a new bundle is created for the same triple, the previous one is revoked first.
-
License expiry is enforced client-side and server-side. The Player checks
expiresAtlocally; the sync-service validates on delta pull. -
GDPR erasure touches both aggregates. Subject request erases all Bundles and LicenseEnvelopes for the user; PlayPackages are retained (they contain no PII) but orphaned bundles are purged.