Skip to main content

DOMAIN_MODEL — theme-config-service

Sibling: SERVICE_OVERVIEW · APPLICATION_LOGIC · DATA_MODEL · EVENT_SCHEMAS

Strategic anchors: 06 Data Models §4.12 ThemeConfig · standards/NAMING · standards/ERROR_CODES

This document is the single source of truth for the domain shape — aggregates, entities, value objects, invariants, state machines, and domain events. It is framework-free; everything here is reproducible in any TypeScript runtime. The application layer (APPLICATION_LOGIC) orchestrates these aggregates; the infrastructure layer (DATA_MODEL) persists them.


1. Bounded-context glossary

TermMeaning
ThemeThe logical brand identity for a tenant (Phase 0) or property (Phase 2). One per (tenantId, propertyId?).
ThemeVersionAn immutable snapshot of every authoring artefact (tokens, layout selections, content, navigation, booking-flow, email overrides, locale packs) at one point in time.
ThemePublicationThe pointer that names the active version. Exactly one active publication per Theme; rollback creates a new publication pointing at an older version.
DesignTokenSetThe semantic token vocabulary: colors, typography, spacing, radius, shadow, motion.
SemanticColorA token name that maps to a brand role (primary, secondary, surface, accent, …) plus its on-color (textOnPrimary, …).
TypographyPairA heading + body font family pairing with a base size and a modular scale.
LayoutPresetA platform-registered, tenant-selectable page layout (e.g., hero-with-search for the home page).
ContentBlockA page-attached piece of content (about, policies, faq, gallery) with per-locale rich-text body.
NavigationConfigAn ordered tree of menu items per surface (header, footer, mobile_drawer).
BookingFlowConfigThe configurable shape of the booking funnel (which steps shown, which optional fields collected).
EmailThemeThe override block consumed by notification-service to render transactional emails in tenant brand.
LocalePackA per-locale keyed string bundle (cta.book_now, errors.required_field, …).
PreviewTokenA short-lived signed token granting anonymous read of one unpublished ThemeVersion.
AssetRefA MediaRef URL into file-storage-service. We never store bytes.
AIProvenanceReference VO from @ghasi/contracts-melmastoon; stored on any draft mutation that incorporates AI-generated content (palette / translation).

2. Branded ID types

All identifiers are ULIDs prefixed per standards/NAMING.md:

import { Branded } from '@ghasi/domain-primitives';

type ThemeId = Branded<string, 'ThemeId'>; // thm_…
type ThemeVersionId = Branded<string, 'ThemeVersionId'>; // thv_…
type ThemePublicationId= Branded<string, 'ThemePublicationId'>; // thp_…
type LayoutPresetId = Branded<string, 'LayoutPresetId'>; // lpr_…
type ContentBlockId = Branded<string, 'ContentBlockId'>; // cnb_…
type NavigationConfigId= Branded<string, 'NavigationConfigId'>; // nvc_…
type BookingFlowId = Branded<string, 'BookingFlowId'>; // bfc_…
type EmailThemeId = Branded<string, 'EmailThemeId'>; // emt_…
type PreviewTokenId = Branded<string, 'PreviewTokenId'>; // pvt_…

TenantId, PropertyId, UserId, MediaRef, Locale, AIProvenance are imported from @ghasi/contracts-melmastoon and are used as-is.


3. State machine: ThemeVersion

┌────────────────────────┐
│ │
│ draft │ ◀── initial (created via POST /themes/:id/versions)
│ │
└─────┬──────────┬───────┘
│ │
patch (PATCH) │ │ POST /preview
▼ ▼
│ ┌───────────────┐
│ │ preview_ready │ ◀── shareable preview URL minted
│ └─────┬─────────┘
│ │
│ │ patch → returns to draft (preview tokens revoked)
│ │
│ │ publish (final validation passes)
│ ▼
│ ┌───────────────┐
│ │ published │ ◀── ThemePublication points here
│ └─────┬─────────┘
│ │
│ │ a *new* version becomes published (this version is superseded)
│ │ OR rollback chooses a different active version
│ ▼
│ ┌───────────────┐
└──▶│ archived │ ◀── immutable; readable for audit
└───────────────┘

Illegal transitions (rejected with MELMASTOON.RESERVATION.INVALID_STATE_TRANSITION
— domain code MELMASTOON.THEME.INVALID_STATE_TRANSITION):
- published → draft (must clone into a new draft)
- archived → anything (immutable)
- draft → archived (only via supersession, never directly)

A ThemeVersion's state is owned by the ThemeVersion aggregate root; only the Theme aggregate (which holds the ThemePublication pointer) may mutate published ↔ archived. All other transitions are local to the ThemeVersion.


4. Aggregate: Theme

Identity: ThemeId (ULID, thm_…) Tenancy: mandatory TenantId; optional PropertyId (Phase 2) Concurrency: OCC via version: number (incremented on every mutation)

