Skip to content

Trackable Assets Domain

The Trackable Assets domain (trackable_asset_v1) is a bounded context responsible for managing the complete lifecycle of physical assets that can be tracked, located, and attributed within an estate’s structural hierarchy. This domain handles asset creation, status management, positioning (both geographic and structural), commenting, imagery, and custom field management.

The Trackable Assets domain sits at the intersection of physical asset management and structural placement. It manages assets as they move through their lifecycle—from creation in draft status through active operation, maintenance, decommissioning, and deletion. Each asset can be positioned in multiple ways:

  1. Structural Assignment: Placement within the estate’s topology (site → building → room → level)
  2. Geographic Position: GeoJSON-based coordinates with optional altitude and accuracy metadata
  3. Layer Association: Optional placement within floor plan layers

The root aggregate of this domain, representing a single trackable asset with complete lifecycle state.

Identity:

  • TrackableAssetId: Unique identifier for the asset

Core Attributes:

final AssetNickname name; // User-friendly name
final AssetDescription? description; // Optional description
final AssetType assetType; // Catalog type code (e.g., 'hvac_ahu', 'fire_extinguisher')
final AssetStatus status; // Current operational status (draft, active, inactive, maintenance, decommissioned, deleted)

Positioning:

final GeoPosition? geoPosition; // Geographic position (lat/lng, altitude, accuracy)
final StructuralAssignment? structuralAssignment; // Placement in estate structure
final LayerId? layerId; // Optional layer association within a floor plan

Origin & Audit:

final CatalogueReference? catalogueRef; // Reference to catalogue listing used at creation
final UserId createdByUserId; // User who created the asset
final UserId? updatedByUserId; // User who last modified the asset
final DateTime createdAt; // Creation timestamp
final DateTime updatedAt; // Last modification timestamp

Extended Data:

final Metadata metadata; // Arbitrary metadata dictionary
final CustomFieldMap customFields; // Type-safe custom field storage (sourced from listing or user-added)
final AttachmentId? primaryImageId; // Primary image reference
final List<AttachmentId> attachmentIds; // All associated images
final List<AssetComment> comments; // Discussion/note trail with voice support

Key Methods:

  • isTopologyBindingStale(int currentSequence): Check if structural assignment is outdated relative to topology changes
  • canAddComment(): Validate comment creation eligibility
  • canUpdateComment(CommentId, UserId): Validate comment modification eligibility
  • Comment query helpers: getComment(), getCommentsByAuthor(), sortedCommentsByCreated, commentsWithVoiceNotes, etc.

The domain produces events that capture significant state changes:

Fired when a new asset is registered. Captures initial state including optional catalogue reference for field inheritance.

AssetCreatedEvent(
assetId: TrackableAssetId.fromString('asset-123'),
name: AssetNickname.fromString('Conference Room HVAC'),
assetType: AssetType.fromCode('hvac_ahu'),
structuralAssignment: StructuralAssignment(
siteId: SiteId.fromString('site-1'),
buildingId: BuildingId.fromString('bldg-1'),
level: BuildingLevel.ground,
topologySequence: 5,
),
createdByUserId: UserId.fromString('user-42'),
createdAt: DateTime.now(),
)

Fired when asset properties (name, description, type, structural assignment, layer) change. Note: Custom fields use their own event.

Fired when operational status transitions (draft → active → inactive → maintenance → decommissioned → deleted). Optionally includes a reason.

AssetStatusChangedEvent(
assetId: TrackableAssetId.fromString('asset-123'),
previousStatus: AssetStatus.active,
newStatus: AssetStatus.maintenance,
reason: AssetStatusReason.fromString('annual_inspection'),
updatedBy: UserId.fromString('user-42'),
updatedAt: DateTime.now(),
)

Fired when geographic position (GeoPosition) or structural assignment changes. Both can be updated independently or together.

High-level event tracking location reference changes (used for audit/change tracking).

Fired when an asset is permanently removed (status transition to deleted).

  • AssetCommentAddedEvent
  • AssetCommentUpdatedEvent
  • AssetCommentRemovedEvent

Each includes the comment object, actor, and timestamp for complete audit trails.

  • AssetImageUploadedEvent: New image attached with metadata
  • AssetImageDeletedEvent: Image removed
  • PrimaryImageSetEvent: Primary image designated

Fired when a custom field is modified. Includes the field key, type, new value, and actor.

Represents where an asset is placed within the estate’s structural hierarchy:

