Skip to main content

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

AggregateStorageLifetimeID prefix
BackofficeSessionMemorystore12h TTL, slidingbos_
DashboardSnapshotMemorystore30s TTLdsh_
WorkbenchViewMemorystore15s TTLwbv_
AISuggestionInbox (entry)Memorystore + Postgres ack logmirror; ack permanentaim_
AlertInbox (entry)Memorystore + Postgres ack logmirror; ack permanentali_
OperatorActivityPostgres ledger90dact_
DeviceSyncStatusPostgres + Memorystoreper-device, until device deleteddsy_
OperatorPreferencesMemorystore + Postgres mirror30dopr_
KeyboardShortcutMapStatic + Postgres overridesversionedksm_
OfflineActionQueueHintPostgres7doaq_
LockActionProxyAuditPostgres ledger7y (regulated)lap_
MfaAttestationMemorystore short-TTL5 min step-up windowmfa_

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.
  • propertyScope is non-empty; sessions for operators with no property are rejected with MELMASTOON.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=true when 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.v1 the 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).
  • status derived: lastHeartbeatAt < now()-5minoffline; alert device.heartbeat.lost.v1.
  • lastCursor is what we believe the device has; canonical truth is in sync-service. We refresh on POST /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)

SubjectTrigger
melmastoon.bff.backoffice.session.opened.v1First authenticated request after no-session
melmastoon.bff.backoffice.session.closed.v1Sign-out / revoke / expire
melmastoon.bff.backoffice.dashboard.viewed.v1/dashboard served
melmastoon.bff.backoffice.workbench.opened.v1Any workbench view served
melmastoon.bff.backoffice.ai_suggestion.decided.v1Decision recorded
melmastoon.bff.backoffice.alert.acknowledged.v1Ack recorded
melmastoon.bff.backoffice.operator.activity.v1Per-action telemetry (sampled)
melmastoon.bff.backoffice.device.heartbeat.v1Each heartbeat (sampled)
melmastoon.bff.backoffice.sync.cursor_advanced.v1Cursor advance
melmastoon.bff.backoffice.sync.handshake_completed.v1Handshake result
melmastoon.bff.backoffice.lock.action_proxied.v1Lock proxy invoked

15. Value objects

VORule
OperatorRoleEnum; matches tenant-service taxonomy
BcpLocaleBCP-47; ^[a-z]{2,3}(-[A-Z]{2})?(-[A-Z][a-z]{3})?$
SessionIdbos_<ULID>
DeviceIddev_<ULID> (issued by iam-service device registry)
MfaAttestationIdmfa_<ULID>; single use
ISODateTimeRFC 3339 UTC, ms precision
SyncCursorOpaque to BFF; canonical authority is sync-service

16. Invariants summary

  1. BFF holds no domain data; any aggregate above carries projections, sessions, telemetry, or audit only.
  2. Sessions are device-bound; cross-device session reuse is rejected.
  3. Decisions and acks are write-once.
  4. MFA attestations are single-use, 5 min TTL.
  5. Lock-proxy audit rows are immutable and 7-year retained.
  6. No event emitted by this BFF asserts a domain fact; all events are telemetry.