4.1 TypeScript shape

import {
TenantId, PropertyId, UserId, ISODate, Locale,
} from '@ghasi/contracts-melmastoon';

export interface Theme {
readonly id: ThemeId;
readonly tenantId: TenantId;
readonly propertyId: PropertyId | null;
name: string; // "Hotel Kabul Plaza — default"
scope: 'tenant' | 'property'; // 'property' iff propertyId !== null
activePublicationId: ThemePublicationId | null;// null only between provisioning and first publish
defaultLocale: Locale; // 'ps-AF' | 'fa-AF' | 'en-US' | …
enabledLocales: ReadonlyArray<Locale>; // must include defaultLocale
fallbackChain: ReadonlyArray<Locale>; // ordered; must terminate at defaultLocale or 'en'
status: 'active' | 'suspended' | 'soft_deleted';
createdAt: ISODate;
createdBy: UserId | null; // null when system-provisioned on tenant.created
updatedAt: ISODate;
updatedBy: UserId | null;
version: number; // OCC counter
}

4.2 Construction

export class ThemeAggregate {
static create(input: {
id: ThemeId;
tenantId: TenantId;
propertyId?: PropertyId | null;
name: string;
defaultLocale: Locale;
enabledLocales: Locale[];
fallbackChain: Locale[];
createdBy: UserId | null;
now: ISODate;
}): { theme: Theme; events: DomainEvent[] } {
if (!input.tenantId) throw new CrossTenantReferenceError();
if (!input.enabledLocales.includes(input.defaultLocale)) {
throw new DefaultLocaleRequiredError();
}
const last = input.fallbackChain[input.fallbackChain.length - 1];
if (last !== input.defaultLocale && last !== 'en' && last !== 'en-US') {
throw new FallbackChainInvalidError(`chain must terminate at defaultLocale or 'en'`);
}
if (new Set(input.fallbackChain).size !== input.fallbackChain.length) {
throw new FallbackChainInvalidError('chain contains a cycle');
}
const theme: Theme = {
id: input.id,
tenantId: input.tenantId,
propertyId: input.propertyId ?? null,
name: input.name,
scope: input.propertyId ? 'property' : 'tenant',
activePublicationId: null,
defaultLocale: input.defaultLocale,
enabledLocales: Object.freeze([...input.enabledLocales]),
fallbackChain: Object.freeze([...input.fallbackChain]),
status: 'active',
createdAt: input.now,
createdBy: input.createdBy,
updatedAt: input.now,
updatedBy: input.createdBy,
version: 1,
};
return { theme, events: [] }; // theme.created event lives on tenant provisioning saga
}
}

4.3 Invariants

#InvariantError
T1tenantId is present and matches the request X-Tenant-IdMELMASTOON.GENERAL.CROSS_TENANT_REFERENCE
T2scope === 'property' iff propertyId !== nullMELMASTOON.THEME.SCOPE_INCONSISTENT
T3enabledLocales includes defaultLocaleMELMASTOON.THEME.DEFAULT_LOCALE_REQUIRED
T4fallbackChain terminates at defaultLocale or en/en-US; no cyclesMELMASTOON.THEME.FALLBACK_CHAIN_INVALID
T5At most one Theme per (tenantId, propertyId) (Phase 0: one per tenant)MELMASTOON.THEME.DUPLICATE_THEME
T6activePublicationId references a ThemePublication with status = 'active' and themeId === idenforced by FK + unique index; runtime invariant MELMASTOON.THEME.PUBLISH_CONFLICT
T7Every mutation increments version; If-Match checked at the controller boundaryMELMASTOON.GENERAL.PRECONDITION_FAILED

5. Aggregate: ThemeVersion

Identity: ThemeVersionId (ULID, thv_…) Owner: Theme (FK themeId) Concurrency: OCC via version (the aggregate OCC, not the version number; that is ordinal)

5.1 TypeScript shape

export type ThemeVersionStatus = 'draft' | 'preview_ready' | 'published' | 'archived';

export interface ThemeVersion {
readonly id: ThemeVersionId;
readonly tenantId: TenantId;
readonly themeId: ThemeId;
readonly ordinal: number; // monotonic, per-theme; 1, 2, 3, …
readonly source: 'scaffold' | 'clone' | 'blank';
readonly clonedFromVersionId: ThemeVersionId | null;
status: ThemeVersionStatus;
archivedAt: ISODate | null;
archivedReason: 'superseded' | 'rolled_back' | 'soft_deleted' | null;

tokens: DesignTokenSet; // §6
layoutSelections: LayoutSelections; // §7
contentBlockIds: ReadonlyArray<ContentBlockId>; // ordered per page
navigationConfigIds: ReadonlyArray<NavigationConfigId>;
bookingFlowConfigId: BookingFlowId;
emailThemeId: EmailThemeId;
localePackIds: ReadonlyArray<string>; // composite (versionId, locale)
aiProvenance: ReadonlyArray<AIProvenance>; // every AI contribution recorded

publishedAt: ISODate | null;
publishedBy: UserId | null;
publishedBundleUrl: string | null; // gs://… immutable
publishedBundleSha256: string | null;
publishedBundleSize: number | null; // bytes (gzipped)

createdAt: ISODate;
createdBy: UserId | null;
updatedAt: ISODate;
updatedBy: UserId | null;
version: number; // OCC counter (separate from ordinal)
}

