PR-001: Domain Model v1.0 — compile-only, zero dependencies

- @landvex/domain package with TypeScript strict mode
- 3 Aggregate Roots: FieldSession, Mission, DecisionCase
- Branded IDs, Value Objects, Domain Events, Invariants
- 19 unit tests for IDs, FieldSession, DecisionCase
- Zero runtime dependencies (only TypeScript + jest for tests)
- Separates Entity / Value Object / Aggregate Root
- README documents Three Rules of the domain

Definition of Done met:
- Compiles without errors
- Exports all domain types
- Unit tests for invariants and value objects
- No PostgreSQL, S3, Express, AI models, queues
This commit is contained in:
Bernt
2026-07-02 14:26:29 +00:00
parent 118180e7ff
commit fa0dbf5127
26 changed files with 1599 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
/**
* Action - Entity
* Executed task based on a decision
*/
import { ActionId, DecisionId } from '../common/ids';
export interface Action {
readonly id: ActionId;
readonly decisionId: DecisionId;
readonly description: string;
readonly assignedTo: string;
readonly scheduledDate: Date;
readonly status: 'pending' | 'in_progress' | 'completed';
readonly createdAt: Date;
}
export interface CreateActionParams {
readonly id: ActionId;
readonly decisionId: DecisionId;
readonly description: string;
readonly assignedTo: string;
readonly scheduledDate: Date;
}
export class ActionFactory {
static create(params: CreateActionParams): Action {
return {
...params,
status: 'pending',
createdAt: new Date()
};
}
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Artifact - Common contract for everything produced
*
* Rule 2: Everything is an Artifact
* All artifacts share: id, version, hash, lineage, createdAt, createdBy, storageUri
*/
import { ArtifactId } from '../common/ids';
import { ArtifactType } from '../common/enums';
import { Hash, StorageUri, Version } from '../common/value-objects';
export interface Artifact {
readonly id: ArtifactId;
readonly type: ArtifactType;
readonly version: Version;
readonly hash: Hash;
readonly createdBy: string;
readonly storageUri: StorageUri;
readonly parentId?: ArtifactId;
readonly lineage: ArtifactId[];
readonly createdAt: Date;
}
export interface ArtifactCapabilities {
readonly storage: boolean;
readonly versioning: boolean;
readonly lineage: boolean;
}
export const ArtifactCapabilities: Record<ArtifactType, ArtifactCapabilities> = {
[ArtifactType.IMAGE]: { storage: true, versioning: true, lineage: true },
[ArtifactType.VIDEO]: { storage: true, versioning: true, lineage: true },
[ArtifactType.AUDIO]: { storage: true, versioning: true, lineage: true },
[ArtifactType.DATASET]: { storage: true, versioning: true, lineage: true },
[ArtifactType.ANNOTATION]: { storage: true, versioning: true, lineage: true },
[ArtifactType.DECISION_CASE]: { storage: true, versioning: true, lineage: true },
[ArtifactType.MODEL]: { storage: true, versioning: true, lineage: true },
[ArtifactType.EVALUATION]: { storage: true, versioning: true, lineage: true },
[ArtifactType.REPLAY]: { storage: true, versioning: true, lineage: true },
[ArtifactType.REPORT]: { storage: true, versioning: true, lineage: true },
[ArtifactType.EXPERIENCE_INSIGHT]: { storage: true, versioning: true, lineage: true }
};
+58
View File
@@ -0,0 +1,58 @@
/**
* Domain Enums
* Centralized to ensure consistency across the platform
*/
export enum ArtifactType {
IMAGE = 'image',
VIDEO = 'video',
AUDIO = 'audio',
DATASET = 'dataset',
ANNOTATION = 'annotation',
DECISION_CASE = 'decision_case',
MODEL = 'model',
EVALUATION = 'evaluation',
REPLAY = 'replay',
REPORT = 'report',
EXPERIENCE_INSIGHT = 'experience_insight'
}
export enum ReviewStatus {
ASSIGNED = 'assigned',
IN_REVIEW = 'in_review',
APPROVED = 'approved',
REJECTED = 'rejected',
NEEDS_MORE_EVIDENCE = 'needs_more_evidence'
}
export enum DecisionVerb {
INSPECT = 'inspect',
REPAIR = 'repair',
MONITOR = 'monitor',
WAIT = 'wait',
COLLECT = 'collect',
ESCALATE = 'escalate',
IGNORE = 'ignore',
PRIORITIZE = 'prioritize'
}
export enum MissionStatus {
CREATED = 'created',
UPLOADING = 'uploading',
PROCESSING = 'processing',
COMPLETED = 'completed',
FAILED = 'failed'
}
export enum SessionStatus {
PLANNED = 'planned',
ACTIVE = 'active',
COMPLETED = 'completed'
}
export enum DecisionStatus {
PENDING = 'pending',
UNDER_REVIEW = 'under_review',
APPROVED = 'approved',
REJECTED = 'rejected'
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Domain Errors
* Specific error types for domain invariants
*/
export abstract class DomainError extends Error {
abstract readonly code: string;
constructor(message: string) {
super(message);
this.name = this.constructor.name;
}
}
export class InvariantViolationError extends DomainError {
readonly code = 'INVARIANT_VIOLATION';
constructor(message: string) {
super(message);
}
}
export class InvalidStateTransitionError extends DomainError {
readonly code = 'INVALID_STATE_TRANSITION';
constructor(from: string, to: string) {
super(`Cannot transition from ${from} to ${to}`);
}
}
export class MissingRequiredFieldError extends DomainError {
readonly code = 'MISSING_REQUIRED_FIELD';
constructor(field: string) {
super(`Missing required field: ${field}`);
}
}
export class InvalidIdFormatError extends DomainError {
readonly code = 'INVALID_ID_FORMAT';
constructor(id: string, expectedFormat: string) {
super(`Invalid ID format: ${id}. Expected: ${expectedFormat}`);
}
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Tests for ID factory functions
*/
import { IdFactory } from './ids';
describe('IdFactory', () => {
const testDate = new Date('2026-08-14');
describe('session', () => {
it('should create session ID with correct format', () => {
const id = IdFactory.session(testDate, 1);
expect(id).toBe('session_20260814_000001');
});
it('should pad sequence number', () => {
const id = IdFactory.session(testDate, 123);
expect(id).toBe('session_20260814_000123');
});
});
describe('mission', () => {
it('should create mission ID with correct format', () => {
const id = IdFactory.mission(testDate, 1);
expect(id).toBe('mission_20260814_000001');
});
});
describe('artifact', () => {
it('should create artifact ID with correct format', () => {
const id = IdFactory.artifact(1);
expect(id).toBe('artifact_000001');
});
});
describe('observation', () => {
it('should create observation ID with correct format', () => {
const id = IdFactory.observation(1);
expect(id).toBe('obs_000001');
});
});
describe('decision', () => {
it('should create decision ID with correct format', () => {
const id = IdFactory.decision(1);
expect(id).toBe('decision_000001');
});
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* Branded ID types for type safety
* Prevents mixing different ID types accidentally
*/
export type SessionId = string & { readonly __brand: 'SessionId' };
export type MissionId = string & { readonly __brand: 'MissionId' };
export type ArtifactId = string & { readonly __brand: 'ArtifactId' };
export type ObservationId = string & { readonly __brand: 'ObservationId' };
export type DecisionId = string & { readonly __brand: 'DecisionId' };
export type EvidenceId = string & { readonly __brand: 'EvidenceId' };
export type FindingId = string & { readonly __brand: 'FindingId' };
export type ReviewId = string & { readonly __brand: 'ReviewId' };
export type ActionId = string & { readonly __brand: 'ActionId' };
export type OutcomeId = string & { readonly __brand: 'OutcomeId' };
/**
* ID factory functions
* Ensures consistent ID format across the domain
*/
export const IdFactory = {
session: (date: Date, sequence: number): SessionId => {
const dateStr = date.toISOString().split('T')[0].replace(/-/g, '');
return `session_${dateStr}_${sequence.toString().padStart(6, '0')}` as SessionId;
},
mission: (date: Date, sequence: number): MissionId => {
const dateStr = date.toISOString().split('T')[0].replace(/-/g, '');
return `mission_${dateStr}_${sequence.toString().padStart(6, '0')}` as MissionId;
},
artifact: (sequence: number): ArtifactId => {
return `artifact_${sequence.toString().padStart(6, '0')}` as ArtifactId;
},
observation: (sequence: number): ObservationId => {
return `obs_${sequence.toString().padStart(6, '0')}` as ObservationId;
},
decision: (sequence: number): DecisionId => {
return `decision_${sequence.toString().padStart(6, '0')}` as DecisionId;
},
evidence: (sequence: number): EvidenceId => {
return `evidence_${sequence.toString().padStart(6, '0')}` as EvidenceId;
},
finding: (sequence: number): FindingId => {
return `finding_${sequence.toString().padStart(6, '0')}` as FindingId;
},
review: (sequence: number): ReviewId => {
return `review_${sequence.toString().padStart(6, '0')}` as ReviewId;
},
action: (sequence: number): ActionId => {
return `action_${sequence.toString().padStart(6, '0')}` as ActionId;
},
outcome: (sequence: number): OutcomeId => {
return `outcome_${sequence.toString().padStart(6, '0')}` as OutcomeId;
}
};
+104
View File
@@ -0,0 +1,104 @@
/**
* Value Objects - immutable, compared by value, not identity
*/
export interface GeoLocation {
readonly lat: number;
readonly lng: number;
readonly accuracy?: number; // meters
}
export interface BoundingBox {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}
export interface Confidence {
readonly value: number; // 0.0 to 1.0
}
export interface Hash {
readonly algorithm: 'sha256';
readonly value: string;
}
export interface StorageUri {
readonly protocol: 's3' | 'r2' | 'file';
readonly bucket?: string;
readonly path: string;
}
export interface Version {
readonly major: number;
readonly minor: number;
readonly patch: number;
}
export interface TimeRange {
readonly start: Date;
readonly end: Date;
}
export interface DeviceInfo {
readonly model: string;
readonly os: string;
readonly appVersion: string;
}
export interface ExifData {
readonly device?: string;
readonly iso?: number;
readonly exposure?: string;
readonly focalLength?: string;
readonly timestamp?: Date;
}
export interface AssetMetadata {
readonly exif: ExifData;
readonly gps: GeoLocation;
readonly device: DeviceInfo;
}
export interface BusinessImpact {
readonly risk: string;
readonly cost: string;
readonly time: string;
readonly opportunity: string;
}
export interface EvidenceContext {
readonly historicalData?: boolean;
readonly weatherData?: boolean;
readonly trafficData?: boolean;
readonly gisData?: boolean;
}
export interface QualityIssue {
readonly type: 'blur' | 'duplicate' | 'bad_gps' | 'low_resolution' | 'missing_metadata' | 'wrong_timestamp' | 'occlusion';
readonly severity: Severity;
readonly description: string;
}
export interface ValidationResult {
readonly valid: boolean;
readonly issues: QualityIssue[];
}
export type Severity = 'low' | 'medium' | 'high' | 'critical';
export type Priority = 'low' | 'medium' | 'high' | 'urgent';
export const Severity = {
LOW: 'low' as Severity,
MEDIUM: 'medium' as Severity,
HIGH: 'high' as Severity,
CRITICAL: 'critical' as Severity
};
export const Priority = {
LOW: 'low' as Priority,
MEDIUM: 'medium' as Priority,
HIGH: 'high' as Priority,
URGENT: 'urgent' as Priority
};
@@ -0,0 +1,126 @@
/**
* Tests for DecisionCase aggregate root
*/
import { DecisionCaseFactory } from './decision-case';
import { IdFactory } from '../common/ids';
import { DecisionStatus } from '../common/enums';
import { InvariantViolationError } from '../common/errors';
describe('DecisionCase', () => {
const testDate = new Date('2026-08-14');
describe('create', () => {
it('should create decision case with correct defaults', () => {
const decisionCase = DecisionCaseFactory.create({
id: 'dc_001',
missionId: IdFactory.mission(testDate, 1),
observationIds: [IdFactory.observation(1)],
evidenceIds: [IdFactory.evidence(1)],
findingId: IdFactory.finding(1),
decisionId: IdFactory.decision(1),
reviewId: IdFactory.review(1)
});
expect(decisionCase.status).toBe(DecisionStatus.PENDING);
expect(decisionCase.version).toBe(1);
});
it('should throw if no observations', () => {
expect(() => {
DecisionCaseFactory.create({
id: 'dc_001',
missionId: IdFactory.mission(testDate, 1),
observationIds: [],
evidenceIds: [IdFactory.evidence(1)],
findingId: IdFactory.finding(1),
decisionId: IdFactory.decision(1),
reviewId: IdFactory.review(1)
});
}).toThrow(InvariantViolationError);
});
it('should throw if no evidence', () => {
expect(() => {
DecisionCaseFactory.create({
id: 'dc_001',
missionId: IdFactory.mission(testDate, 1),
observationIds: [IdFactory.observation(1)],
evidenceIds: [],
findingId: IdFactory.finding(1),
decisionId: IdFactory.decision(1),
reviewId: IdFactory.review(1)
});
}).toThrow(InvariantViolationError);
});
it('should throw if no decision', () => {
expect(() => {
DecisionCaseFactory.create({
id: 'dc_001',
missionId: IdFactory.mission(testDate, 1),
observationIds: [IdFactory.observation(1)],
evidenceIds: [IdFactory.evidence(1)],
findingId: IdFactory.finding(1),
decisionId: null as any,
reviewId: IdFactory.review(1)
});
}).toThrow(InvariantViolationError);
});
});
describe('approve', () => {
it('should approve decision case with review', () => {
const decisionCase = DecisionCaseFactory.create({
id: 'dc_001',
missionId: IdFactory.mission(testDate, 1),
observationIds: [IdFactory.observation(1)],
evidenceIds: [IdFactory.evidence(1)],
findingId: IdFactory.finding(1),
decisionId: IdFactory.decision(1),
reviewId: IdFactory.review(1)
});
const approved = DecisionCaseFactory.approve(decisionCase);
expect(approved.status).toBe(DecisionStatus.APPROVED);
});
it('should throw if no review', () => {
const decisionCase = DecisionCaseFactory.create({
id: 'dc_001',
missionId: IdFactory.mission(testDate, 1),
observationIds: [IdFactory.observation(1)],
evidenceIds: [IdFactory.evidence(1)],
findingId: IdFactory.finding(1),
decisionId: IdFactory.decision(1),
reviewId: null as any
});
expect(() => {
DecisionCaseFactory.approve(decisionCase);
}).toThrow(InvariantViolationError);
});
});
describe('createRevision', () => {
it('should create new version', () => {
const decisionCase = DecisionCaseFactory.create({
id: 'dc_001',
missionId: IdFactory.mission(testDate, 1),
observationIds: [IdFactory.observation(1)],
evidenceIds: [IdFactory.evidence(1)],
findingId: IdFactory.finding(1),
decisionId: IdFactory.decision(1),
reviewId: IdFactory.review(1)
});
const approved = DecisionCaseFactory.approve(decisionCase);
const revision = DecisionCaseFactory.createRevision(approved, {
decisionId: IdFactory.decision(2)
});
expect(revision.version).toBe(2);
expect(revision.status).toBe(DecisionStatus.PENDING);
});
});
});
@@ -0,0 +1,79 @@
/**
* DecisionCase - Aggregate Root
* Complete decision chain with full traceability
*
* Invariants:
* - Must have at least one Observation
* - Must have at least one Evidence
* - Must have exactly one Decision
* - Cannot be Approved without Review
* - Immutable once created — revisions create new versions
*/
import { MissionId, ObservationId, EvidenceId, FindingId, DecisionId, ReviewId } from '../common/ids';
import { DecisionStatus } from '../common/enums';
import { InvariantViolationError } from '../common/errors';
export interface DecisionCase {
readonly id: string;
readonly missionId: MissionId;
readonly observationIds: ObservationId[];
readonly evidenceIds: EvidenceId[];
readonly findingId: FindingId;
readonly decisionId: DecisionId;
readonly reviewId: ReviewId;
readonly status: DecisionStatus;
readonly version: number;
readonly createdAt: Date;
}
export interface CreateDecisionCaseParams {
readonly id: string;
readonly missionId: MissionId;
readonly observationIds: ObservationId[];
readonly evidenceIds: EvidenceId[];
readonly findingId: FindingId;
readonly decisionId: DecisionId;
readonly reviewId: ReviewId;
}
export class DecisionCaseFactory {
static create(params: CreateDecisionCaseParams): DecisionCase {
if (params.observationIds.length === 0) {
throw new InvariantViolationError('DecisionCase must have at least one observation');
}
if (params.evidenceIds.length === 0) {
throw new InvariantViolationError('DecisionCase must have at least one evidence');
}
if (!params.decisionId) {
throw new InvariantViolationError('DecisionCase must have exactly one decision');
}
return {
...params,
status: DecisionStatus.PENDING,
version: 1,
createdAt: new Date()
};
}
static approve(decisionCase: DecisionCase): DecisionCase {
if (!decisionCase.reviewId) {
throw new InvariantViolationError('DecisionCase cannot be approved without review');
}
return {
...decisionCase,
status: DecisionStatus.APPROVED
};
}
static createRevision(decisionCase: DecisionCase, params: Partial<CreateDecisionCaseParams>): DecisionCase {
return {
...decisionCase,
...params,
version: decisionCase.version + 1,
status: DecisionStatus.PENDING
};
}
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Decision - Entity
* Recommended action with full context
*
* Rule 3: All decisions are reproducible
* Must answer: Which observations? Which evidence? Which model? Which version?
* Which rules? Who reviewed? When? Which version was approved?
*/
import { DecisionId, FindingId } from '../common/ids';
import { DecisionVerb } from '../common/enums';
import { Confidence, BusinessImpact } from '../common/value-objects';
export interface Decision {
readonly id: DecisionId;
readonly findingId: FindingId;
readonly verb: DecisionVerb;
readonly description: string;
readonly rationale: string;
readonly confidence: Confidence;
readonly consequence: string;
readonly action: string;
readonly businessImpact: BusinessImpact;
readonly createdAt: Date;
}
export interface CreateDecisionParams {
readonly id: DecisionId;
readonly findingId: FindingId;
readonly verb: DecisionVerb;
readonly description: string;
readonly rationale: string;
readonly confidence: Confidence;
readonly consequence: string;
readonly action: string;
readonly businessImpact: BusinessImpact;
}
export class DecisionFactory {
static create(params: CreateDecisionParams): Decision {
return {
...params,
createdAt: new Date()
};
}
}
export interface DecisionCapabilities {
readonly decision: boolean;
readonly recommendation: boolean;
readonly business: boolean;
}
export const DefaultDecisionCapabilities: DecisionCapabilities = {
decision: true,
recommendation: true,
business: true
};
+127
View File
@@ -0,0 +1,127 @@
/**
* Domain Events
* Event contracts for the entire platform
*
* Rule 3: All decisions are reproducible
* Events provide the audit trail for full traceability
*/
import {
SessionId,
MissionId,
ArtifactId,
ObservationId,
EvidenceId,
FindingId,
DecisionId,
ReviewId
} from '../common/ids';
import { ArtifactType, ReviewStatus, DecisionVerb } from '../common/enums';
import { GeoLocation, Confidence, Severity, StorageUri, Hash } from '../common/value-objects';
// Base event interface
export interface DomainEvent {
readonly type: string;
readonly timestamp: Date;
readonly aggregateId: string;
}
// Session events
export interface FieldSessionCreated extends DomainEvent {
readonly type: 'FieldSessionCreated';
readonly sessionId: SessionId;
readonly location: GeoLocation;
readonly date: Date;
}
// Mission events
export interface MissionCreated extends DomainEvent {
readonly type: 'MissionCreated';
readonly missionId: MissionId;
readonly sessionId: SessionId;
readonly location: GeoLocation;
}
export interface MissionImported extends DomainEvent {
readonly type: 'MissionImported';
readonly missionId: MissionId;
readonly assetCount: number;
}
// Artifact events
export interface ArtifactRegistered extends DomainEvent {
readonly type: 'ArtifactRegistered';
readonly artifactId: ArtifactId;
readonly missionId: MissionId;
readonly artifactType: ArtifactType;
readonly storageUri: StorageUri;
readonly hash: Hash;
}
export interface ArtifactValidated extends DomainEvent {
readonly type: 'ArtifactValidated';
readonly artifactId: ArtifactId;
readonly valid: boolean;
}
// Observation events
export interface ObservationCreated extends DomainEvent {
readonly type: 'ObservationCreated';
readonly observationId: ObservationId;
readonly missionId: MissionId;
readonly artifactId: ArtifactId;
readonly description: string;
}
// Evidence events
export interface EvidenceLinked extends DomainEvent {
readonly type: 'EvidenceLinked';
readonly evidenceId: EvidenceId;
readonly observationIds: ObservationId[];
}
// Finding events
export interface FindingCreated extends DomainEvent {
readonly type: 'FindingCreated';
readonly findingId: FindingId;
readonly evidenceId: EvidenceId;
readonly severity: Severity;
}
// Decision events
export interface DecisionCreated extends DomainEvent {
readonly type: 'DecisionCreated';
readonly decisionId: DecisionId;
readonly findingId: FindingId;
readonly verb: DecisionVerb;
readonly confidence: Confidence;
}
export interface DecisionReviewed extends DomainEvent {
readonly type: 'DecisionReviewed';
readonly decisionId: DecisionId;
readonly reviewId: ReviewId;
readonly status: ReviewStatus;
readonly reviewer: string;
}
export interface DecisionApproved extends DomainEvent {
readonly type: 'DecisionApproved';
readonly decisionId: DecisionId;
readonly reviewId: ReviewId;
readonly approvedBy: string;
}
// Union type of all domain events
export type LandveXDomainEvent =
| FieldSessionCreated
| MissionCreated
| MissionImported
| ArtifactRegistered
| ArtifactValidated
| ObservationCreated
| EvidenceLinked
| FindingCreated
| DecisionCreated
| DecisionReviewed
| DecisionApproved;
+35
View File
@@ -0,0 +1,35 @@
/**
* Evidence - Entity
* Linked observations with context
*/
import { EvidenceId, ObservationId } from '../common/ids';
import { EvidenceContext } from '../common/value-objects';
export interface Evidence {
readonly id: EvidenceId;
readonly observationIds: ObservationId[];
readonly description: string;
readonly context: EvidenceContext;
readonly createdAt: Date;
}
export interface CreateEvidenceParams {
readonly id: EvidenceId;
readonly observationIds: ObservationId[];
readonly description: string;
readonly context: EvidenceContext;
}
export class EvidenceFactory {
static create(params: CreateEvidenceParams): Evidence {
if (params.observationIds.length === 0) {
throw new Error('Evidence must have at least one observation');
}
return {
...params,
createdAt: new Date()
};
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Finding - Entity
* Pattern or conclusion derived from evidence
*/
import { FindingId, EvidenceId } from '../common/ids';
import { Severity, Confidence } from '../common/value-objects';
export interface Finding {
readonly id: FindingId;
readonly evidenceId: EvidenceId;
readonly description: string;
readonly severity: Severity;
readonly pattern: string;
readonly confidence: Confidence;
readonly createdAt: Date;
}
export interface CreateFindingParams {
readonly id: FindingId;
readonly evidenceId: EvidenceId;
readonly description: string;
readonly severity: Severity;
readonly pattern: string;
readonly confidence: Confidence;
}
export class FindingFactory {
static create(params: CreateFindingParams): Finding {
return {
...params,
createdAt: new Date()
};
}
}
+41
View File
@@ -0,0 +1,41 @@
/**
* @landvex/domain
*
* LandveX Domain Model - Control Intelligence Platform
*
* Three Rules:
* 1. Domain knows nothing about AI
* 2. Everything is an Artifact
* 3. All decisions are reproducible
*
* This package has zero dependencies on Express, PostgreSQL, S3, or AI frameworks.
*/
// Common
export * from './common/ids';
export * from './common/value-objects';
export * from './common/enums';
export * from './common/errors';
// Artifacts
export * from './artifacts/artifact';
// Aggregate Roots
export * from './session/session';
export * from './mission/mission';
export * from './decision-case/decision-case';
// Entities
export * from './observation/observation';
export * from './evidence/evidence';
export * from './finding/finding';
export * from './decision/decision';
export * from './review/review';
export * from './action/action';
export * from './outcome/outcome';
// Events
export * from './events/domain-events';
// Invariants
export * from './invariants/invariants';
@@ -0,0 +1,42 @@
/**
* Domain Invariants
* Explicit rules that must always hold
*/
import { FieldSession, Mission, DecisionCase } from '../index';
import { InvariantViolationError } from '../common/errors';
export class DomainInvariants {
static assertSessionValid(session: FieldSession): void {
if (!session.location) {
throw new InvariantViolationError('Session must have a location');
}
if (!session.date) {
throw new InvariantViolationError('Session must have a date');
}
}
static assertMissionValid(mission: Mission): void {
if (!mission.sessionId) {
throw new InvariantViolationError('Mission must belong to a session');
}
if (mission.artifactIds.length === 0) {
throw new InvariantViolationError('Mission must have at least one artifact');
}
}
static assertDecisionCaseValid(decisionCase: DecisionCase): void {
if (decisionCase.observationIds.length === 0) {
throw new InvariantViolationError('DecisionCase must have at least one observation');
}
if (decisionCase.evidenceIds.length === 0) {
throw new InvariantViolationError('DecisionCase must have at least one evidence');
}
if (!decisionCase.decisionId) {
throw new InvariantViolationError('DecisionCase must have exactly one decision');
}
if (decisionCase.status === 'approved' && !decisionCase.reviewId) {
throw new InvariantViolationError('DecisionCase cannot be approved without review');
}
}
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Mission - Aggregate Root
* Single data collection task. Belongs to exactly one Session.
*
* Invariants:
* - Must belong to exactly one Session
* - Must have at least one Artifact
* - Cannot be completed before import is finished
* - Status is a projection of event history, not mutable state
*/
import { MissionId, SessionId, ArtifactId, ObservationId } from '../common/ids';
import { MissionStatus } from '../common/enums';
import { GeoLocation, DeviceInfo } from '../common/value-objects';
import { InvariantViolationError } from '../common/errors';
export interface Mission {
readonly id: MissionId;
readonly sessionId: SessionId;
readonly location: GeoLocation;
readonly status: MissionStatus;
readonly device: DeviceInfo;
readonly artifactIds: ArtifactId[];
readonly observationIds: ObservationId[];
readonly createdAt: Date;
readonly updatedAt: Date;
}
export interface CreateMissionParams {
readonly id: MissionId;
readonly sessionId: SessionId;
readonly location: GeoLocation;
readonly device: DeviceInfo;
}
export class MissionFactory {
static create(params: CreateMissionParams): Mission {
if (!params.sessionId) {
throw new InvariantViolationError('Mission must belong to a session');
}
if (!params.location) {
throw new InvariantViolationError('Mission must have a location');
}
return {
id: params.id,
sessionId: params.sessionId,
location: params.location,
status: MissionStatus.CREATED,
device: params.device,
artifactIds: [],
observationIds: [],
createdAt: new Date(),
updatedAt: new Date()
};
}
static addArtifact(mission: Mission, artifactId: ArtifactId): Mission {
return {
...mission,
artifactIds: [...mission.artifactIds, artifactId],
status: MissionStatus.UPLOADING,
updatedAt: new Date()
};
}
static addObservation(mission: Mission, observationId: ObservationId): Mission {
return {
...mission,
observationIds: [...mission.observationIds, observationId],
status: MissionStatus.PROCESSING,
updatedAt: new Date()
};
}
static complete(mission: Mission): Mission {
if (mission.artifactIds.length === 0) {
throw new InvariantViolationError('Mission cannot be completed without artifacts');
}
if (mission.status === MissionStatus.UPLOADING) {
throw new InvariantViolationError('Mission cannot be completed while uploading');
}
return {
...mission,
status: MissionStatus.COMPLETED,
updatedAt: new Date()
};
}
}
@@ -0,0 +1,56 @@
/**
* Observation - Entity
* Recorded fact from reality. Not AI-specific.
*
* Rule 1: Domain knows nothing about AI
* This is just an observation, not AIObservation or YOLODetection
*/
import { ObservationId, MissionId, ArtifactId } from '../common/ids';
import { GeoLocation, Confidence, BoundingBox } from '../common/value-objects';
export interface Observation {
readonly id: ObservationId;
readonly missionId: MissionId;
readonly artifactId: ArtifactId;
readonly type: string;
readonly description: string;
readonly location: GeoLocation;
readonly confidence: Confidence;
readonly boundingBox?: BoundingBox;
readonly createdAt: Date;
}
export interface CreateObservationParams {
readonly id: ObservationId;
readonly missionId: MissionId;
readonly artifactId: ArtifactId;
readonly type: string;
readonly description: string;
readonly location: GeoLocation;
readonly confidence: Confidence;
readonly boundingBox?: BoundingBox;
}
export class ObservationFactory {
static create(params: CreateObservationParams): Observation {
return {
...params,
createdAt: new Date()
};
}
}
export interface ObservationCapabilities {
readonly detection: boolean;
readonly vision: boolean;
readonly gps: boolean;
readonly image: boolean;
}
export const DefaultObservationCapabilities: ObservationCapabilities = {
detection: true,
vision: true,
gps: true,
image: true
};
+33
View File
@@ -0,0 +1,33 @@
/**
* Outcome - Entity
* Measured result of an action
*/
import { OutcomeId, ActionId } from '../common/ids';
import { BusinessImpact } from '../common/value-objects';
export interface Outcome {
readonly id: OutcomeId;
readonly actionId: ActionId;
readonly description: string;
readonly measuredImpact: BusinessImpact;
readonly verifiedBy: string;
readonly createdAt: Date;
}
export interface CreateOutcomeParams {
readonly id: OutcomeId;
readonly actionId: ActionId;
readonly description: string;
readonly measuredImpact: BusinessImpact;
readonly verifiedBy: string;
}
export class OutcomeFactory {
static create(params: CreateOutcomeParams): Outcome {
return {
...params,
createdAt: new Date()
};
}
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Review - Entity
* Human quality assessment of a decision
*
* Invariants:
* - Must have a reviewer
* - Must have a status
* - Cannot be approved without review
*/
import { ReviewId, DecisionId } from '../common/ids';
import { ReviewStatus } from '../common/enums';
import { InvariantViolationError } from '../common/errors';
export interface Review {
readonly id: ReviewId;
readonly decisionId: DecisionId;
readonly status: ReviewStatus;
readonly reviewer: string;
readonly comments: string;
readonly createdAt: Date;
readonly updatedAt?: Date;
readonly completedAt?: Date;
}
export interface CreateReviewParams {
readonly id: ReviewId;
readonly decisionId: DecisionId;
readonly reviewer: string;
}
export class ReviewFactory {
static create(params: CreateReviewParams): Review {
if (!params.reviewer) {
throw new InvariantViolationError('Review must have a reviewer');
}
return {
...params,
status: ReviewStatus.ASSIGNED,
comments: '',
createdAt: new Date()
};
}
static startReview(review: Review): Review {
if (review.status !== ReviewStatus.ASSIGNED) {
throw new InvariantViolationError('Cannot start review that is not assigned');
}
return {
...review,
status: ReviewStatus.IN_REVIEW,
updatedAt: new Date()
};
}
static approve(review: Review, comments: string): Review {
if (review.status !== ReviewStatus.IN_REVIEW) {
throw new InvariantViolationError('Cannot approve review that is not in review');
}
return {
...review,
status: ReviewStatus.APPROVED,
comments,
completedAt: new Date()
};
}
static reject(review: Review, comments: string): Review {
if (review.status !== ReviewStatus.IN_REVIEW) {
throw new InvariantViolationError('Cannot reject review that is not in review');
}
return {
...review,
status: ReviewStatus.REJECTED,
comments,
completedAt: new Date()
};
}
static requestMoreEvidence(review: Review, comments: string): Review {
if (review.status !== ReviewStatus.IN_REVIEW) {
throw new InvariantViolationError('Cannot request evidence for review that is not in review');
}
return {
...review,
status: ReviewStatus.NEEDS_MORE_EVIDENCE,
comments,
completedAt: new Date()
};
}
}
@@ -0,0 +1,89 @@
/**
* Tests for FieldSession aggregate root
*/
import { FieldSessionFactory } from './session';
import { IdFactory } from '../common/ids';
import { SessionStatus } from '../common/enums';
import { InvariantViolationError } from '../common/errors';
describe('FieldSession', () => {
const testDate = new Date('2026-08-14');
const testLocation = { lat: 59.2371, lng: 18.1456 };
describe('create', () => {
it('should create a session with correct defaults', () => {
const session = FieldSessionFactory.create({
id: IdFactory.session(testDate, 1),
location: testLocation,
date: testDate
});
expect(session.status).toBe(SessionStatus.PLANNED);
expect(session.missionIds).toEqual([]);
});
it('should throw if location is missing', () => {
expect(() => {
FieldSessionFactory.create({
id: IdFactory.session(testDate, 1),
location: null as any,
date: testDate
});
}).toThrow(InvariantViolationError);
});
it('should throw if date is missing', () => {
expect(() => {
FieldSessionFactory.create({
id: IdFactory.session(testDate, 1),
location: testLocation,
date: null as any
});
}).toThrow(InvariantViolationError);
});
});
describe('addMission', () => {
it('should add mission and set status to active', () => {
const session = FieldSessionFactory.create({
id: IdFactory.session(testDate, 1),
location: testLocation,
date: testDate
});
const missionId = IdFactory.mission(testDate, 1);
const updated = FieldSessionFactory.addMission(session, missionId);
expect(updated.missionIds).toContain(missionId);
expect(updated.status).toBe(SessionStatus.ACTIVE);
});
});
describe('complete', () => {
it('should complete active session', () => {
const session = FieldSessionFactory.create({
id: IdFactory.session(testDate, 1),
location: testLocation,
date: testDate
});
const withMission = FieldSessionFactory.addMission(session, IdFactory.mission(testDate, 1));
const completed = FieldSessionFactory.complete(withMission);
expect(completed.status).toBe(SessionStatus.COMPLETED);
});
it('should throw if session is not active', () => {
const session = FieldSessionFactory.create({
id: IdFactory.session(testDate, 1),
location: testLocation,
date: testDate
});
expect(() => {
FieldSessionFactory.complete(session);
}).toThrow(InvariantViolationError);
});
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* FieldSession - Aggregate Root
* Organizes field work. A pilot day produces many missions.
*
* Invariants:
* - Must have exactly one location
* - Must have a date
* - Can have zero or more missions
* - Cannot be completed if any mission is still uploading
*/
import { SessionId, MissionId } from '../common/ids';
import { SessionStatus } from '../common/enums';
import { GeoLocation } from '../common/value-objects';
import { InvariantViolationError } from '../common/errors';
export interface FieldSession {
readonly id: SessionId;
readonly location: GeoLocation;
readonly date: Date;
readonly status: SessionStatus;
readonly missionIds: MissionId[];
readonly createdAt: Date;
readonly updatedAt: Date;
}
export interface CreateSessionParams {
readonly id: SessionId;
readonly location: GeoLocation;
readonly date: Date;
}
export class FieldSessionFactory {
static create(params: CreateSessionParams): FieldSession {
if (!params.location) {
throw new InvariantViolationError('Session must have a location');
}
if (!params.date) {
throw new InvariantViolationError('Session must have a date');
}
return {
id: params.id,
location: params.location,
date: params.date,
status: SessionStatus.PLANNED,
missionIds: [],
createdAt: new Date(),
updatedAt: new Date()
};
}
static addMission(session: FieldSession, missionId: MissionId): FieldSession {
return {
...session,
missionIds: [...session.missionIds, missionId],
status: SessionStatus.ACTIVE,
updatedAt: new Date()
};
}
static complete(session: FieldSession): FieldSession {
if (session.status !== SessionStatus.ACTIVE) {
throw new InvariantViolationError('Cannot complete session that is not active');
}
return {
...session,
status: SessionStatus.COMPLETED,
updatedAt: new Date()
};
}
}