class StructuralAssignment {
final SiteId siteId; // Always present - root site
final BuildingId? buildingId; // Optional: building within site
final RoomId? roomId; // Optional: room within building
final BuildingLevel level; // Floor/level within building (e.g., G, 1, 2, -1)
final int topologySequence; // Version of topology when assigned
final DateTime assignedAt; // Assignment timestamp
final GeoFeatureKey? drawingFeatureKey; // Optional: link to drawing feature
}

Key Behaviors:

  • Invariant: If buildingId is present, level must also be present (assets in buildings must be on a level)
  • Topology Tracking: topologySequence records when the assignment was made, allowing staleness detection if the estate topology changes
  • Validation: isValid() and isStale() check assignment currency

Represents geographic location for map-based operations:

class GeoPosition {
final GeoJSONPoint point; // RFC 7946 compliant [longitude, latitude]
final double? altitude; // Optional elevation in meters
final double? accuracy; // Optional horizontal accuracy in meters
final DateTime? lastUpdated; // Optional timestamp of measurement
}

Provides convenience accessors: latitude, longitude, and methods for GeoJSON serialization.

Assets can be positioned via multiple, independent mechanisms:

  1. Structural: Within the estate hierarchy (required, always a site minimum)
  2. Geographic: Via GeoJSON coordinates (optional, typically outdoor/site level)
  3. Layer: Within a floor plan drawing (optional, interior placement visualization)

These patterns coexist. An asset might be assigned to Building A, Room 201, AND have a geographic position if it’s precisely mapped.

draft
active ←→ inactive ← → maintenance
↓ ↓
└──→ decommissioned → deleted

Status Values:

  • draft: New asset not yet in service
  • active: Operational and in use
  • inactive: Temporarily not in service (available for reactivation)
  • maintenance: Undergoing service or repair
  • decommissioned: Permanently out of service but retained for historical record
  • deleted: Removed from system
  • unknown: Fallback for unrecognized values

Assets relate to the estate structure through StructuralAssignment:

Estate (SiteId)
├── Building (BuildingId)
│ ├── Level (BuildingLevel: Ground, 1, 2, -1, etc.)
│ │ └── Room (RoomId)
│ │ └── Asset (with StructuralAssignment)
│ │
│ └── (unplaced building - still referenced by assets)
└── Asset (site-level, no building)

Key Patterns:

  1. Building Required for Level: Assets in buildings must specify a level. Unassigned assets start at ground level by default.

  2. Site Always Present: Every asset must reference a site, even if unassigned to buildings.

  3. Topology Sequence: The topologySequence in StructuralAssignment records the estate structure’s version at assignment time. When estate topology changes (buildings added/removed, rooms reorganized), this sequence increments. Assets with stale sequences can be identified for revalidation.

  4. Unplaced Buildings: The domain supports assets assigned to “unplaced” buildings (buildings without geographic geometry), allowing structural hierarchy representation independent of physical placement.

The domain implements directives (intent handlers) for all user-initiated operations:

CreateAssetDirective Creates a new asset, optionally inheriting custom fields from a catalogue listing.

CreateAssetPayload(
assetId: TrackableAssetId.fromString('asset-456'),
name: AssetNickname.fromString('Emergency Light'),
assetType: AssetType.fromCode('emergency_light'),
structuralAssignment: StructuralAssignment(
siteId: SiteId.fromString('site-1'),
buildingId: BuildingId.fromString('bldg-1'),
level: BuildingLevel.fromJson({'number': 2}),
topologySequence: 5,
),
createdByUserId: UserId.fromString('user-42'),
)

UpdateAssetStatusDirective Transitions asset status (e.g., draft → active).

UpdateAssetStatusPayload(
assetId: TrackableAssetId.fromString('asset-123'),
previousStatus: AssetStatus.draft,
newStatus: AssetStatus.active,
updatedBy: UserId.fromString('user-42'),
)

UpdateAssetPositionDirective Updates geographic and/or structural position.

UpdateAssetPositionPayload(
assetId: TrackableAssetId.fromString('asset-123'),
geoPosition: GeoPosition.fromLatLng(
latitude: 40.7128,
longitude: -74.0060,
accuracy: 5.0,
),
updatedByUserId: UserId.fromString('user-42'),
)

AssignAssetToTopologyDirective / BulkAssignAssetsToTopologyDirective Assign asset(s) to site/building/room within the estate structure.