5.2 State transitions

From → ToTriggerDomain eventNotes
(none) → draftPOST /themes/:id/versionstheme.draft_created.v1clonedFromVersionId set if cloned
draftdraftPATCH /theme-versions/:idtheme.draft_updated.v1OCC checked
draftpreview_readyPOST /theme-versions/:id/previewtheme.preview_generated.v1Mints PreviewToken
preview_readydraftfurther PATCHtheme.draft_updated.v1All preview tokens for this version are revoked
draft or preview_readypublishedPOST /theme-versions/:id/publishtheme.published.v1 + theme.tokens_changed.v1 (with diff vs prev) + theme.cdn_cache_invalidated.v1Atomic flip; previous published version transitions to archived with reason superseded
publishedarchivedsystem, when superseded by a new publishtheme.version_archived.v1 (internal)reason: superseded
publishedarchivedrollback to a different versiontheme.version_archived.v1reason: rolled_back
draft or preview_readyarchivedDELETE /theme-versions/:id (soft-delete unused draft)theme.version_archived.v1reason: soft_deleted

Illegal transitions are rejected with MELMASTOON.THEME.INVALID_STATE_TRANSITION.

5.3 Invariants

#InvariantError
V1tenantId matches parent Theme.tenantIdMELMASTOON.GENERAL.CROSS_TENANT_REFERENCE
V2ordinal is monotonic and unique per themeIdenforced by unique index (themeId, ordinal)
V3A published or archived version is immutable; any PATCH returns 409 MELMASTOON.THEME.VERSION_IMMUTABLEcontroller + aggregate guard
V4publishedAt, publishedBy, publishedBundleUrl, publishedBundleSha256, publishedBundleSize are all-or-none and only set when status === 'published' || 'archived'aggregate guard
V5archivedAt set iff status === 'archived'; archivedReason set iff archivedAt setaggregate guard
V6If aiProvenance.length > 0, the publish workflow requires approvedBy + approvedAt recorded in the publish commandMELMASTOON.AI.HITL_REQUIRED
V7Every layout selection references a registered LayoutPresetMELMASTOON.THEME.LAYOUT_PRESET_UNKNOWN
V8Token contrast invariant (see §6)MELMASTOON.THEME.TOKEN_INVALID
V9RTL parity: every spacing.* and padding.* token has a logical-property derivativeMELMASTOON.THEME.RTL_VARIANT_MISSING

6. Value object: DesignTokenSet

The token vocabulary is flat-by-category, semantic-by-name. Tenants cannot add new token names; they can only override the values. New tokens are added by Frontend Platform via a registry-bumping migration with consumer-app coordination.

6.1 Shape

export interface DesignTokenSet {
color: ColorTokens;
typography: TypographyTokens;
spacing: SpacingScale;
radius: RadiusScale;
shadow: ShadowScale;
motion: MotionScale;
direction: 'ltr' | 'rtl' | 'auto'; // 'auto' resolves per locale at render
}

export interface ColorTokens {
// Brand
primary: HexColor;
primaryHover: HexColor;
primaryActive: HexColor;
textOnPrimary: HexColor;

secondary: HexColor;
secondaryHover: HexColor;
textOnSecondary: HexColor;

accent: HexColor;
textOnAccent: HexColor;

// Surfaces
surface: HexColor; // page background
surfaceMuted: HexColor; // card / panel background
surfaceElevated: HexColor; // modal / popover background
textOnSurface: HexColor; // body text on surface
textOnSurfaceMuted: HexColor; // helper text
border: HexColor;
divider: HexColor;

// Status
success: HexColor;
textOnSuccess: HexColor;
warning: HexColor;
textOnWarning: HexColor;
error: HexColor;
textOnError: HexColor;
info: HexColor;
textOnInfo: HexColor;

// Focus + interaction
focusRing: HexColor;
overlay: HexColor; // modal scrim (rgba string)
}

export type HexColor = string & { readonly __brand: 'HexColor' }; // /^#[0-9a-fA-F]{6}$/

export interface TypographyTokens {
fontFamilyHeading: string; // CSS family stack
fontFamilyBody: string;
fontFamilyMono: string;
baseSizePx: number; // 14 | 16 | 18
scaleRatio: number; // 1.125 | 1.2 | 1.25 (modular scale)
// Resolved scale (computed at publish):
size: { xs: number; sm: number; md: number; lg: number; xl: number; xxl: number };
weight: { regular: 400; medium: 500; semibold: 600; bold: 700 };
lineHeight: { tight: 1.2; snug: 1.35; normal: 1.5; relaxed: 1.7 };
letterSpacing: { tight: number; normal: number; wide: number };
}

