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.