Entity References
An entity reference is a value that enables a part of the system to access an entity, inspect its public data, or request actions on it. Entity references are themselves values — they can be stored, transmitted, and processed by different parts of the system.
Pinned vs Floating References
Section titled “Pinned vs Floating References”Entities in the Arda system are journaled in a bitemporal sense. A reference to an entity can identify either:
-
Floating Reference: Identifies the complete lineage of versions of an entity. Accessing a floating reference requires specifying bitemporal coordinates to identify the specific version to access. When floating references are used with “now” time coordinates, the returned values may change depending on when the entity is accessed, or the entity may even have been deleted.
-
Pinned Reference: Identifies a specific bitemporal version (record) of an entity. Pinned references can rely on the fact that the version they point to will not change over time. In normal operation, no record is ever deleted, though OAM operations (archiving, purging) may remove records.
The choice between pinned and floating references is a domain-level concern. Business processes that must produce immutable records (e.g., a closed purchase order) should use pinned references to the reference data they captured at the time of the transaction.
Reference Structure
Section titled “Reference Structure”Regardless of the data type used to encode an entity reference, its information contents can be expressed using a standardized URI structure. The components are:
Schema
Section titled “Schema”The protocol used to access the entity:
| Schema | Protocol |
|---|---|
https | REST-based access using HTTPS (typically HTTP/1.1) |
grpc | gRPC-based access using HTTP/2 |
eventbus | Arda event bus based access (TBD) |
local | Access within the same component |
contextual | Interpreted by the user of the reference |
Authority
Section titled “Authority”The endpoint where the entity can be accessed. UserInfo must not be used in the authority section. The authority is mandatory for all schemas except contextual.
For https: a globally resolvable hostname or IP address; no port number (port 443 is implicit).
For grpc: a hostname resolvable within the Kubernetes cluster. By convention Arda components run in namespaces named <partition>-<component> (e.g., prod-operations), but the authority for a gRPC URI uses only the component name (e.g., operations). Components are not allowed to access entities outside their partition.
The unique identifier of the entity within the authority’s domain. For https:
module: The name of the module (service) managing the entityresource/entity-type: The type of entity among those managed by the module. Bothmoduleandresourcesegments must be present even when they have the same value.- Then one of:
{local-eid}— Floating reference: the UUID identifying the complete bitemporal lineage{local-eid}/rid/{local-rid}— Pinned reference: the UUID of a specific bitemporal record within the lineage
Query and Header Parameters
Section titled “Query and Header Parameters”Reference Parameters
Section titled “Reference Parameters”An entity reference is completely defined by its schema, authority, and path. One additional parameter is useful for floating references when an entity has been deleted:
includedeleted=true: Return the last version of the entity at the time of deletion rather than a “not found” response. The default for all references is to return “not found” if the entity does not exist at the specified bitemporal coordinates.
Standard Access Parameters
Section titled “Standard Access Parameters”These parameters are not part of the entity reference itself but are standard parameters used in requests that address an entity:
| Parameter | Description |
|---|---|
tenantid | UUID of the tenant that the entity belongs to. Mandatory for regular user requests. Encoded as an HTTP header for the https schema. |
effectiveasof | Unix epoch milliseconds specifying the effective time dimension. If omitted, the server uses its local clock as “now”. Encoded as a query parameter for https. |
recordedasof | Unix epoch milliseconds specifying the recorded time dimension. If omitted, the server uses its local clock as “now”. Encoded as a query parameter for https. |
Note on current system (as of 2025-12): The system is not yet consistent in using
operationtimevseffectiveasoffor mutation operations. Use ofrequestIdfor idempotency is also limited at this time.
Excluded from this document: gRPC, eventbus, local, and contextual schema path details are described in the source document but are implementation-level concerns. Protocol-specific header encoding details belong in the API reference section of the Current System documentation.
Implementation in Kotlin
Section titled “Implementation in Kotlin”Within Arda backend modules (Kotlin / Ktor / Exposed), an entity reference is realised as a sealed interface with at least one nested Value data class, both implementing EntityPayload:
@Serializablesealed interface ItemReference : EntityPayload { @Serializable(with = UUIDSerializer::class) override val eId: EntityId @Serializable(with = UUIDSerializer::class) val rId: UUID? // present when pinning to a specific version; null for floating val name: String? // cached display field (so consumers can render without re-fetch) val retired: Boolean // denormalized: is the referenced entity logically deleted? val provenance: Provenance? // denormalized who/when of the last sync; null until first synced
@Serializable data class Value( @Serializable(with = UUIDSerializer::class) override val eId: EntityId, @Serializable(with = UUIDSerializer::class) override val rId: UUID? = null, override val name: String? = null, override val retired: Boolean = false, override val provenance: Provenance? = null, ) : ItemReference { override fun validate(ctx: ApplicationContext, mutation: Mutation) = Result.success(Unit) companion object { fun fromItem(item: Item, rId: UUID? = null): Value = Value(item.eId, rId, item.name) } }}The eId carries the floating identity (the entity, regardless of version). The optional rId pins the reference to a specific bitemporal record (the version of the entity at the moment the reference was captured). Cached display fields are stored alongside so consumers can render the reference without re-fetching the target entity.
The retired flag and provenance (Provenance — who/when of the last sync) are denormalized onto the reference by an observer that reacts to the target entity’s changes. They are a routing hint and a convenience for consumers that do not re-resolve; the resolved target record is the source of truth — readers that resolve the reference coalesce retired/provenance from the record and treat the cached copy as a fallback. Two shipped instances follow this shape: the kanban card’s ItemReference (reacts to Item deletion — PDEV-808) and an item supply’s SupplierReference (reacts to VENDOR-role rename/removal). See Kanban Cards Module and Item Module.
The seal lets the type evolve (e.g., a future ItemReference.External(...) variant for entities owned by a remote system) without breaking existing call sites.
Descriptor variant: required name, nullable identity
Section titled “Descriptor variant: required name, nullable identity”The shape above is a strict reference: identity (eId, and rId when pinned) is mandatory, and the cached display fields are a convenience derived from the target. A descriptor reference inverts that emphasis:
- Its cached display field is required and authoritative — the reference is the single home for that human-meaningful name, not a denormalized copy of it.
- Its identity (
eId, and the parent eId when the target is a child entity) is nullable —nullmeans the descriptor is unlinked: it names something the system has not (yet) connected to a live entity, either because the entity does not exist yet or because legacy data was captured before the link existed.
Choose a descriptor over a strict reference when the human-meaningful name must survive even when the entity is unlinked or legacy data has no id yet. If the name is purely a render cache that can always be re-fetched from a guaranteed-present target, keep the strict shape instead.
The worked example is SupplierReference.Value (the link from an ItemSupply to the VENDOR BusinessRole that supplies it):
@Serializablesealed interface SupplierReference { val name: String // required — single source of truth for the vendor name val eId: UUID? // VENDOR BusinessRole id; null when unlinked (name-only) val affiliateEId: UUID? // parent BusinessAffiliate id; required when eId is non-null val rId: UUID? // pinned tombstone record id; null while floating val retired: Boolean // denormalized copy of the target role's retired state val provenance: Provenance.Value? // denormalized who/when of the target's last update
@Serializable data class Value( override val name: String, override val eId: UUID? = null, override val affiliateEId: UUID? = null, override val rId: UUID? = null, override val retired: Boolean = false, override val provenance: Provenance.Value? = null, ) : SupplierReference { companion object { // smart constructor — enforces the invariants, returns Result operator fun invoke(/* … */): Result<Value> = TODO() } }}Two details distinguish it from the strict shape:
affiliateEId(the parent eId) is present because the target is a child entity. AVENDORBusinessRoleis scoped to its parentBusinessAffiliate; resolving it across the Universe boundary requires the parent eId. A strict reference to a top-level entity would not carry this.- It is constructed through a smart constructor —
operator fun invoke(): Result<Value>on the companion — rather than the public data-class constructor, so the invariants are enforced at every construction site: the name must be non-blank, and ifeIdis present thenaffiliateEIdmust also be present (you cannot have a linked role without knowing its parent).
The nullable identity is a transitional state, not the end state. It exists to carry legacy or not-yet-linked rows through a migration window (or a lazy re-link, where each row re-links the next time it is edited). A follow-up tightens identity back to non-null once the legacy rows are re-linked, at which point the descriptor regains the canonical strict shape (for the worked example, tracked as PDEV-860).
Descriptor resolution modes (create-on-the-fly)
Section titled “Descriptor resolution modes (create-on-the-fly)”Because a descriptor can arrive with a name but no id, the owning service must decide what to do when it cannot resolve the named target. Three modes cover the cases:
| Mode | Behavior when the descriptor has no resolvable id |
|---|---|
STRICT | The named target must already resolve to a live entity; otherwise the write is rejected. |
PROPAGATE | Find-or-create: the owning service locates the target entity (and its parent, for a child entity) or creates it, then links the descriptor — rather than rejecting. |
LAX | Leave the descriptor unlinked (name-only); no lookup, no creation. |
Two rules constrain resolution regardless of mode:
- Never resurrect a removed target. Resolving against a retired/removed target is rejected —
PROPAGATEmust not re-create or revive a tombstoned entity, andSTRICT/LAXsimply do not see it (it is filtered from active bitemporal queries). A descriptor may not start out linked to a dead target. - On target removal, mark stale rather than delete. When the target is removed after the descriptor was linked, the descriptor is marked stale —
retired = trueandrIdpinned to the target’s tombstone record — rather than deleted. The descriptor’s data is preserved so consumers still render the last-known state, and pinned resolution reads from the tombstone. See Pinned vs Floating References above and, for the design-process guidance on choosing and wiring these modes, Information Model Design § Descriptor references and create-on-the-fly resolution.
When persisted, the reference value object is flattened into the holder’s table with prefix-based column naming (e.g., "ITEM_REFERENCE_entity_id", "ITEM_REFERENCE_item_name", "ITEM_REFERENCE_record_id"). This keeps the foreign-side data accessible without a join while explicitly preserving “no SQL FK across services” for cross-service references — see Data Authority Module Pattern § Cross-Service Isolation.
For the broader design playbook (when to introduce a reference family, how to choose the cached display fields, how to handle resend chains and parent-child relationships), see Information Model Design.
Copyright: © Arda Systems 2025-2026, All rights reserved