export interface SpacingScale {
unit: number; // 4 | 6 | 8 (px)
// Resolved scale at publish:
values: { 0: 0; 1: number; 2: number; 3: number; 4: number; 6: number; 8: number; 12: number; 16: number };
// Logical-property derivatives generated at publish for direction:auto handling:
start: SpacingScale['values']; // padding-inline-start, margin-inline-start
end: SpacingScale['values'];
}

export interface RadiusScale { none: 0; sm: number; md: number; lg: number; pill: number }
export interface ShadowScale { none: 'none'; sm: string; md: string; lg: string; focus: string }
export interface MotionScale {
durationMsFast: number; // 120
durationMsBase: number; // 200
durationMsSlow: number; // 320
easingStandard: string; // cubic-bezier(0.2, 0, 0, 1)
easingEntrance: string;
easingExit: string;
}

6.2 Validation invariants

#RuleError
C1Every HexColor matches /^#[0-9a-fA-F]{6}$/ (no shorthand, no alpha — alpha lives in dedicated tokens)MELMASTOON.THEME.TOKEN_INVALID
C2Contrast (foreground, background) for every documented pair meets WCAG AA at publish: (textOnPrimary, primary) ≥ 4.5, (textOnSurface, surface) ≥ 4.5, (textOnError, error) ≥ 4.5, etc. Body text ≥ 4.5; large text (≥ 24 px or ≥ 19 px bold) ≥ 3.0.MELMASTOON.THEME.TOKEN_INVALID (with errors[] listing each violating pair)
C3baseSizePx ∈ {14, 16, 18}MELMASTOON.GENERAL.VALIDATION_FAILED
C4scaleRatio ∈ [1.067, 1.5]MELMASTOON.GENERAL.VALIDATION_FAILED
C5spacing.unit ∈ {4, 6, 8}MELMASTOON.GENERAL.VALIDATION_FAILED
C6direction ∈ {ltr, rtl, auto}MELMASTOON.GENERAL.VALIDATION_FAILED
C7At publish, spacing.start and spacing.end are computed from spacing.values and serialized into the bundle (logical properties); raw left / right keys are forbiddenMELMASTOON.THEME.RTL_VARIANT_MISSING
C8Font family stacks must include at least one safe fallback (system-ui, -apple-system, sans-serif); Noto Naskh Arabic and Noto Sans Arabic are recommended for RTL localeswarning event theme.token_validation_warning.v1

The contrast checker is a pure function (contrastRatio(fg: HexColor, bg: HexColor): number) implemented in the domain layer using the WCAG 2.1 luminance formula; it has no I/O dependency and is unit-tested exhaustively.


7. Value object: LayoutSelections

export type SurfaceKey = 'home' | 'listing' | 'detail' | 'booking' | 'post_stay';

export interface LayoutSelections {
home: LayoutPresetId; // e.g., 'lpr_HERO_WITH_SEARCH'
listing: LayoutPresetId; // e.g., 'lpr_MOSAIC_GRID' | 'lpr_LIST_WITH_MAP'
detail: LayoutPresetId; // e.g., 'lpr_GALLERY_TOP_INFO_BOTTOM'
booking: LayoutPresetId; // e.g., 'lpr_VERTICAL_STEPPER' | 'lpr_SINGLE_PAGE'
post_stay: LayoutPresetId; // e.g., 'lpr_THANK_YOU_WITH_REVIEW_CTA'
}

Every preset id must exist in the platform-global LayoutPresetRegistry (read-through cache). Unknown preset → MELMASTOON.THEME.LAYOUT_PRESET_UNKNOWN.

LayoutPreset (platform-global aggregate):

export interface LayoutPreset {
readonly id: LayoutPresetId;
readonly key: string; // 'hero-with-search'
readonly title: string; // human label for the authoring console
readonly applicableSurfaces: SurfaceKey[];
readonly supportsRtl: boolean; // must be true for any preset usable on RTL locales
readonly previewImageUrl: string;
readonly minTokens: ReadonlyArray<keyof DesignTokenSet['color']>; // contract: which color tokens this preset uses
readonly version: number;
readonly createdAt: ISODate;
readonly deprecatedAt: ISODate | null;
}

8. Aggregate: ContentBlock

export type ContentBlockKind =
| 'about_us' | 'amenities_grid' | 'gallery' | 'testimonials'
| 'faq' | 'contact' | 'policies' | 'location_map' | 'custom_markdown';

