Mission module — entity/usecases/repository/facade for UE5 mission orchestration with DAG validation, SHA-256 versioning and publish workflow. Workspace-scoped.
Purpose: domain model for UE5 mission authoring. A Mission is a DAG of gameplay nodes (objectives, conditions, dialogues, cinematics, rewards) compiled into an immutable MissionContract identified by a SHA-256 activeHash. The UE5 runtime pulls the published contract via the activeHash.
Scope: multi-tenant — missions are workspace-scoped (Organization → Workspace → Mission). Every gateway call carries organizationId. The entity also holds workspaceId for workspace-level isolation.
src/modules/mission/
├── domain/
│ ├── mission.entity.ts ← rich entity (publish, updateMission, events)
│ ├── validators/mission.validator.ts ← class-validator rules (create/update groups)
│ └── services/
│ ├── mission-hash.service.ts ← SHA-256 hash of MissionContract
│ └── dag-validator.service.ts ← tricolor DFS cycle + dead-end + unreachable
├── event/
│ ├── mission-created.event.ts
│ └── mission-published.event.ts
├── usecase/
│ ├── find-by-id/ ← throws NotFoundError(id, Mission)
│ ├── create/
│ ├── update/
│ ├── save-version/ ← needs FindByIdUseCase + MissionHashService
│ ├── publish/ ← needs FindByIdUseCase + EventDispatcher
│ ├── list-versions/
│ └── get-active/
├── gateway/mission.gateway.ts ← interface only, NO framework
├── repository/mission.repository.ts ← Prisma impl, uses relation filter for multi-tenant
├── facade/
│ ├── mission.facade.ts ← default export class
│ └── mission.facade.dto.ts ← pure interfaces + MissionDto + MissionFacadeInterface
├── factory/facade.factory.ts ← composes all usecases; accepts optional EventDispatcher
├── types/mission.types.ts ← CanvasGraph, MissionContract, DAGValidationErrors
└── __tests__/ ← mirrors module layout
The entity owns its invariants. Use cases only orchestrate.
// ❌ WRONG — anemic, usecase bypassing entity rules
mission._name = input.name;
await repo.update(mission);
// ✅ CORRECT
const mission = await this.findByIdUseCase.execute({ id, organizationId });
mission.updateMission({ name: input.name, description: input.description });
await this.missionRepository.update(mission);
Entity methods: Mission.create() (static factory), updateMission(props), publish(hash), changeName/Description/Status. Every mutator validates the relevant group and throws EntityValidationError on failure.
addEvent() in entity, pullEvents() in usecase after persistence// entity
this.addEvent(new MissionPublishedEvent(id, hash, orgId));
// usecase — AFTER persistence commits
await this.missionRepository.update(mission);
if (this.eventDispatcher) {
for (const event of mission.pullEvents()) {
await this.eventDispatcher.dispatch(event);
}
}
Never dispatch from inside the entity. If the DB write fails, events must not fire (dual-write bug).
FindByIdUseCase is the single point of "not found"Gateway returns Mission | null. The FindByIdUseCase throws new NotFoundError(id, Mission). Every other usecase that needs a mission injects FindByIdUseCaseInterface — never calls the gateway directly for lookup+throw.
organizationId.update() uses updateMany({ where: { id, organizationId } }).MissionVersion queries filter through the relation: { missionId, mission: { organizationId } }.saveVersion() pre-checks that the mission belongs to the org before inserting.workspaceId (required, validated as UUID on create).workspaceId on create and reads it back in toDomainEntity.workspaces/:workspaceId/missions — the param is injected into the create payload.workspaceId is immutable on the entity (no setter, set only at creation).DAGValidatorService.validate(graph, startNodeId?) uses:
Reward.Give, Flag.Set, Cinematic.Play).startNodeId.Returns { isValid, errors: DAGValidationError[] } where each error has { nodeId, errorType, message }.
SaveVersionUseCase injects DAGValidatorService and computes isValid and validationErrors from graphData before computing the hash. The frontend cannot override validation results — the usecase always validates server-side. This prevents invalid contracts from being published to the runtime.
// Inside SaveVersionUseCase
const validation = this.dagValidatorService.validate(input.graphData, startNodeId);
const contract = new MissionContract(input.missionData, input.graphData);
const hash = this.hashService.compute(contract);
await this.missionVersionRepository.create({
missionId, hash, isValid: validation.isValid, validationErrors: validation.errors
});
MissionHashService.compute(contract) returns SHA-256 hex of JSON.stringify(contract). Same content → same hash → runtime cache hit. Injected into SaveVersionUseCase.
mission.facade.dto.ts never imports class-validator. Class-validator decorators only live in usecase/**/*.usecase.dto.ts (because controllers use those classes). Facade consumers get clean interfaces.
active field aligned with BaseEntityThe entity persists active on create and update. toDomainEntity reads active from the DB row. Soft-delete sets deletedAt + deactivates (active = false).
| Use case | Signature (input → output) | Notes |
|---|---|---|
FindByIdUseCase | {id, organizationId} → Mission | throws NotFoundError(id, Mission) |
CreateUseCase | {id, name, description?, organizationId, workspaceId, authorId} → MissionDto | id must be snake_case; rejects duplicates; dispatches MissionCreatedEvent |
UpdateUseCase | {id, organizationId, name?, description?} → void | uses mission.updateMission() |
SaveVersionUseCase | {missionId, organizationId, authorId, graphData, missionData} → {id, missionId, hash, isValid, validationErrors, ...} | computes SHA-256 hash; validates DAG server-side |
PublishUseCase | {missionId, organizationId, versionHash} → {id, name, status, activeHash, updatedAt} | rejects invalid versions; dispatches MissionPublishedEvent |
ListVersionsUseCase | {missionId, organizationId, page?=1, perPage?=20} → SearchResult<MissionVersionSummaryDto> | |
GetActiveUseCase | {missionId, organizationId} → MissionContract | throws if no active version |
| Error | When |
|---|---|
NotFoundError(id, Mission) | mission missing in org |
NotFoundError(hash, {name:'MissionVersion'}) | version row missing |
NotFoundError(id, {name:'MissionActiveVersion'}) | mission exists but activeHash is null |
EntityValidationError | invalid DTO, duplicate id on create, publishing an invalid version |
src/infra/http/mission/mission.module.ts — MissionModule exports MissionService and provides MissionFacade via factory.src/infra/http/mission/mission.controller.ts — REST surface at workspaces/:workspaceId/missions. Guarded by AuthGuard + RolesGuard.
POST / (create) — requires DESIGNER, extracts workspaceId from route param.POST /:id/versions (saveVersion) — requires DESIGNER.PUT /:id/publish — requires DESIGNER.GET /:id/versions (listVersions) — requires VIEWER.GET /:id/active (getActive) — requires VIEWER.src/infra/http/mission/mission.service.ts — thin adapter delegating to MissionFacade.__tests__/ mirrors the module structure.jest.fn() using the makeSut() pattern.EventDispatcher mocked in Create/Publish specs to assert events fire only after repository.create/update resolves.workspaceId as a required UUID field.DeleteUseCase yet (soft delete via mission.delete() is on the entity).test/integration/mission/.Per CLAUDE.md's MANDATORY rule, update this file whenever you: