Skip to main content

Application Logic

:::info Source Sourced from services/content-service/APPLICATION_LOGIC.md in the documentation repo. :::

Companion: DOMAIN_MODEL · EVENT_SCHEMAS · API_CONTRACTS

1. Use Case Catalog

Use CaseTriggerAggregateOutcome
BuildPlayPackageauthoring.course_draft.published.v1PlayPackageNew PlayPackage in built
RebuildPlayPackageAdmin API / asset changePlayPackageNew PlayPackage version; old one remains
CreateOfflineBundleenrollment.created.v1 + device boundPlayPackageBundleNew Bundle in available
IssueBundleLicenseBundle creationLicenseEnvelopeLicense embedded in bundle
RevokePlayPackageAdmin API / license revocation / GDPRPlayPackage + BundlesAll transition to revoked
RevokeBundleAdmin API / device unbind / GDPRPlayPackageBundleTransition to revoked
DetectTamperedBundleClient report APITamper event emitted; optional auto-revoke
ExportSCORM12Author/admin APIPlayPackage (reads)SCORM 1.2 zip in formats.scorm12
ExportSCORM2004Author/admin APIPlayPackage (reads)SCORM 2004 zip in formats.scorm2004
ExportHTML5Author/admin APIPlayPackage (reads)HTML5 zip in formats.html5
ExportXAPIAuthor/admin APIPlayPackage (reads)xAPI zip in formats.xapi
ImportSCORMAdmin/author APIValidated SCORM, then creates PlayPackage
HandleGDPRRequestgdpr.subject_request.received.v1Bundles + LicensesErase user's bundles/licenses
GetPackageManifestHTTP GETPlayPackage (reads)Returns manifest JSON

2. Use Case: BuildPlayPackage

2.1 Trigger

Consumes authoring.course_draft.published.v1 from NATS.

2.2 Preconditions

  • Draft is in published status (authoring-service enforces)
  • Tenant has an active signing key in KMS
  • All referenced assets exist in media-service with ready status

2.3 Flow

1. Validate envelope + payload against schema (Ajv)
2. Check idempotency: (tenantId, courseVersionId, locale) → if built package exists with same commitHash, no-op
3. Begin transaction:
a. INSERT play_package row with status='building'
b. INSERT outbox event 'PackageBuildStarted'
4. Commit. Return immediately; proceed asynchronously.
5. Async build pipeline (state: building):
a. Fetch published draft manifest from authoring-service (via read projection or HTTP)
b. For each asset reference:
- Resolve via MediaClient.getAsset(assetId)
- Verify asset.sha256 and sizeBytes
- Collect in AssetReference[]
c. Assemble PackageManifest:
- Map modules/lessons/blocks from draft
- Inject assistantConfig if tenant has AI enabled
- Compute navigation model
- Apply prerequisite rules
d. Compute SHA-256:
hash = SHA256(concat(asset.sha256 for asset in assets in manifest order))
e. Sign via KMSClient.sign(tenantId, hash) → JWS
f. Update play_package row:
SET status='built', hash=?, signature=?, manifest=?, assets=?, built_at=now()
g. INSERT outbox event 'content.play_package.built.v1'
h. Commit transaction
6. Failure at any step:
- DELETE play_package row (not left in 'building')
- INSERT outbox event 'content.play_package.build_failed.v1' (internal only)
- Log with traceId; alert if error rate exceeds threshold

2.4 Idempotency

Idempotency key: sha256(tenantId + courseVersionId + locale + commitHash)

  • Consumer inbox table stores processed event IDs (see 04 Event-Driven §6).
  • If a build for the same (courseVersionId, locale, commitHash) already exists in built status, the event is acknowledged without re-building.
  • Partial builds (stuck in building) are garbage-collected by a scheduled job after 1 hour.

2.5 Postconditions

  • play_package row exists with status = 'built'
  • Hash verifies against SHA-256 of pinned assets
  • Signature verifies against tenant signing key
  • Outbox event published for downstream consumers

2.6 Failure Modes

FailureBehavior
Media asset not foundRetry 3x with exponential backoff; then fail build, emit error event
Asset hash mismatchFail build immediately (possible tampering); emit security alert
KMS unavailableRetry with backoff up to 5 min; then fail build; build reattempted on next published event
Postgres unavailableRetry with backoff; NATS redelivery handles the rest
Manifest validation failureFail build; surface schema error in event payload

3. Use Case: CreateOfflineBundle

3.1 Trigger

  • Consumes enrollment.created.v1 → for each bound device of the user, create bundle
  • Consumes identity.device.bound_for_offline.v1 → for each active enrollment, create bundle
  • Explicit HTTP POST /api/v1/packages/{id}/bundles

3.2 Preconditions

  • PlayPackage exists in built status for the enrolled course + locale
  • Device is bound in identity-service with a valid public key
  • User has an active, non-revoked enrollment

3.3 Flow