export interface ContentBlock {
readonly id: ContentBlockId;
readonly tenantId: TenantId;
readonly themeVersionId: ThemeVersionId;
readonly kind: ContentBlockKind;
surface: SurfaceKey; // which page this block lives on
ordinal: number; // ordering within the surface
visible: boolean;
body: I18nMarkup; // per-locale body, see §8.1
meta: ContentBlockMeta; // kind-specific structured data
createdAt: ISODate;
createdBy: UserId | null;
updatedAt: ISODate;
updatedBy: UserId | null;
version: number; // OCC
}

8.1 I18nMarkup value object

export interface I18nMarkup {
// For each enabled locale, an allow-listed Markdown string OR a sanitized HTML island:
readonly entries: ReadonlyMap<Locale, MarkupEntry>;
}

export interface MarkupEntry {
readonly format: 'markdown' | 'html_safe';
readonly content: string; // sanitized via DOMPurify (allow-list per kind)
readonly aiProvenance?: AIProvenance; // present only if AI-translated
readonly updatedAt: ISODate;
readonly updatedBy: UserId | null;
}

The Markdown allow-list bans script tags, inline event handlers, javascript: URLs, <iframe> (except YouTube/Vimeo via the allow-listed embed widget), and arbitrary CSS. Validation runs at save and again at publish; failure → MELMASTOON.THEME.CONTENT_INVALID.

8.2 ContentBlockMeta discriminated union

export type ContentBlockMeta =
| { kind: 'about_us'; heroImage?: MediaRef; cta?: { labelKey: string; href: string } }
| { kind: 'amenities_grid'; items: { iconKey: string; titleKey: string; bodyKey: string }[] }
| { kind: 'gallery'; media: MediaRef[]; layout: 'mosaic' | 'carousel' | 'grid' }
| { kind: 'testimonials'; items: { author: string; quoteI18n: I18nMarkup; rating?: 1|2|3|4|5 }[] }
| { kind: 'faq'; items: { questionI18n: I18nMarkup; answerI18n: I18nMarkup }[] }
| { kind: 'contact'; phone?: string; email?: string; whatsapp?: string; addressI18n?: I18nMarkup }
| { kind: 'policies'; documents: { typeKey: 'cancellation'|'house'|'pet'|'privacy'; bodyI18n: I18nMarkup }[] }
| { kind: 'location_map'; lat: number; lng: number; zoom: number; markerLabelKey?: string }
| { kind: 'custom_markdown'; titleI18n?: I18nMarkup };

8.3 Invariants

#RuleError
B1Parent ThemeVersion must be draft or preview_ready to mutateMELMASTOON.THEME.VERSION_IMMUTABLE
B2surface is a valid SurfaceKey and the chosen LayoutPreset for that surface accepts blocks of this kind (per registry contract)MELMASTOON.THEME.BLOCK_NOT_ALLOWED_ON_SURFACE
B3body.entries must have an entry for defaultLocale; missing other locales fall back via the chainMELMASTOON.THEME.CONTENT_DEFAULT_LOCALE_REQUIRED
B4Every MediaRef in meta must resolve at publish (HEAD against file-storage-service)MELMASTOON.THEME.ASSET_BROKEN
B5body content sanitised per the allow-list; rejected content does not enter persistenceMELMASTOON.THEME.CONTENT_INVALID
B6ordinal is unique per (themeVersionId, surface)enforced by unique index

9. Aggregate: NavigationConfig

One per (themeVersionId, surface ∈ {header, footer, mobile_drawer}).

export type NavSurface = 'header' | 'footer' | 'mobile_drawer';

export interface NavigationConfig {
readonly id: NavigationConfigId;
readonly tenantId: TenantId;
readonly themeVersionId: ThemeVersionId;
readonly surface: NavSurface;
items: ReadonlyArray<MenuItem>; // ordered tree
version: number; // OCC
createdAt: ISODate;
updatedAt: ISODate;
}

export interface MenuItem {
readonly id: string; // local UUID, stable across saves
labelI18n: I18nMarkup; // typically plain text per locale
target:
| { kind: 'route'; routeId: string; params?: Record<string, string> } // 'rooms', 'about', …
| { kind: 'external'; href: string; openInNewTab: boolean }
| { kind: 'anchor'; surface: SurfaceKey; blockOrdinal: number };
icon?: string; // icon-key from the platform icon set
visible: boolean;
children: ReadonlyArray<MenuItem>; // recursive (max depth 3)
}

9.1 Invariants

#RuleError
N1routeId must exist in the canonical route registryMELMASTOON.THEME.ROUTE_UNKNOWN
N2external.href must be HTTPS and parsable URLMELMASTOON.THEME.MENU_HREF_INVALID
N3Tree depth ≤ 3MELMASTOON.THEME.MENU_DEPTH_EXCEEDED
N4At most 8 top-level items per surfaceMELMASTOON.THEME.MENU_TOO_MANY_ITEMS
N5id uniqueness within the tree (recursive)enforced in aggregate

10. Aggregate: BookingFlowConfig

export type BookingStepKey =
| 'select_dates' | 'select_room' | 'select_rate' | 'guest_details'
| 'add_ons' | 'review' | 'payment' | 'confirmation';