AssignAssetToTopologyPayload(
assetId: TrackableAssetId.fromString('asset-123'),
siteId: SiteId.fromString('site-1'),
buildingId: BuildingId.fromString('bldg-2'),
roomId: RoomId.fromString('room-205'),
level: BuildingLevel.fromJson({'number': 2}),
updatedByUserId: UserId.fromString('user-42'),
)

AddAssetCommentDirective Attach a comment (with optional voice note, photo, or video).

AddAssetCommentPayload(
assetId: TrackableAssetId.fromString('asset-123'),
comment: AssetComment(
commentId: CommentId.generate(),
content: DomainText.fromString('Serviced filter'),
authorUserId: UserId.fromString('user-42'),
createdAt: DateTime.now(),
voiceNoteId: AttachmentId.fromString('voice-123'), // optional
),
)

UpdateAssetCommentDirective / RemoveAssetCommentDirective Modify or delete existing comments (author-only by default).

AssetImageUploadedEvent / AssetImageDeletedEvent Manage image attachments. Use PrimaryImageSetEvent to designate the primary image.

UpdateCustomFieldDirective Update a single custom field with type safety.

UpdateCustomFieldPayload(
assetId: TrackableAssetId.fromString('asset-123'),
fieldKey: CustomFieldKey.fromString('manufacturer'),
fieldType: CustomFieldType.text,
fieldValue: CustomFieldValue.text('ACME Corp'),
updatedByUserId: UserId.fromString('user-42'),
)

BulkDeleteAssetsDirective Delete multiple assets in a single operation.

BulkUpdateAssetPositionsDirective Update positions (e.g., rescale coordinates) for many assets at once (useful for drawing rescale).

BulkCreateAssetsFromListingDirective Create multiple assets from a single catalogue listing, fetching the listing once and applying defaults to all.

RevalidateAssetTopologyDirective Revalidate asset assignments against current estate topology (after topology changes).

RescaleAssetPositionsDirective Rescale all asset positions following drawing coordinate system changes.

The domain provides declarative selectors for common query patterns:

import 'package:trackable_asset_v1/trackable_asset_v1.dart';
// Stream assets in a specific topology scope
watchAssetsInTopology(
app: nomosApp,
workspaceId: workspaceId,
timelineId: timelineId,
siteId: 'site-1',
buildingId: 'bldg-1',
roomId: 'room-205',
onlyValidAgainstCurrentTopology: true,
currentTopologySequence: 10,
);
// Stream assets with geographic positions
watchAssetsWithGeo(
app: nomosApp,
workspaceId: workspaceId,
timelineId: timelineId,
siteId: 'site-1',
);
// Query assets within a geographic polygon
queryAssetsInGeoPolygon(
app: nomosApp,
workspaceId: workspaceId,
timelineId: timelineId,
polygon: GeoJSONPolygon(...),
siteId: 'site-1',
);
// Stream assets with stale topology assignments
watchAssetsWithStaleTopologyBinding(
app: nomosApp,
workspaceId: workspaceId,
timelineId: timelineId,
currentTopologySequence: 10,
siteId: 'site-1',
);

AssetChangeService Compute detailed asset changes between two points in time:

final service = AssetChangeService(nomosApp);
final changes = await service.computeAssetChanges(
workspaceId: workspaceId,
timelineId: timelineId,
siteId: siteId,
baselineSequence: 5,
currentSequence: 10,
);
// Returns ChangeItem records with before/after states and detailed change descriptions

Tracks:

  • Asset creation, deletion, updates
  • Property changes (name, type, status)
  • Structural assignment changes (building, room)
  • Geographic position changes
  • Attachment and comment count changes
  • Custom field additions/removals

Assets support extensible custom fields with type safety:

final customFields = CustomFieldMap({
CustomFieldKey.fromString('manufacturer'):
CustomField.text(key, 'ACME Corp'),
CustomFieldKey.fromString('warranty_years'):
CustomField.number(key, 5),
CustomFieldKey.fromString('needs_inspection'):
CustomField.boolean(key, true),
});

Field Sourcing: Custom fields track their origin:

  • listing: Inherited from catalogue listing at creation
  • userAdded: Added or modified by user after creation