1. Load PlayPackage (must be 'built'; reject otherwise)
2. Fetch device public key via IdentityClient.getDevicePubKey(deviceId)
3. Derive bundle encryption key:
bundleKey = HKDF(
ikm = tenantKey (from KMS),
salt = devicePubKey,
info = bundleId,
length = 32 bytes
)
4. Build bundle payload:
a. Fetch asset bytes from media-service (streaming, via signed URLs)
b. Assemble manifest.json + asset files in a tar archive
c. Encrypt archive with AES-256-GCM using bundleKey
d. Append HMAC tag
5. Upload encrypted blob to S3/R2 at path:
tenants/{tenantId}/bundles/{bundleId}.bin
6. Compute sha256 of encrypted blob
7. Issue LicenseEnvelope:
a. Construct envelope (bundleId, enrollmentId, userId, deviceId, features, expiresAt)
b. Sign via KMSClient.sign(tenantId, envelope)
8. Sign bundle: signature = JWS over (bundleId + sha256 + kid)
9. Begin transaction:
a. INSERT bundle row
b. INSERT outbox event 'content.play_package.bundle.published.v1'
10. Commit.
11. If a previous bundle exists for (playPackageId, enrollmentId, deviceId),
revoke it in the same transaction.

3.4 Key Derivation Detail

HKDF-Extract-and-Expand (RFC 5869) with SHA-256:

PRK = HKDF-Extract(salt = devicePubKey, IKM = tenantKey)
OKM = HKDF-Expand(PRK, info = bundleId, L = 32)
bundleKey = OKM

Properties:
- Per-device isolation: compromising one device's key does not affect others
- Per-bundle isolation: a replayed bundleId salt cannot decrypt a different bundle
- Tenant isolation: different tenant keys produce unrelated bundle keys

3.5 Idempotency

Idempotency key: (enrollmentId, deviceId, playPackageId)

Producer-side:

  • The outbox pattern ensures at-most-one successful publish per commit.
  • If the row exists with the same triple and status = 'available', no new bundle is created.

3.6 Postconditions

  • Bundle row in available status
  • Encrypted blob uploaded to S3/R2
  • LicenseEnvelope embedded in bundle metadata
  • Sync-service notified via event for device delta sync

4. Use Case: RevokePlayPackage

