Skip to main content

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​

TermDefinition
PlayPackageAn immutable, signed, runtime-ready projection of a published course version for a specific locale. The primary aggregate root.
PackageManifestThe declarative structure embedded in a PlayPackage that describes modules, navigation, prerequisites, AI config, and course metadata.
PlayPackageBundleAn encrypted, per-device downloadable artifact derived from a PlayPackage, bound to a specific enrollment and device via a LicenseEnvelope.
LicenseEnvelopeA signed, time-bound, device-bound, feature-gated token that authorizes offline playback of a bundle on a specific device.
FormatArtifactsThe set of export outputs (SCORM 1.2, SCORM 2004, HTML5, xAPI, offline bundle) generated from a PlayPackage.
AssetReferenceA pointer to a media asset (owned by media-service) with its SHA-256 hash, size, and MIME type, pinned at build time.
NavigationModelThe traversal strategy for a course: linear, tree, or branching.
AssistantConfigAI tutor configuration baked into the manifest at build time (prompt ID, model, features enabled).
Tamper ReportA client-reported hash mismatch on a downloaded bundle, indicating potential corruption or tampering.
BuildThe process of transforming a published course draft into a PlayPackage: resolve assets, assemble manifest, compute hash, sign.
RevocationThe 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:

  1. hash MUST equal SHA-256 of all assets[].sha256 concatenated in manifest module/lesson order.
  2. signature MUST verify against the tenant's current signing key via KMS.
  3. Transition to revoked is permanent; no return to building or built.
  4. (tenantId, courseVersionId, locale) is unique per non-revoked PlayPackage.
  5. manifest.version MUST 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:

  1. sha256 MUST equal SHA-256 of the encrypted blob at url.
  2. signature MUST verify against the tenant signing key.
  3. license.bundleId MUST equal id.
  4. encryption.kid must reference a valid, non-rotated key in KMS.
  5. Transition to revoked is permanent.
  6. Bundle can only exist for a PlayPackage in built status.

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:

  1. expiresAt MUST be after issuedAt.
  2. signature MUST verify against the tenant signing key embedded in the bundle.
  3. features is 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 revoked to any other state.
  • No transition from built back to building (new version = new PlayPackage).

4.2 PlayPackageBundle Lifecycle​

┌────────────┐
create │ available │──────────► (downloadable, synced)
──────────► │ │
└─────┬──────┘
│ revoke
▼
┌────────────┐
│ revoked │ (terminal, permanent)
└────────────┘

Transition Rules:

  • Bundle created directly in available status (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)​

EventTriggerPayload Key Fields
PackageBuildStartedBuild pipeline beginsplayPackageId, courseVersionId, locale
PackageBuildCompletedBuild succeedsplayPackageId, hash, signature
PackageBuildFailedBuild failscourseVersionId, locale, errorCode, errorMessage
PackageRevokedRevocation commandplayPackageId, reason, revokedBy
BundleCreatedBundle encryption + upload completebundleId, playPackageId, deviceId, enrollmentId
BundleRevokedBundle revocationbundleId, reason
TamperDetectedClient hash mismatch reportbundleId, deviceId, expectedHash, actualHash
ExportCompletedSCORM/HTML5/xAPI export doneplayPackageId, format, zipUrl
ImportCompletedSCORM import processedimportId, 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​

  1. PlayPackage revocation cascades to Bundles. When a PlayPackage is revoked, all Bundles referencing it are revoked in the same transaction. Events emitted for each.

  2. Bundle cannot outlive its PlayPackage. A Bundle's playPackageId must reference a non-revoked PlayPackage at creation time.

  3. One active Bundle per (PlayPackage, Enrollment, Device). If a new bundle is created for the same triple, the previous one is revoked first.

  4. License expiry is enforced client-side and server-side. The Player checks expiresAt locally; the sync-service validates on delta pull.

  5. 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.