Type-Aware Defaults: The domain applies asset-type-specific defaults at creation:

  • hvac_ahu: Quantity (1), Wattage (7500W)
  • fire_extinguisher: Compliance Required (true)
  • contracts_v1: Published language for IDs (TrackableAssetId, SiteId, BuildingId, etc.), asset types, asset status reasons, metadata structures
  • estate_structures_v1: Site/building/room definitions and topology structure
  • catalogues_v1: Listing aggregates for field inheritance
  • foundation_geometry_v1: Geometric operations
  • nomos_core: Core framework (events, aggregates, directives, GeoJSON via nomos_geo)
  • Applications & UIs: Read asset state, dispatch directives, subscribe to events
  • Policies & Workflows: Validate operations, orchestrate multi-domain flows
  • Analytics: Consume events for reporting and auditing

Asset Aggregate Invariants:

  1. Asset ID cannot be unresolved (unless aggregate is empty)
  2. Non-deleted assets must have a non-empty name
  3. Non-deleted assets must have a non-empty type code
  4. Building-Level Invariant: If structuralAssignment.buildingId is present, level must also be present

Status Transition Rules: Defined at the policy/application layer, not enforced within the domain.

import 'package:trackable_asset_v1/trackable_asset_v1.dart';
import 'package:contracts_v1/contracts_v1.dart';
// 1. Create an asset from a catalogue listing
final createDirective = CreateAssetDirective(
payload: CreateAssetPayload(
assetId: TrackableAssetId.generate(),
name: AssetNickname.fromString('HVAC Unit 3F'),
description: AssetDescription.fromString('Main supply, 3rd floor'),
assetType: AssetType.fromCode('hvac_ahu'),
structuralAssignment: StructuralAssignment(
siteId: SiteId.fromString('site-nyc'),
buildingId: BuildingId.fromString('bldg-main'),
level: BuildingLevel.floor(3),
topologySequence: 15,
assignedAt: DateTime.now(),
),
catalogueRef: CatalogueReference(
listingId: ListingId.fromString('listing-hvac-standard'),
workspaceId: NomosWorkspaceId.fromString('workspace-123'),
timelineId: NomosTimelineId.fromString('timeline-default'),
),
topologySequence: 15,
createdByUserId: UserId.fromString('user-alice'),
),
);
// 2. Update asset status once operational
final statusDirective = UpdateAssetStatusDirective(
payload: UpdateAssetStatusPayload(
assetId: createDirective.payload.assetId,
previousStatus: AssetStatus.draft,
newStatus: AssetStatus.active,
updatedBy: UserId.fromString('user-bob'),
),
);
// 3. Add geographic position (e.g., from GPS)
final positionDirective = UpdateAssetPositionDirective(
payload: UpdateAssetPositionPayload(
assetId: createDirective.payload.assetId,
geoPosition: GeoPosition.fromLatLng(
latitude: 40.7128,
longitude: -74.0060,
accuracy: 3.5,
),
updatedByUserId: UserId.fromString('user-charlie'),
),
);
// 4. Add a maintenance comment
final commentDirective = AddAssetCommentDirective(
payload: AddAssetCommentPayload(
assetId: createDirective.payload.assetId,
comment: AssetComment(
commentId: CommentId.generate(),
content: DomainText.fromString('Replaced filter, tested airflow'),
authorUserId: UserId.fromString('user-dave'),
createdAt: DateTime.now(),
),
),
);
// 5. Query assets in the building
final assetsInBldg = watchAssetsInTopology(
app: nomosApp,
workspaceId: workspaceId,
timelineId: timelineId,
buildingId: 'bldg-main',
).listen((assets) {
print('Assets in main building: ${assets.length}');
});
// 6. Detect topology staleness
final staleAssets = watchAssetsWithStaleTopologyBinding(
app: nomosApp,
workspaceId: workspaceId,
timelineId: timelineId,
currentTopologySequence: 16, // topology changed since assignment
siteId: 'site-nyc',
);
  • Event Sourcing: The domain is built on event sourcing via nomos_core. State is reconstructed by applying events in sequence.
  • Snapshot Optimization: Snapshots are periodically persisted to avoid replaying long event chains.
  • Timeline Awareness: All operations are timeline-aware, supporting branched, sandboxed, or alternative timelines.
  • Web Compatibility: Safe JSON parsing handles Flutter Web’s JS undefined semantics (see _parseString(), _parseMap() in aggregate).
  • estate_structures_v1: Defines the site/building/room hierarchy that StructuralAssignment references
  • catalogues_v1: Provides listing definitions that assets inherit custom fields from
  • contracts_v1: Shared value object and ID types across the system