export interface BookingFlowConfig {
readonly id: BookingFlowId;
readonly tenantId: TenantId;
readonly themeVersionId: ThemeVersionId;
steps: ReadonlyArray<BookingStepKey>; // ordered subset of canonical keys
fieldRequirements: ReadonlyMap<BookingStepKey, FieldRequirement[]>;
toggles: BookingFlowToggles;
version: number;
createdAt: ISODate;
updatedAt: ISODate;
}

export interface FieldRequirement {
readonly field: string; // 'guest.passportNumber', 'guest.dateOfBirth', …
required: boolean;
visibleByDefault: boolean;
helperTextKey?: string;
}

export interface BookingFlowToggles {
captureGuestPassport: boolean;
captureGuestNationalId: boolean;
requestAirportTransport: boolean;
allowGiftBooking: boolean;
requireGuestSignature: boolean;
showCancellationPolicySummary: boolean;
showPriceBreakdown: 'always' | 'on_review' | 'never';
allowCashOnArrival: boolean;
allowPayLaterByLink: boolean;
promptForReviewOnPostStay: boolean;
}

10.1 Invariants

#RuleError
F1steps is a non-empty ordered subset of the canonical step registry; select_dates, select_room, review, payment, confirmation are mandatoryMELMASTOON.THEME.BOOKING_STEP_REQUIRED
F2Every field in fieldRequirements exists in the canonical field registry; unknown field → MELMASTOON.THEME.BOOKING_FIELD_UNKNOWN
F3A field marked required: true must have visibleByDefault: trueMELMASTOON.THEME.BOOKING_FIELD_INCONSISTENT
F4If requireGuestSignature: true, the review step must be presentMELMASTOON.THEME.BOOKING_FLOW_INVALID
F5If allowCashOnArrival: false, payment step is mandatory and onlineMELMASTOON.THEME.BOOKING_FLOW_INVALID

11. Aggregate: EmailTheme

export interface EmailTheme {
readonly id: EmailThemeId;
readonly tenantId: TenantId;
readonly themeVersionId: ThemeVersionId;
brand: {
logoUrl: MediaRef; // height target: 48 px in email
logoAltKey: string; // i18n key in LocalePack
headerBackground: HexColor;
headerForeground: HexColor;
primary: HexColor;
textOnPrimary: HexColor;
surface: HexColor;
textOnSurface: HexColor;
};
typography: {
fontFamilyEmail: string; // email-safe stack (no web fonts in email)
baseSizePx: 14 | 15 | 16;
};
footer: {
addressI18n: I18nMarkup;
socialLinks: Array<{ platform: 'whatsapp'|'facebook'|'instagram'|'x'|'tiktok'|'youtube'; href: string }>;
legalI18n: I18nMarkup; // boilerplate (unsubscribe / company id / …)
showPoweredByMelmastoon: boolean;
};
version: number;
createdAt: ISODate;
updatedAt: ISODate;
}

11.1 Invariants

#RuleError
E1logoUrl must resolve and be ≤ 1 MiB (delegated to file-storage-service checks)MELMASTOON.THEME.ASSET_BROKEN / MELMASTOON.THEME.ASSET_TOO_LARGE
E2fontFamilyEmail must be in the email-safe fallback list (no @import web fonts)MELMASTOON.THEME.EMAIL_FONT_UNSAFE
E3Contrast (textOnPrimary, primary) ≥ 4.5; (textOnSurface, surface) ≥ 4.5MELMASTOON.THEME.TOKEN_INVALID
E4addressI18n and legalI18n must include defaultLocaleMELMASTOON.THEME.CONTENT_DEFAULT_LOCALE_REQUIRED

12. Aggregate: LocalePack

export interface LocalePack {
readonly tenantId: TenantId;
readonly themeVersionId: ThemeVersionId;
readonly locale: Locale; // 'ps-AF', 'fa-AF', 'en-US', …
formatting: {
dateFormatPattern: string; // e.g., 'YYYY/MM/DD' for fa-AF (Persian calendar handled in app)
timeFormat: '12h' | '24h';
currencyDisplay: 'symbol' | 'code' | 'narrowSymbol';
phoneFormat: 'international' | 'national';
decimalSeparator: '.' | ',' | '٫';
thousandSeparator: ',' | '.' | ' ' | '٬';
firstDayOfWeek: 0|1|2|3|4|5|6;
};
copy: ReadonlyMap<string, string>; // key → translated string (e.g., 'cta.book_now' → 'هم اوس حجز کړئ')
aiProvenance: AIProvenance | null; // present only if any copy entry is AI-translated and pending HITL approval
approvedBy: UserId | null;
approvedAt: ISODate | null;
version: number;
createdAt: ISODate;
updatedAt: ISODate;
}

12.1 Invariants

