DOMAIN_MODEL — bff-backoffice-service
Sibling: SERVICE_OVERVIEW · APPLICATION_LOGIC · DATA_MODEL
Cross-cutting: Standards · NAMING · Standards · ERROR_CODES · ADR-0003
This BFF owns no domain state. The "domain model" here is the session and orchestration ergonomics of being a backoffice operator — and the projection-side caches that make composed dashboards fast. Every aggregate below is either a short-lived session blob, a TTL'd projection, or an append-only ledger row. None of it is the source of truth for a domain concept.
1. Aggregates summary
| Aggregate | Storage | Lifetime | ID prefix |
|---|---|---|---|
BackofficeSession | Memorystore | 12h TTL, sliding | bos_ |
DashboardSnapshot | Memorystore | 30s TTL | dsh_ |
WorkbenchView | Memorystore | 15s TTL | wbv_ |
AISuggestionInbox (entry) | Memorystore + Postgres ack log | mirror; ack permanent | aim_ |
AlertInbox (entry) | Memorystore + Postgres ack log | mirror; ack permanent | ali_ |
OperatorActivity | Postgres ledger | 90d | act_ |
DeviceSyncStatus | Postgres + Memorystore | per-device, until device deleted | dsy_ |
OperatorPreferences | Memorystore + Postgres mirror | 30d | opr_ |
KeyboardShortcutMap | Static + Postgres overrides | versioned | ksm_ |
OfflineActionQueueHint | Postgres | 7d | oaq_ |
LockActionProxyAudit | Postgres ledger | 7y (regulated) | lap_ |
MfaAttestation | Memorystore short-TTL | 5 min step-up window | mfa_ |
ID format follows NAMING: <prefix>_<ULID>.
2. BackofficeSession
Created on first authenticated request from a device-bound JWT.
interface BackofficeSession {
id: SessionId; // bos_<ULID>
tenantId: TenantId;
operatorId: OperatorId; // from JWT subject
deviceId: DeviceId; // from JWT cnf claim
propertyScope: PropertyId[]; // resolved from tenant-service.operatorRole
primaryPropertyId?: PropertyId; // operator-chosen
roles: OperatorRole[]; // resolved from tenant-service.operatorRole
locale: BcpLocale;
themeVersion: string; // for shortcut help / branded copy
appVersion: string; // desktop build version
appPlatform: 'win32' | 'darwin' | 'linux';
networkProfile: 'good' | 'flaky' | 'offline-recently';
cookieMintedAt: ISODateTime; // optional; SSR sometimes serves cookies
lastActivityAt: ISODateTime;
ttlExpiresAt: ISODateTime;
// hashed PII for telemetry (no plain)
fingerprintHash: string;
ipHash: string;
}
Invariants:
ttlExpiresAt - now() <= 12h.- Device-bound JWT subject =
operatorId;cnf.kid=deviceId. propertyScopeis non-empty; sessions for operators with no property are rejected withMELMASTOON.IAM.NO_PROPERTY_SCOPE.- Lifecycle hooks emit
melmastoon.bff.backoffice.session.opened.v1/.closed.v1.
3. DashboardSnapshot
interface DashboardSnapshot {
id: DashboardSnapshotId; // dsh_<ULID>
tenantId: TenantId;
propertyId: PropertyId;
operatorRole: OperatorRole; // permissioned widget set
composedAt: ISODateTime;
cacheKeyVersion: number; // bumps on bootstrap version change
widgets: {
today: TodayWidget;
arrivals: ArrivalsWidget;
departures: DeparturesWidget;
inHouse: InHouseWidget;
occupancy: OccupancyWidget;
revenue: RevenueWidget;
aiInbox: AIInboxWidget;
alerts: AlertsWidget;
housekeepingSummary?: HousekeepingSummaryWidget;
maintenanceSummary?: MaintenanceSummaryWidget;
syncStatus: SyncStatusWidget;
};
partial: boolean; // any widget timed-out
staleness: { [widgetName: string]: 'fresh' | 'stale' | 'unavailable' };
}
Invariants:
- TTL 30 s; cache key includes
(tenantId, propertyId, operatorRole). partial=truewhen any widget is'stale' | 'unavailable'.- Single-flight collapses concurrent identical compositions per cache key.
4. WorkbenchView
A union over the per-feature projections served by /today, /arrivals, /departures, /in-house, /housekeeping/board, /maintenance/board.
type WorkbenchView =
| TodayView
| ArrivalsView
| DeparturesView
| InHouseView
| HousekeepingBoardView
| MaintenanceBoardView;
Each carries { id: WorkbenchViewId, view: '...', composedAt, partial, items: [...] }. TTL 15 s. Single-flight per cache key.
5. AISuggestionInbox
interface AISuggestionInboxEntry {
id: SuggestionInboxEntryId; // aim_<ULID>
suggestionId: string; // upstream id (ai-orchestrator-service)
tenantId: TenantId;
propertyId: PropertyId;
category: 'overbooking_warning' | 'rate_change' | 'housekeeping_reorder'
| 'maintenance_priority' | 'guest_special_handling' | 'staffing'
| 'audit_anomaly' | 'other';
severity: 'info' | 'attention' | 'action_required';
createdAt: ISODateTime;
expiresAt: ISODateTime; // upstream-supplied
decision?: SuggestionDecision; // when set, immutable
decidedBy?: OperatorId;
decidedAt?: ISODateTime;
decidedDeviceId?: DeviceId;
provenance: AIProvenance;
}
interface SuggestionDecision {
outcome: 'accepted' | 'rejected' | 'modified';
modifiedDelta?: Record<string, unknown>;
notes?: string; // truncated to 500 chars
}
interface AIProvenance {
model: string;
modelVersion: string;
promptVersion: string;
modelClass: 'cloud' | 'edge';
signatureFingerprint: string;
}
Invariants:
- Decisions are write-once; mutation is rejected with
MELMASTOON.BFF.BACKOFFICE.DECISION_ALREADY_RECORDED. - Decisions emit
melmastoon.bff.backoffice.ai_suggestion.decided.v1. - BFF cache mirrors orchestrator inbox; on
melmastoon.ai.suggestion.invalidated.v1the mirror entry is dropped.
6. AlertInbox
interface AlertInboxEntry {
id: AlertInboxEntryId; // ali_<ULID>
alertId: string; // upstream notification-service id
tenantId: TenantId;
propertyId: PropertyId;
severity: 'info' | 'warning' | 'critical';
category: 'oversold' | 'no_show' | 'payment_failed' | 'lock_failed'
| 'sync_lag' | 'device_offline' | 'security' | 'other';
raisedAt: ISODateTime;
resolvedAt?: ISODateTime;
acknowledgedAt?: ISODateTime;
acknowledgedBy?: OperatorId;
acknowledgedDeviceId?: DeviceId;
payload: Record<string, unknown>; // upstream-typed
}
Acknowledgments are write-once per operator+alert; further acks 409 with MELMASTOON.BFF.BACKOFFICE.ALERT_ALREADY_ACKED.
7. OperatorActivity (ledger)
Append-only. Each row corresponds to a meaningful operator action.
interface OperatorActivityRow {
id: ActivityRowId; // act_<ULID>
tenantId: TenantId;
propertyId: PropertyId;
operatorId: OperatorId;
deviceId: DeviceId;
sessionId: SessionId;
occurredAt: ISODateTime;
category: 'view' | 'mutation_proxy' | 'lock_action' | 'ai_decision'
| 'alert_ack' | 'sync_handshake' | 'preferences_changed'
| 'auth' | 'other';
action: string; // e.g. 'reservation.check_in', 'dashboard.viewed'
resourceRef?: { kind: string; id: string }; // e.g. {kind:'reservation', id:'rsv_...'}
outcome: 'success' | 'failure' | 'partial';
latencyMs: number;
error?: { code: string; message?: string };
contextHash?: string; // sha256 of contextual snapshot for dispute resolution
}
Retention 90 days hot; export to BigQuery audit lake for 7-year retention. Indexed (tenantId, occurredAt DESC) and (operatorId, occurredAt DESC).
8. DeviceSyncStatus
interface DeviceSyncStatus {
id: DeviceSyncId; // dsy_<ULID>
tenantId: TenantId;
propertyId: PropertyId;
deviceId: DeviceId;
operatorId?: OperatorId; // last operator on the device
lastHeartbeatAt: ISODateTime;
lastSyncHandshakeAt?: ISODateTime;
lastCursor: SyncCursor; // see SYNC_CONTRACT
pendingHints: { aggregate: string; count: number }[]; // OfflineActionQueueHint summary
appVersion: string;
appPlatform: 'win32' | 'darwin' | 'linux';
installerChannel: 'stable' | 'beta' | 'canary';
networkProfile: 'good' | 'flaky' | 'offline-recently';
status: 'online' | 'idle' | 'offline' | 'stale_session';
}
interface SyncCursor {
pullCursor: string; // opaque to BFF; sync-service authoritative
pushAck: string;
cursorVersion: number;
serverTimestamp: ISODateTime;
}
Invariants:
- Updated on every heartbeat (PATCH semantics; partial fields allowed).
statusderived:lastHeartbeatAt < now()-5min→offline; alertdevice.heartbeat.lost.v1.lastCursoris what we believe the device has; canonical truth is insync-service. We refresh onPOST /sync/cursor.
9. OperatorPreferences
interface OperatorPreferences {
id: OperatorPrefsId; // opr_<ULID>
tenantId: TenantId;
operatorId: OperatorId;
propertyId?: PropertyId; // some prefs are per-property
layout: { density: 'comfortable' | 'compact'; columnsByPage: Record<string, string[]> };
shortcuts: KeyboardShortcutOverrides;
notifications: { sound: boolean; popup: boolean; aiBadge: boolean };
i18n: { locale: BcpLocale; firstDayOfWeek: 'sun' | 'mon' };
appearance: { theme: 'system' | 'light' | 'dark'; tenantThemeOverride?: string };
updatedAt: ISODateTime;
version: number; // optimistic concurrency
}
Source-of-truth is tenant-service.operatorPreferences; we read-through and mirror for fast /preferences GET. Writes proxy to tenant-service and update local mirror.
10. OfflineActionQueueHint
interface OfflineActionQueueHint {
id: OfflineHintId; // oaq_<ULID>
tenantId: TenantId;
deviceId: DeviceId;
operatorId: OperatorId;
reportedAt: ISODateTime;
pending: { aggregate: string; count: number; oldestAgeSeconds: number }[];
appVersion: string;
}
Reported on each heartbeat; never authoritative. Used to render "X actions pending on your other device" hints in multi-device staff workflows.
11. LockActionProxyAudit (ledger)
interface LockActionProxyAudit {
id: LockProxyAuditId; // lap_<ULID>
tenantId: TenantId;
propertyId: PropertyId;
operatorId: OperatorId;
deviceId: DeviceId;
reservationId: string;
action: 'issue' | 'revoke' | 'rebind' | 'extend';
vendor: string; // e.g. 'ttlock' | 'salto' | 'assa-vostio' | 'wiegand-generic'
occurredAt: ISODateTime;
outcome: 'success' | 'failure';
error?: { code: string; message?: string };
mfaAttestationId?: MfaAttestationId; // when step-up required
upstreamRequestId: string;
upstreamLatencyMs: number;
}
Retention 7 years (regulated audit); RLS-isolated per tenant.
12. MfaAttestation
Short-lived attestation that an operator completed a step-up MFA challenge for a sensitive action (lock revoke, refund, large folio adjustment).
interface MfaAttestation {
id: MfaAttestationId; // mfa_<ULID>
tenantId: TenantId;
operatorId: OperatorId;
deviceId: DeviceId;
scope: 'lock_revoke' | 'large_folio_adjust' | 'refund' | 'force_checkout';
attestedAt: ISODateTime;
expiresAt: ISODateTime; // 5 min
consumedAt?: ISODateTime; // single-use
upstreamFactor: 'totp' | 'webauthn' | 'sms';
iamAttestationToken: string; // opaque token from iam-service
}
Single-use, 5 min TTL; consumption recorded; replay rejected.
13. State machine: BackofficeSession
[init]
│
▼
[opening] ── auth refresh ──► [open] ── activity ──► [open]
│
├── idle 12h ──► [expired]
├── revocation ──► [revoked]
└── sign-out ──► [closed]
[expired]
[revoked]
[closed]
Force-logout on melmastoon.iam.session.revoked.v1 transitions to revoked; SSE pushes a forceLogout event to the device.
14. Domain events emitted (telemetry only)
| Subject | Trigger |
|---|---|
melmastoon.bff.backoffice.session.opened.v1 | First authenticated request after no-session |
melmastoon.bff.backoffice.session.closed.v1 | Sign-out / revoke / expire |
melmastoon.bff.backoffice.dashboard.viewed.v1 | /dashboard served |
melmastoon.bff.backoffice.workbench.opened.v1 | Any workbench view served |
melmastoon.bff.backoffice.ai_suggestion.decided.v1 | Decision recorded |
melmastoon.bff.backoffice.alert.acknowledged.v1 | Ack recorded |
melmastoon.bff.backoffice.operator.activity.v1 | Per-action telemetry (sampled) |
melmastoon.bff.backoffice.device.heartbeat.v1 | Each heartbeat (sampled) |
melmastoon.bff.backoffice.sync.cursor_advanced.v1 | Cursor advance |
melmastoon.bff.backoffice.sync.handshake_completed.v1 | Handshake result |
melmastoon.bff.backoffice.lock.action_proxied.v1 | Lock proxy invoked |
15. Value objects
| VO | Rule |
|---|---|
OperatorRole | Enum; matches tenant-service taxonomy |
BcpLocale | BCP-47; ^[a-z]{2,3}(-[A-Z]{2})?(-[A-Z][a-z]{3})?$ |
SessionId | bos_<ULID> |
DeviceId | dev_<ULID> (issued by iam-service device registry) |
MfaAttestationId | mfa_<ULID>; single use |
ISODateTime | RFC 3339 UTC, ms precision |
SyncCursor | Opaque to BFF; canonical authority is sync-service |
16. Invariants summary
- BFF holds no domain data; any aggregate above carries projections, sessions, telemetry, or audit only.
- Sessions are device-bound; cross-device session reuse is rejected.
- Decisions and acks are write-once.
- MFA attestations are single-use, 5 min TTL.
- Lock-proxy audit rows are immutable and 7-year retained.
- No event emitted by this BFF asserts a domain fact; all events are telemetry.