DOMAIN_MODEL — reporting-service
Sibling: SERVICE_OVERVIEW · APPLICATION_LOGIC · API_CONTRACTS · EVENT_SCHEMAS · DATA_MODEL
Strategic anchors: 02 §7 Domain-Driven Design · standards/NAMING
The domain layer of reporting-service is pure TypeScript — no NestJS, no Drizzle, no fetch, no GCP SDK. Every aggregate is a class with a private constructor, factory methods (create* / from*), invariant checks, and explicit state-machine guards. CI enforces this with the platform's domain-layer ESLint pack.
1. Branded value objects (identifier types)
// services/reporting-service/src/domain/value-objects/ids.ts
import { Branded } from '@ghasi/domain-primitives';
export type ReportTemplateId = Branded<string, 'ReportTemplateId'>; // tpl_rep_<ulid>
export type TemplateVersionId = Branded<string, 'TemplateVersionId'>; // composite, ULID
export type ReportId = Branded<string, 'ReportId'>; // rep_<ulid>
export type ReportRunId = Branded<string, 'ReportRunId'>; // run_<ulid>
export type ReportScheduleId = Branded<string, 'ReportScheduleId'>; // sch_<ulid>
export type ReportSubscriptionId = Branded<string, 'ReportSubscriptionId'>; // sub_<ulid>
export type ReportFilterId = Branded<string, 'ReportFilterId'>; // flt_<ulid>
export type ExportArtifactId = Branded<string, 'ExportArtifactId'>; // art_<ulid>
export type RegulatorySubmissionId = Branded<string, 'RegulatorySubmissionId'>; // reg_<ulid>
All factories live in value-objects/ids.ts and reject malformed prefixes:
export const ReportRunId = {
from(raw: string): ReportRunId {
if (!/^run_[0-9A-HJKMNP-TV-Z]{26}$/.test(raw)) {
throw new InvalidIdError('ReportRunId', raw);
}
return raw as ReportRunId;
},
generate(clock: Clock, idgen: IdGenerator): ReportRunId {
return `run_${idgen.ulid(clock.now())}` as ReportRunId;
},
};
2. Supporting value objects
2.1 ColumnSpec, FilterSpec, LayoutBlock
export interface ColumnSpec {
key: string; // dot-path into the row payload, e.g., 'guest.fullName.latin'
header: I18nString; // localized header
type: 'string'|'number'|'currency'|'date'|'datetime'|'boolean'|'enum'|'rich';
align?: 'start'|'center'|'end';
width?: number; // mm for PDF, characters for XLSX, n/a for CSV
format?: ColumnFormat; // currency code/decimals/date pattern/enum map
visibleIn?: Array<'pdf'|'xlsx'|'csv'>;
groupBy?: boolean;
aggregate?: 'sum'|'avg'|'min'|'max'|'count'|'distinct_count';
}
export interface FilterSpec {
key: string; // 'stayWindow', 'propertyId', 'channel', 'paymentMethod'
type: 'date_range'|'enum'|'string'|'number'|'tenant_property_picker'|'tag_set';
required: boolean;
defaultValue?: unknown;
enumValues?: string[];
rangeBounds?: { min?: number|string; max?: number|string };
multi?: boolean;
}
export type LayoutBlock =
| { kind: 'header'; logo?: 'tenant'|'platform'|'jurisdiction'; title: I18nString; subtitle?: I18nString }
| { kind: 'kpi_band'; metrics: Array<{ label: I18nString; sourceKey: string; format?: ColumnFormat }> }
| { kind: 'table'; columns: ColumnSpec[]; sourceKey: string; emptyText?: I18nString; rowCap?: number }
| { kind: 'chart'; chartType: 'line'|'bar'|'pie'; sourceKey: string; xKey: string; yKeys: string[] }
| { kind: 'narrative'; html: I18nString } // sanitized via DOMPurify in renderer
| { kind: 'page_break' }
| { kind: 'footer'; signed?: boolean; pageNumbers?: boolean };
2.2 Locale, I18nString, RtlAware
export type Locale = 'ps-AF'|'fa-AF'|'fa-IR'|'tg-TJ'|'ar-SA'|'en-US'|'fr-FR';
export interface I18nString { default: string; variants?: Partial<Record<Locale, string>>; }
export const isRTL = (l: Locale) => l === 'ps-AF' || l === 'fa-AF' || l === 'fa-IR' || l === 'ar-SA';
2.3 RenderFormat, RetentionClass
export type RenderFormat = 'pdf'|'xlsx'|'csv';
export type RetentionClass = 'operational_2y'|'operational_7y'|'regulatory_10y_objectlock';
2.4 ActorRef, Channel
export interface ActorRef { type: 'user'|'system'|'scheduler'|'partner'; id: string; }
export type DeliveryChannel = 'email'|'in_app'|'desktop_sync'|'webdav'|'sftp';
2.5 AIProvenance (re-used from platform)
See 02 §11 — same shape used in reservation-service AI_INTEGRATION §2.
3. Aggregate: ReportTemplate and TemplateVersion
A ReportTemplate is a logical name; TemplateVersion is the immutable spec at a publish point.
3.1 Type shape
export class ReportTemplate {
private constructor(
public readonly id: ReportTemplateId,
public readonly tenantId: TenantId | null, // null = platform-shared
public readonly key: string, // 'reservation.daily_arrivals'
public readonly category: TemplateCategory, // 'operational'|'financial'|'compliance'|'regulatory'|'manager_dashboard'
public readonly versions: TemplateVersion[], // append-only; latest at end
public readonly archived: boolean,
public readonly createdAt: Date,
public readonly updatedAt: Date,
public readonly version: number, // OCC
) {}
// factories: createFresh, fromPersistence
// methods: publishVersion(spec: TemplateSpec, actor: ActorRef): TemplateVersion
// archive(actor: ActorRef): void
}
export class TemplateVersion {
private constructor(
public readonly id: TemplateVersionId,
public readonly templateId: ReportTemplateId,
public readonly versionNumber: number, // monotonically increasing per templateId
public readonly columns: ColumnSpec[],
public readonly filters: FilterSpec[],
public readonly layout: LayoutBlock[],
public readonly supportedFormats: RenderFormat[],
public readonly defaultLocale: Locale,
public readonly localeVariants: Locale[],
public readonly regulatory: boolean,
public readonly jurisdictionCode?: string, // ISO 3166-1 alpha-2 + optional sub-code, e.g. 'AF', 'TJ', 'IR-FARS'
public readonly retentionClass: RetentionClass,
public readonly dataSourceSpec: DataSourceSpec, // declarative input spec; see 3.2
public readonly publishedAt: Date,
public readonly publishedBy: ActorRef,
) {}
// immutable post-construction; no setters
}
3.2 DataSourceSpec
export type DataSourceSpec = {
primary: // every template has exactly one primary source
| { kind: 'bigquery'; queryRef: string; params: string[]; }
| { kind: 'service_read'; service: 'billing'|'reservation'|'inventory'|'housekeeping'|'staff'; queryName: string; }
| { kind: 'composite'; parts: Array<DataSourceSpec['primary']>; };
derived?: Array<{ // optional derived blocks (e.g., KPI bands)
key: string;
transform: 'sum'|'avg'|'min'|'max'|'count'|'distinct_count'|'group_by';
over: string;
by?: string[];
}>;
};
queryRef points to a registered BigQuery template owned and reviewed alongside analytics-service. Reporting never emits ad-hoc SQL into BigQuery.
3.3 Invariants
| # | Invariant | Error code |
|---|---|---|
| T1 | versionNumber strictly increasing per template | MELMASTOON.REPORTING.TEMPLATE_VERSION_OUT_OF_ORDER |
| T2 | Once published, TemplateVersion is immutable | MELMASTOON.REPORTING.TEMPLATE_LOCKED |
| T3 | regulatory == true ⇒ jurisdictionCode defined and retentionClass == 'regulatory_10y_objectlock' | MELMASTOON.REPORTING.REGULATORY_INCONSISTENT |
| T4 | Every LayoutBlock.kind=='table'.columns[*].key resolves into the data shape produced by dataSourceSpec | MELMASTOON.REPORTING.LAYOUT_REFERENCE_INVALID |
| T5 | defaultLocale ∈ localeVariants and at least one variant is supplied for every I18nString referenced | MELMASTOON.REPORTING.LOCALE_VARIANT_MISSING |
| T6 | At most one header and one footer block; both at fixed positions | MELMASTOON.REPORTING.LAYOUT_INVALID |
| T7 | Tenant-shared templates (tenantId == null) require platform-admin actor | MELMASTOON.IDENTITY.PERMISSION_DENIED |
3.4 State machine
draft ──publish──▶ published ──archive──▶ archived
└──supersede(newVersion)──▶ published (the older version is "superseded" but still resolvable for audit)
draft is not persisted as a TemplateVersion; it lives only inside publishVersion(spec) until validation passes.
4. Aggregate: Report and ReportRun
Report is the user-facing logical entity — "Daily Arrivals". ReportRun is one execution.
4.1 Report
export class Report {
private constructor(
public readonly id: ReportId,
public readonly tenantId: TenantId,
public readonly templateId: ReportTemplateId, // current default template
public readonly templateVersionPin: number | null, // optional pin; null ⇒ use latest published
public readonly displayName: I18nString,
public readonly defaultFilters: ResolvedFilterSet,
public readonly recentRunIds: ReportRunId[], // last 30, head=newest
public readonly createdAt: Date,
public readonly updatedAt: Date,
public readonly version: number, // OCC
) {}
}
4.2 ReportRun
export type ReportRunStatus =
| 'queued' | 'running' | 'rendering' | 'delivering'
| 'completed' | 'failed' | 'cancelled';
export class ReportRun {
private constructor(
public readonly id: ReportRunId,
public readonly tenantId: TenantId,
public readonly reportId: ReportId,
public readonly templateId: ReportTemplateId,
public readonly templateVersionId: TemplateVersionId, // immutable post-construction
public readonly templateVersionNumber: number,
public readonly resolvedFilters: ResolvedFilterSet,
public readonly requestedFormats: RenderFormat[],
public readonly requestedBy: ActorRef,
public readonly correlationId: string,
public readonly idempotencyKey: string,
public status: ReportRunStatus,
public readonly artifactIds: ExportArtifactId[],
public readonly errorCode: string | null,
public readonly errorDetail: string | null,
public readonly retryCount: number,
public readonly maxRetries: number,
public readonly queuedAt: Date,
public readonly startedAt: Date | null,
public readonly renderedAt: Date | null,
public readonly completedAt: Date | null,
public readonly failedAt: Date | null,
public readonly cancelledAt: Date | null,
public readonly aiProvenance: AIProvenance | null, // present when AI assisted query/anomaly callouts
public readonly version: number, // OCC
) {}
start(now: Date): ReportRunStartedEvent { /* … */ }
markRendering(now: Date): void { /* … */ }
attachArtifact(artId: ExportArtifactId): void { /* … */ }
markCompleted(now: Date): ReportCompletedEvent { /* … */ }
markFailed(now: Date, code: string, detail: string, retriable: boolean): ReportFailedEvent { /* … */ }
cancel(now: Date, actor: ActorRef): ReportCancelledEvent { /* … */ }
retryAfter(): Date | null { /* exponential backoff: 30s, 2m, 10m */ }
}
4.3 State machine
queued ──start──▶ running ──render──▶ rendering ──persist+sign──▶ delivering ──per-sub success─▶ completed
│ │ │
│ │ └─partial fail (retriable)→ delivering (retry)
│ │
│ └── data-source error ─▶ failed (retriable / terminal) ─retry─▶ queued (until maxRetries)
│
└── cancel(actor) ──▶ cancelled
| Transition | Guard |
|---|---|
queued → running | worker leases the run; attempts++ |
running → rendering | data source returned within deadline |
rendering → delivering | every requested format produced an ExportArtifact |
delivering → completed | every subscription delivery returned delivered or not_applicable |
* → failed | terminal after maxRetries exhausted, or non-retriable error code |
* → cancelled | only by GM/owner/scheduler-cancel; not from completed/failed |
4.4 Invariants
| # | Invariant | Error code |
|---|---|---|
| R1 | No cross-tenant — Report.tenantId == ReportRun.tenantId == TemplateVersion.tenantId (or template is platform-shared) | MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE |
| R2 | templateVersionId immutable post-construction | MELMASTOON.REPORTING.TEMPLATE_VERSION_LOCKED |
| R3 | requestedFormats ⊆ templateVersion.supportedFormats | MELMASTOON.REPORTING.FORMAT_NOT_SUPPORTED |
| R4 | resolvedFilters validates against templateVersion.filters | MELMASTOON.REPORTING.FILTER_INVALID |
| R5 | A ReportRun cannot transition from terminal (completed/failed/cancelled) | MELMASTOON.REPORTING.RUN_TERMINAL |
| R6 | OCC version checked on every save | MELMASTOON.GENERAL.PRECONDITION_FAILED |
| R7 | Idempotency key, when supplied, deduplicates (tenantId, idempotencyKey) to one run | MELMASTOON.SYNC.IDEMPOTENCY_KEY_REUSED |
4.5 Domain events emitted
ReportRequestedEvent, ReportStartedEvent, ReportCompletedEvent, ReportFailedEvent, ReportDeliveredEvent, ReportCancelledEvent — see EVENT_SCHEMAS.
5. Aggregate: ExportArtifact
export class ExportArtifact {
private constructor(
public readonly id: ExportArtifactId,
public readonly tenantId: TenantId,
public readonly runId: ReportRunId,
public readonly format: RenderFormat,
public readonly locale: Locale,
public readonly bucket: string, // 'gs://melmastoon-reports-<region>'
public readonly objectPath: string, // 'tnt_…/run_…/<sha>.pdf'
public readonly sizeBytes: number,
public readonly sha256: string,
public readonly retentionClass: RetentionClass,
public readonly objectLockedUntil: Date | null, // for regulatory_10y_objectlock
public readonly producedAt: Date,
public readonly signedUrlCached: { url: string; expiresAt: Date } | null,
) {}
}
5.1 Invariants
| # | Invariant | Error code |
|---|---|---|
| A1 | Append-only; only signedUrlCached and objectLockedUntil (one-way to lock) may change post-create | MELMASTOON.REPORTING.ARTIFACT_LOCKED |
| A2 | sha256 matches uploaded GCS object metadata; mismatch fails the run | MELMASTOON.REPORTING.ARTIFACT_CHECKSUM_MISMATCH |
| A3 | format ∈ run.requestedFormats | MELMASTOON.REPORTING.FORMAT_NOT_SUPPORTED |
| A4 | objectPath starts with <tenantId>/ (defense-in-depth tenant prefix) | MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE |
6. Aggregate: ReportSchedule
export type ScheduleStatus = 'active'|'paused'|'disabled_after_failures';
export class ReportSchedule {
private constructor(
public readonly id: ReportScheduleId,
public readonly tenantId: TenantId,
public readonly reportId: ReportId,
public readonly cronExpr: string, // GCP-Scheduler-compatible
public readonly timezone: string, // IANA TZ
public readonly templateVersionPin: number | null, // null ⇒ use Report's pin (which may also be null)
public readonly filters: ResolvedFilterSet,
public readonly subscriptionIds: ReportSubscriptionId[],
public status: ScheduleStatus,
public readonly consecutiveFailures: number,
public readonly disabledAfterFailuresThreshold: number, // default 5
public readonly createdAt: Date,
public readonly createdBy: ActorRef,
public readonly updatedAt: Date,
public readonly version: number, // OCC
) {}
fire(now: Date): ScheduleFiredEvent { /* … */ }
recordRunOutcome(success: boolean): void {
if (success) this.consecutiveFailures = 0;
else if (++this.consecutiveFailures >= this.disabledAfterFailuresThreshold) {
this.status = 'disabled_after_failures';
}
}
}
6.1 Invariants
| # | Invariant | Error code |
|---|---|---|
| S1 | cronExpr parses against GCP cron grammar | MELMASTOON.REPORTING.SCHEDULE_INVALID_CRON |
| S2 | timezone must be IANA-valid | MELMASTOON.REPORTING.SCHEDULE_INVALID_TZ |
| S3 | subscriptionIds non-empty (a schedule with no recipients is useless and a likely misconfig) | MELMASTOON.REPORTING.SCHEDULE_NO_RECIPIENTS |
| S4 | Per-tenant collision: at most 5 schedules may fire within a 60-second window — beyond that the platform fans them across the next minute | MELMASTOON.REPORTING.SCHEDULE_COLLISION |
| S5 | disabled_after_failures re-enables only via explicit PATCH /schedules/:id { status: 'active' } (audit-recorded) | (state guard) |
7. Aggregate: ReportSubscription
export type SubscriptionStatus = 'active'|'paused'|'cancelled';
export class ReportSubscription {
private constructor(
public readonly id: ReportSubscriptionId,
public readonly tenantId: TenantId,
public readonly reportId: ReportId,
public readonly recipient: SubscriptionRecipient,
public readonly channel: DeliveryChannel,
public readonly format: RenderFormat,
public readonly locale: Locale,
public status: SubscriptionStatus,
public readonly lastDeliveredAt: Date | null,
public readonly lastDeliveryStatus: 'delivered'|'failed'|'unknown',
public readonly lastFailureCode: string | null,
public readonly createdAt: Date,
public readonly createdBy: ActorRef,
public readonly version: number, // OCC
) {}
}
export type SubscriptionRecipient =
| { kind: 'user'; userId: string }
| { kind: 'email'; address: string; nameHint?: string }
| { kind: 'desktop_device'; deviceId: string }
| { kind: 'webdav'; endpoint: string; credentialsRef: string }
| { kind: 'sftp'; host: string; path: string; credentialsRef: string };
7.1 Invariants
| # | Invariant | Error code |
|---|---|---|
| U1 | recipient validates against the chosen channel (e.g., email channel requires recipient.kind=='email' or 'user') | MELMASTOON.NOTIFICATION.RECIPIENT_INVALID |
| U2 | format ∈ templateVersion.supportedFormats for the report's current default version | MELMASTOON.REPORTING.FORMAT_NOT_SUPPORTED |
| U3 | A user-recipient must currently be a member of tenantId (re-checked at delivery time) | MELMASTOON.TENANT.NOT_A_MEMBER |
| U4 | Per-tenant cap: 200 active subscriptions per report (platform-default; configurable) | MELMASTOON.REPORTING.SUBSCRIPTION_LIMIT |
8. Aggregate: RegulatorySubmission
export type SubmissionStatus = 'pending'|'submitting'|'succeeded'|'failed'|'manually_resolved';
export class RegulatorySubmission {
private constructor(
public readonly id: RegulatorySubmissionId,
public readonly tenantId: TenantId,
public readonly runId: ReportRunId,
public readonly artifactId: ExportArtifactId,
public readonly jurisdictionCode: string,
public readonly adapterRef: string, // 'af.tourism.daily_guests.v1'
public status: SubmissionStatus,
public readonly attempts: number,
public readonly maxAttempts: number,
public readonly nextAttemptAt: Date | null,
public readonly proofOfDelivery: ProofOfDelivery | null,
public readonly lastErrorCode: string | null,
public readonly lastErrorDetail: string | null,
public readonly submittedAt: Date | null,
public readonly succeededAt: Date | null,
public readonly failedAt: Date | null,
public readonly version: number,
) {}
}
export interface ProofOfDelivery {
receiptKind: 'http_2xx_body'|'signed_xml_receipt'|'sftp_inode_ack'|'paper_print_signature';
receiptHash: string; // sha256 of receipt content
receivedAt: Date;
registerReference?: string; // external register's reference id
storedAtObjectPath: string; // GCS path to receipt artifact
}
8.1 Invariants
| # | Invariant | Error code |
|---|---|---|
| G1 | runId.templateVersion.regulatory == true | MELMASTOON.REPORTING.REGULATORY_INCONSISTENT |
| G2 | adapterRef exists in the registry and matches jurisdictionCode | MELMASTOON.REPORTING.REGULATORY_ADAPTER_MISSING |
| G3 | status='succeeded' ⇒ proofOfDelivery != null | MELMASTOON.REPORTING.PROOF_OF_DELIVERY_REQUIRED |
| G4 | attempts <= maxAttempts (default 5 over 24 h backoff) | MELMASTOON.REPORTING.SUBMISSION_RETRY_EXHAUSTED |
8.2 State machine
pending ──submit──▶ submitting ──ok──▶ succeeded
│
└──err (retriable)──▶ pending (with backoff)
└──err (terminal)───▶ failed
│
└──staff resolves manually──▶ manually_resolved
9. Domain errors (declared, mapped to canonical codes)
export class TemplateLockedError extends DomainError {
readonly code = 'MELMASTOON.REPORTING.TEMPLATE_LOCKED';
}
export class TemplateVersionLockedError extends DomainError {
readonly code = 'MELMASTOON.REPORTING.TEMPLATE_VERSION_LOCKED';
}
export class FilterInvalidError extends DomainError {
readonly code = 'MELMASTOON.REPORTING.FILTER_INVALID';
}
export class RegulatoryAdapterMissingError extends DomainError {
readonly code = 'MELMASTOON.REPORTING.REGULATORY_ADAPTER_MISSING';
}
export class FormatNotSupportedError extends DomainError {
readonly code = 'MELMASTOON.REPORTING.FORMAT_NOT_SUPPORTED';
}
export class RunTerminalError extends DomainError {
readonly code = 'MELMASTOON.REPORTING.RUN_TERMINAL';
}
export class ArtifactLockedError extends DomainError {
readonly code = 'MELMASTOON.REPORTING.ARTIFACT_LOCKED';
}
export class ScheduleInvalidCronError extends DomainError {
readonly code = 'MELMASTOON.REPORTING.SCHEDULE_INVALID_CRON';
}
export class SubscriptionLimitError extends DomainError {
readonly code = 'MELMASTOON.REPORTING.SUBSCRIPTION_LIMIT';
}
export class CrossTenantReferenceError extends DomainError {
readonly code = 'MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE';
}
All new codes are added to standards/ERROR_CODES.md under a new REPORTING section in the same PR that introduces them.
10. Domain services
| Service | Purpose |
|---|---|
FilterValidator | Validate a ResolvedFilterSet against a TemplateVersion.filters |
LayoutResolver | Resolve LayoutBlock[] against the data shape produced by DataSourceSpec (used at template publish time) |
RetentionClassifier | Derive RetentionClass from templateVersion.regulatory + tenant.region |
ScheduleNormalizer | Normalize cron + timezone; detect collisions |
RecipientResolver | Resolve SubscriptionRecipient against current tenant membership at delivery time |
These are pure-function classes with no I/O; ports live in the application layer.
11. Cross-references
- APPLICATION_LOGIC — use cases that orchestrate these aggregates
- API_CONTRACTS — REST surface
- EVENT_SCHEMAS — event payloads
- DATA_MODEL — Postgres tables, indexes, RLS policies
- SECURITY_MODEL — RBAC matrix that gates these commands