#RuleError
L1locale is a BCP-47 string in the platform's enabled setMELMASTOON.THEME.LOCALE_UNKNOWN
L2copy keys are a subset of the canonical LocalePackKeyRegistry; unknown keys rejectedMELMASTOON.THEME.COPY_KEY_UNKNOWN
L3If aiProvenance is set and the parent version is being published, approvedBy + approvedAt are requiredMELMASTOON.AI.HITL_REQUIRED
L4dateFormatPattern parseable by @formatjsMELMASTOON.GENERAL.VALIDATION_FAILED

13. Aggregate: PreviewToken

export interface PreviewToken {
readonly id: PreviewTokenId; // pvt_…
readonly tenantId: TenantId;
readonly themeId: ThemeId;
readonly themeVersionId: ThemeVersionId;
readonly tokenHash: string; // sha256(secret); the secret itself never persisted
readonly createdBy: UserId;
readonly issuedAt: ISODate;
readonly expiresAt: ISODate; // ≤ 7 days from issue
readonly revokedAt: ISODate | null;
readonly revokedBy: UserId | null;
readonly note: string | null; // free-text (e.g., "share with VP of brand")
readonly accessCount: number; // incremented on every preview fetch (sampled)
readonly lastAccessAt: ISODate | null;
}

13.1 Invariants

#RuleError
P1expiresAt - issuedAt ≤ 7 daysMELMASTOON.GENERAL.VALIDATION_FAILED
P2themeVersionId references a version with status ∈ {draft, preview_ready}MELMASTOON.THEME.VERSION_NOT_PREVIEWABLE
P3Verification of an incoming preview request requires tokenHash === sha256(presented) and revokedAt === null and now < expiresAtMELMASTOON.THEME.PREVIEW_TOKEN_INVALID
P4When the parent ThemeVersion transitions back to draft from preview_ready (i.e., further edits), all outstanding PreviewTokens for that version are revoked atomicallyaggregate event
P5When the parent ThemeVersion is published, all outstanding PreviewTokens for it are revoked atomicallyaggregate event

14. Domain events

Subjects follow melmastoon.theme.<aggregate>.<verb-past-tense>.v1 per standards/NAMING §Events. All events carry the standard envelope (see EVENT_SCHEMAS.md) with tenantId, eventId, occurredAt, causationId, correlationId, aggregateId.

SubjectProducer aggregateTriggerRetention class
melmastoon.theme.draft_created.v1ThemeVersionNew ThemeVersion enters draftwarm
melmastoon.theme.draft_updated.v1ThemeVersionDraft mutated (any patch)warm
melmastoon.theme.preview_generated.v1PreviewTokenPreview token mintedwarm
melmastoon.theme.preview_revoked.v1PreviewTokenToken revoked (manual or via state transition)warm
melmastoon.theme.published.v1Theme + ThemeVersionAtomic publish flip succeedswarm + audit
melmastoon.theme.publish_rejected.v1ThemeVersionFinal validation failed at publishwarm
melmastoon.theme.rolled_back.v1ThemePrevious version re-publishedwarm + audit
melmastoon.theme.tokens_changed.v1ThemeVersionToken diff between two consecutive published versions (carries the diff)warm
melmastoon.theme.content_block.created.v1ContentBlockBlock addedwarm
melmastoon.theme.content_block.updated.v1ContentBlockBlock editedwarm
melmastoon.theme.content_block.deleted.v1ContentBlockBlock removedwarm
melmastoon.theme.locale_added.v1ThemeLocale added to enabled setwarm
melmastoon.theme.locale_removed.v1ThemeLocale removedwarm
melmastoon.theme.layout_preset_changed.v1ThemeVersionLayout preset selection changed for a surfacewarm
melmastoon.theme.booking_flow_config_updated.v1BookingFlowConfigBooking flow toggles or steps changedwarm
melmastoon.theme.email_theme_updated.v1EmailThemeEmail theme override changedwarm
melmastoon.theme.cdn_cache_invalidated.v1ThemeCDN purge issued for the published bundlewarm
melmastoon.theme.broken_asset_detected.v1ThemeVersionAn asset URL referenced in a published version returns 404warm
melmastoon.theme.token_validation_failed.v1ThemeVersionAudit-only: tokens failed schema/contrast on save (not on publish — that just blocks)warm
melmastoon.theme.version_archived.v1ThemeVersionVersion archived (superseded / rolled_back / soft_deleted)warm

Detailed payload schemas live in EVENT_SCHEMAS.md.


15. Domain errors