4.1 Trigger

  • HTTP POST /api/v1/packages/{id}/revoke (admin)
  • Consumes marketplace.license.revoked.v1 (affects all bundles under that license)
  • Consumes gdpr.subject_request.received.v1 (affects subject's bundles)

4.2 Flow

1. Load PlayPackage by id + tenantId (enforce RLS)
2. Reject if status != 'built' (already revoked or still building)
3. Begin transaction:
a. UPDATE play_package SET status='revoked', revoked_at=now(), revoked_by=?, revoke_reason=?
b. UPDATE bundles SET status='revoked', revoked_at=now() WHERE play_package_id=?
c. INSERT outbox event 'content.play_package.revoked.v1'
d. For each affected bundle, INSERT outbox event 'content.play_package.bundle.revoked.v1'
4. Commit.
5. Downstream: sync-service propagates revocation to devices on next sync pull.
6. Optional: issue signed URL invalidation against CDN (best effort).

4.3 Permanence

Revocation is irreversible. If a course needs to be re-activated, a new build is triggered from the draft, producing a new PlayPackage with a new ID.

5. Use Case: DetectTamperedBundle

5.1 Trigger

HTTP POST /api/v1/bundles/{id}/report-tamper from Player client.

5.2 Flow

1. Load Bundle by id + tenantId
2. Validate report:
a. Caller authenticated + license envelope present in request
b. License envelope signature verifies
c. Reported hash differs from expected hash
3. INSERT tamper_reports row (deviceId, reportedHash, reportedAt)
4. INSERT outbox event 'content.bundle.tamper_detected.v1'
5. Decision policy (configurable per tenant):
- first_report: flag only, no revocation
- confirmed_replay: auto-revoke bundle + alert security
- threshold_breach: N reports in T minutes → auto-revoke
6. If auto-revoke triggered: invoke RevokeBundle use case.
7. Always return 204 to caller (no information leakage).

5.3 Security Note

The tamper report endpoint is authenticated but must not leak whether a specific hash was expected. Always 204, regardless of validity of the reported hash.

6. Use Case: ExportSCORM (1.2 / 2004)

6.1 Flow

1. Load PlayPackage (status='built')
2. Generate imsmanifest.xml:
- SCORM 1.2: organizations/resources structure, cmi data model mapping
- SCORM 2004: sequencing rules, imscp namespace, activity tree
3. Copy asset files into /resources/{assetId}
4. Copy player runtime shim into /scorm-player/ (LMS-compatible JS)
5. Emit tracking bridge that maps xAPI → SCORM CMI
6. Zip all files
7. Upload to S3/R2 at tenants/{tid}/exports/scorm/{versionId}.zip
8. Update PlayPackage.formats.scorm12 (or scorm2004) with { zipUrl, sha256, sizeBytes }
9. INSERT outbox event 'content.export.completed.v1'

6.2 Validation

Generated zip is validated against SCORM conformance test suite in CI (SCORM Cloud API). Exports failing validation block merge.

7. Use Case: ImportSCORM

7.1 Flow

1. Upload SCORM zip to /api/v1/import/scorm (max 500 MB)
2. Create scorm_imports row in status='uploaded'
3. Async pipeline (sandboxed worker):
a. Extract zip to ephemeral tmpfs volume
b. Parse imsmanifest.xml against SCORM schema
c. Reject if any file contains eval() or other banned patterns (static scan)
d. Reject if asset count or total size exceeds limits
e. Map SCORM structure to internal manifest
f. Register each asset with media-service (upload + get MediaAssetId)
g. Create a synthetic CourseVersionId and trigger BuildPlayPackage
4. Update scorm_imports row with playPackageId + status='completed'
5. INSERT outbox event 'content.import.completed.v1'
6. Failures: status='failed' with error code; no partial PlayPackage retained

7.2 Sandbox Policy

  • Imports run in a dedicated worker with no outbound network except media-service.
  • The tmpfs volume is destroyed after the job.
  • ClamAV scan runs on all extracted files before processing.
  • No script execution; HTML files are parsed but never executed.

8. Saga: Offline Bundle Publish

This is the end-to-end saga owned by content-service.

8.1 State Machine

authoring.course_draft.published.v1


┌─────────────────────────┐
│ BUILD_PACKAGE │
│ - resolve assets │
│ - assemble manifest │
│ - sign │
└──────────┬──────────────┘
│ ok

┌─────────────────────────┐
│ PACKAGE_BUILT │ emits content.play_package.built.v1
└──────────┬──────────────┘
│ enrollment.created.v1 received
│ identity.device.bound_for_offline.v1 received

┌─────────────────────────┐
│ DERIVE_KEY │
│ - fetch devicePubKey │
│ - HKDF bundleKey │
└──────────┬──────────────┘
│ ok

┌─────────────────────────┐
│ ENCRYPT_UPLOAD │
│ - AES-256-GCM │
│ - S3/R2 multipart │
└──────────┬──────────────┘
│ ok

┌─────────────────────────┐
│ ISSUE_LICENSE │
│ - sign envelope │
└──────────┬──────────────┘
│ ok

┌─────────────────────────┐
│ BUNDLE_PUBLISHED │ emits content.play_package.bundle.published.v1
└──────────┬──────────────┘
│ sync-service picks up

┌─────────────────────────┐
│ DEVICE_SYNC_PENDING │ (sync-service owns)
└──────────┬──────────────┘
│ device acks via sync

┌─────────────────────────┐
│ DEVICE_ACKED │
└─────────────────────────┘

8.2 Compensations

Failed StepCompensation
DERIVE_KEY (device key invalid)Emit content.bundle.key_derivation_failed.v1; do not retry; await new device binding
ENCRYPT_UPLOAD (S3 failure)Retry with backoff up to 10 attempts; then fail saga; delete orphan S3 parts
ISSUE_LICENSE (KMS failure)Retry with backoff up to 5 min; if fails, delete uploaded blob; emit failure event
BUNDLE_PUBLISHED not consumed by syncDLQ alert; operator investigation; no auto-retry (event durable in JetStream)

8.3 Saga Timeouts

StepTimeoutAction on Timeout
BUILD_PACKAGE10 minMark failed; alert; manual rebuild
DERIVE_KEY30 secRetry 3x; then fail step
ENCRYPT_UPLOAD30 min (large bundles)Resume via multipart upload ID
ISSUE_LICENSE10 secRetry 3x; then fail

9. Concurrency Model

9.1 Build Pipeline

  • One worker per (tenantId, courseVersionId, locale) at a time (advisory lock).
  • Multiple tenants' builds run in parallel, bounded by worker pool size (default 10).
  • Asset resolution within a build is parallel (up to 20 concurrent fetches).

9.2 Bundle Creation

  • Multiple bundles per PlayPackage run in parallel.
  • Per-(enrollmentId, deviceId) is serialized via advisory lock to prevent duplicates.

9.3 Revocation

  • Revocation is serialized per PlayPackage via row-level lock (SELECT FOR UPDATE).
  • Cascade to bundles is single transaction.

10. Idempotency Strategy

All state-changing operations use one of:

  • NATS consumer inbox: eventId unique constraint; duplicates acknowledged without processing.
  • HTTP Idempotency-Key header: Required on write endpoints; stored in idempotency_keys table with 24h TTL.
  • Natural idempotency key: Some operations (like RevokePlayPackage) are naturally idempotent (already revoked = no-op).

11. Error Handling

Error ClassHTTP CodeEvent OutcomeRetry Policy
Validation error (400)400No eventNo retry; client fixes input
Not found (404)404No eventNo retry
Forbidden / cross-tenant (403)403Audit eventNo retry; security alert
Conflict (409)409No eventNo retry; client resolves
KMS transient500Retry queue3 retries, backoff 1s/4s/16s
S3 transient500Retry queue10 retries, backoff capped 60s
Upstream service unreachable503Retry queueCircuit breaker after 5 failures
Schema validation failureDLQNo retry; operator investigation