Error class (TS)Code (registry)HTTP
CrossTenantReferenceErrorMELMASTOON.GENERAL.CROSS_TENANT_REFERENCE422
OptimisticConcurrencyErrorMELMASTOON.GENERAL.PRECONDITION_FAILED412
ThemeNotFoundErrorMELMASTOON.THEME.NOT_FOUND404
TokenInvalidErrorMELMASTOON.THEME.TOKEN_INVALID422
LayoutPresetUnknownErrorMELMASTOON.THEME.LAYOUT_PRESET_UNKNOWN422
RtlVariantMissingErrorMELMASTOON.THEME.RTL_VARIANT_MISSING422
AssetTooLargeErrorMELMASTOON.THEME.ASSET_TOO_LARGE413
AssetBrokenErrorMELMASTOON.THEME.ASSET_BROKEN422
VersionImmutableErrorMELMASTOON.THEME.VERSION_IMMUTABLE409
InvalidStateTransitionErrorMELMASTOON.THEME.INVALID_STATE_TRANSITION409
PublishConflictErrorMELMASTOON.THEME.PUBLISH_CONFLICT409
DefaultLocaleRequiredErrorMELMASTOON.THEME.DEFAULT_LOCALE_REQUIRED422
FallbackChainInvalidErrorMELMASTOON.THEME.FALLBACK_CHAIN_INVALID422
LocaleUnknownErrorMELMASTOON.THEME.LOCALE_UNKNOWN422
LocaleInUseErrorMELMASTOON.THEME.LOCALE_IN_USE409
ContentInvalidErrorMELMASTOON.THEME.CONTENT_INVALID422
ContentDefaultLocaleRequiredErrorMELMASTOON.THEME.CONTENT_DEFAULT_LOCALE_REQUIRED422
BlockNotAllowedOnSurfaceErrorMELMASTOON.THEME.BLOCK_NOT_ALLOWED_ON_SURFACE422
RouteUnknownErrorMELMASTOON.THEME.ROUTE_UNKNOWN422
MenuHrefInvalidErrorMELMASTOON.THEME.MENU_HREF_INVALID422
MenuDepthExceededErrorMELMASTOON.THEME.MENU_DEPTH_EXCEEDED422
MenuTooManyItemsErrorMELMASTOON.THEME.MENU_TOO_MANY_ITEMS422
BookingStepRequiredErrorMELMASTOON.THEME.BOOKING_STEP_REQUIRED422
BookingStepUnknownErrorMELMASTOON.THEME.BOOKING_STEP_UNKNOWN422
BookingFieldUnknownErrorMELMASTOON.THEME.BOOKING_FIELD_UNKNOWN422
BookingFlowInvalidErrorMELMASTOON.THEME.BOOKING_FLOW_INVALID422
EmailFontUnsafeErrorMELMASTOON.THEME.EMAIL_FONT_UNSAFE422
PreviewTokenInvalidErrorMELMASTOON.THEME.PREVIEW_TOKEN_INVALID401
VersionNotPreviewableErrorMELMASTOON.THEME.VERSION_NOT_PREVIEWABLE409
HitlRequiredErrorMELMASTOON.AI.HITL_REQUIRED403

New codes added by this service (i.e., not already in the platform ERROR_CODES.md registry) must be added in the same PR as the code that throws them. The registry already lists MELMASTOON.THEME.NOT_FOUND, TOKEN_INVALID, LAYOUT_PRESET_UNKNOWN, RTL_VARIANT_MISSING, ASSET_TOO_LARGE; the additional codes used here (e.g., VERSION_IMMUTABLE, PUBLISH_CONFLICT, INVALID_STATE_TRANSITION, DEFAULT_LOCALE_REQUIRED, FALLBACK_CHAIN_INVALID, BOOKING_*, MENU_*, CONTENT_*, EMAIL_FONT_UNSAFE, PREVIEW_TOKEN_INVALID, VERSION_NOT_PREVIEWABLE, ASSET_BROKEN, LOCALE_*) ship as a registry update with this service.


16. Cross-aggregate invariants (publish-time orchestration)

Some invariants span aggregates and are enforced by the publish use case (in the application layer), not in any single aggregate constructor:

  1. Single active publication per Theme. Enforced by a partial unique index in Postgres (UNIQUE (theme_id) WHERE status = 'active') and by the use case running the flip inside a single transaction.
  2. Bundle integrity. The published bundle's SHA-256 written to ThemeVersion.publishedBundleSha256 must equal the SHA-256 of the bytes uploaded to GCS. The publish use case re-hashes after upload and rolls back the transaction on mismatch.
  3. Asset integrity at publish. Every MediaRef URL referenced anywhere in the version (content blocks, email theme, navigation icons) is HEAD-checked. Failure aborts the publish (override gated by an explicit allowBrokenAssets: true flag for staged migrations only; never available via UI).
  4. Locale completeness check (warning, not error). For every enabled locale, every LocalePack.copy key in the canonical registry that's marked required must have a translation. Missing entries do not block publish but generate theme.locale_completeness_warning metrics and a non-blocking warning in the publish response.
  5. Booking-flow ↔ payment consistency. If bookingFlowConfig.toggles.allowCashOnArrival === false, the payment step must be present in steps; if both false and payment is missing, publish is rejected with BookingFlowInvalidError.

17. References