Skip to main content

DIRE — Device Identity and Reconciliation Engine

How ServiceRadar decides "which device is this?" — the identity model, merge policy, and lifecycle rules implemented under elixir/serviceradar_core/lib/serviceradar/inventory/identity/. The public entry point is ServiceRadar.Inventory.IdentityReconciler (a facade; every ingestion path resolves through it — ingestors are translators, never identity engines).

Identifier vocabulary​

TypeConfidenceNotes
agent_idstrongmTLS-validated agent identity; at most one connected agent per device (co-location is refused and alerted)
armis_device_idstrongExternal platform id
integration_idstrongVersioned + stable per external object; Proxmox admissibility and bridge formats are defined by IntegrationIdentity
netbox_device_idstrongExternal platform id
macstrong / mediumAtomic, validated 12-hex values only; locally-administered MACs (IEEE bit) are medium, never merge on their own, and never derive a device uid: an update whose only MACs are locally administered is resolved like a weak update
IPweakNever an identifier; resolves a device only when no strong identifier is present

Validation happens at every boundary (Go agent syncsources.NormalizeUpdate and Elixir Identity.Mac): multi-value fields are split, malformed values are rejected with telemetry, and rejected values never become rows.

Agent host evidence​

The agent sends host_macs in both unary enrollment and control-stream hello. It reports only the MAC of an active, non-loopback local interface owning its announced host_ip. If no eligible interface owns that IP, or eligible owners have different MACs, it sends no MAC evidence. Unrelated interface MACs and neighbor observations are excluded. The selection contract is covered by go/pkg/agent/host_identity_test.go.

The gateway forwards this evidence to core's existing identity reconciliation. Both the agent and agent-gateway must be upgraded for this path to supply evidence; older peers remain compatible but do not supply or forward the new field. Matching Proxmox guest NIC evidence can then correlate a guest with the monitored device, subject to the merge policy below. This does not guarantee every missing VM-to-hypervisor edge, merge by hostname, or fabricate links.

Integration identity admission​

DIRE rejects bare numeric integration_id candidates regardless of provider. An unscoped opaque candidate is also excluded when the update carries armis_device_id, netbox_device_id, or a validated hardware_serial: it is a legacy compatibility echo, not a second identity. Without typed evidence, opaque candidates retain legacy admission. The extractor Ids owns the scope recognition and admission rules.

The generic sync-service path scopes raw IDs before admission and retains a lookup-only raw-ID bridge. Armis and NetBox keep their driver-owned formats without core-side synthesis.

Armis integration identity​

The Armis driver emits armis:<scope>:device:<native-id> as integration_id when SyncServiceID supplies a nonempty normalized scope. Scope normalization lowercases the value and joins colon- or whitespace-separated segments with dashes. Without that scope, the driver retains the legacy bare native ID; it does not substitute the source key or partition.

DIRE admits the scoped Armis value through the generic integration lookup and registration path, alongside armis_device_id. The driver's legacy bare value follows the admission rules above. The typed identifier remains source-authoritative for northbound write-back and drift repair. Northbound selection accepts either the legacy bare metadata value or the scoped value matching the source and typed ID; drift repair preserves valid scoped identities.

Resolution order (single and batch)​

Before allocating a new device, ingestion applies the source-specific creation evidence contract in SourcePolicy.sufficient_to_create?/1.

  1. Strong-identifier match (priority order above; agent_id matches are trusted-checked against the device's bound agent)
  2. Pre-set sr: UUID — a hint, re-validated and canonical-followed
  3. Deterministic UID derived from the highest-priority identifiers (a locally-administered MAC alone is not one; a MAC-only UID is seeded from the first universal MAC)
  4. IP/alias fallback — only for weak updates
  5. Deterministic (IP-seeded) or random UID

A source-authoritative identifier (armis_device_id, netbox_device_id) decides identity. An integration_id is not source-authoritative; its role in hostname-adoption refusals is described below. An update carrying a source-authoritative identifier never resolves, through a shared MAC or any other identifier, onto a record that holds a different one of the same type in the same scope (the identifier partition; Armis partitions carry the sync source, and a NetBox device id carries its source in the value), whether that id is stored or was claimed earlier in the same batch: that record is not a match, the update resolves by its own identifier, and the shared identifier stays with its owner as evidence. Each override is recorded as a source_override identity decision naming both records, and as an open source_authoritative_override source-identity conflict on the incoming record, naming the overridden records and the identifiers they share, so it can be reviewed. A record holding no source-authoritative identifier of that type can still be a match: that is how an Armis id attaches to the discovered record of the same device, and how the Armis and NetBox records of one device converge through a shared MAC. An automatic merge of two records holding different values of one type in one scope is refused and recorded as a source_block decision.

An address follows the device observed at it. When a strong-identified write that observed the device at its address (Armis, the passive census, mapper/SNMP discovery, an agent's self-report; see SourcePolicy.observed_address_source?/1) lands on an address that a different live device still holds (DHCP moved the address), and that observation is newer than the holder's last identity-bearing observation, the incoming device takes it and the stale holder releases it: its IP is cleared in the same transaction. The holder stays live unless it is an anchorless sweep seed (below). The decision is recorded as an open active_ip_conflict source-identity conflict (proposed action preserve_source_identity_release_stale_ip).

The last identity-bearing observation is identity_observed_at. Only a report of the device's own identity writes it, and it only moves forward:

  • a source sync carrying a current source-authoritative identifier, at the time the source last saw the device;
  • an agent check-in;
  • a mapper poll that identified the device by its interface MACs.

A sweep, a census sighting and an address-only sighting never write it, so a sweep that keeps answering at a stale holder's address does not make the holder newer. A holder with none counts as older than any write. A census sighting carries no time of its own and compares its record's: a record only census sightings have seen has none, and never displaces a holder that has one.

A write carrying a current source-authoritative identifier (armis_device_id, netbox_device_id) also takes the address from a retired holder, whatever either record's times: one marked source_retired, or one whose source-authoritative identifiers have all retired. The source has stopped reporting it.

The holder keeps the address, and the incoming record drops it, in these cases (the conflict is still recorded, with proposed action preserve_source_identity_drop_conflicting_ip):

  • the write comes from a declarative inventory (AWX, NetBox, Proxmox, hypervisor enrichment, generic integrations), whose address is configuration that can lag the network rather than a sighting. Only one carrying a source-authoritative identifier, as NetBox does, takes the address, and only from a retired holder;
  • the observation is not newer than the holder's (older or equal, or the write has none and the holder has one). An Armis sync observes at the time Armis last saw the device, so the last-known address of a device Armis has not seen since does not displace a holder observed later. It does displace a holder whose identity was never observed, such as one only sweeps and census sightings have seen;
  • another record in the same batch, the holder's own or a second incoming one, also claims the address: neither observation is fresher;
  • the holder is bound to a different agent that is still live (an agent check-in only; live means not retired and seen within the last 30 minutes): two live agents behind one NAT address each keep their own device and the address does not flap between them. A holder bound to an agent that is gone, or with no agent, such as an Armis device, still releases a stale address to a newer check-in.

Two further cases adopt the holder's uid instead of moving the address: an anchorless provisional seed at the address, and a holder whose hostname agrees, under narrow conditions. Both apply only to an incoming record that is not yet a device: an existing device that moves onto a seeded address takes it under the rules above, and the seed releases it (below). A hostname is evidence, like the address, never identity, so hostname agreement adopts the holder only when the incoming record is not yet a device and neither side holds a source-authoritative identifier (armis_device_id, netbox_device_id), with no disagreeing hardware serial, no disjoint integration identifiers under the same provider prefix (conflicting_integration_identity), and no third device claiming either side's identity. It never merges two existing devices. For an AWX inventory host, host-name agreement only corroborates an exact ansible_host address match with the device holding that address; it is never identity on its own (see Ansible). When the hostnames agree but adoption is refused, the two stay separate devices, the address is decided as above, and the pair is recorded as a policy_block identity decision (reason hostname_agreement_not_identity), which opens a de-duplication task for an operator to merge, mark distinct or dismiss.

The integration conflict check compares incoming claims with the holder's map claims and live registered integration_id rows in the same partition. It groups IDs by the normalized prefix before the first colon and refuses adoption if any shared provider has disjoint nonempty sets, recording evidence["refusal"] == "conflicting_integration_identity". Different providers and IDs without a provider prefix do not trigger this refusal. Overlap through legacy_integration_ids preserves eligibility for adoption, subject to the other guards. A node moved between clusters of the same provider with a new ID and no legacy overlap is also refused automatic hostname adoption if it retains the hostname and IP in the same partition; operator review is the intentional trade-off for keeping distinct cluster nodes separate.

A sweep seed that releases its address is soft-deleted in the same transaction, with deleted_reason seed_released, when the address is all it is: it has no identifier row, current or archived, no discovery source but the sweep, and no address alias for another address. A seed carrying more stays live. No sighting revives a seed_released tombstone; only an operator restores one. A seed's uid comes from its address, so while the tombstone remains, a sweep of the address creates no new seed.

An agent check-in never adopts on hostname: AgentGatewaySync adopts a holder only when it claims no anchor identifier (agent id, Armis id, MAC, serial, ...) that the agent does not also claim. An agent's existing device is never replaced by the holder; it takes the address under the rule above or keeps its own, and either decision is recorded, with an evidence reason: holder_stale (released), holder_seen_no_earlier (the holder's identity was observed no earlier than the check-in) or held_by_live_agent (kept). The holder lookup for a new agent device is scoped to that device's partition; a missing or blank partition on the check-in is default, for the device and the lookup.

Merged-away device IDs are never resurrected: resolution follows the merge_audit canonical mapping to the survivor (Identity.Resolver / Identity.BatchResolver), including after the tombstone row has been purged, unless an unmerge reversed that merge. In batch ingestion, a source-authoritative update cannot follow a redirect onto a survivor holding a conflicting source identifier. It also cannot attach through a non-source identifier to a retained tombstone (source_retired or seed_released). If its final target is an unrevivable tombstone or a conflicting redirect, resolution uses its own deterministic UID or a reseeded UID, preserving the old record. The reseed skips a UID held by a record with a conflicting source identifier or claimed for one by an earlier update in the same batch. Sweep seeding follows the same mapping: before it creates a batch of seeds, SweepResultsIngestor reads the merge rows of the uids their addresses derive. A seed whose uid redirects to a survivor takes the first uid of a bounded chain (Ids.reseeded_device_id/1) that resolves to itself, so a merged-away seed that was purged is not re-created under its old uid when a sweep finds its address again. A seed whose uid no merge names keeps it. A host whose chain has no such uid within the bound is not seeded, and is logged.

A strong match in Identity.Resolver considers every record that owns one of the update's globally-unique MACs, not only the owner of the first MAC found. When those are two or more records, the split is a conflict for the merge policy below. A locally-administered MAC never adds a record to the conflict.

SNMP mapper polls​

A device the mapper polls is identified by the MACs its own physical interfaces report, never by the address it was polled at. DHCP hands that address to other devices, so the record holding it, or a confirmed alias of it, may describe a different device. The MACs resolve through the steps above, and they are registered as the device's interface claims only after that. The polled address is evidence only: it breaks a tie between records the MACs identify.

  • A new device is written at the polled address under the same active-address rules as any strong write.
  • An existing device moves to the polled address only when its recorded address is not one its interfaces still report, so a router polled at its WAN and LAN addresses keeps one address. The address follows the newer identity-bearing observation: a live holder whose identity was last observed before the poll, or never, releases it in the same transaction, and a holder that is not older keeps it, with an active_ip_conflict recorded. A holder the same poll identified is not older.
  • Only globally-unique MACs identify a device. A poll that reports no globally-unique interface MAC, whether it has none or only randomized ones, falls back to the address: the live holder, then a confirmed alias, then an address-seeded device.
  • A poll never revives a device an operator deleted, one a merge tombstoned, or one a DIRE remediation removed (deleted_reason starting with dire_remediation). A device another automatic process deleted (a system: actor, such as a reaper or an expiry) came back online and is restored through the audited :restore action, which bumps the identity revision and records the revival. When its old address has since been leased to another live device, the restore clears it and the device moves to the polled address; the other device keeps its address.

Hypervisor nodes​

Hypervisor enrichment resolves a node, in order, by its integration id, then by its own NIC MACs, then, for Proxmox, by the device fingerprinted at its address. The Proxmox API reports no node NIC MACs, so the last step is how a Proxmox node finds the record discovery already holds for it. That record must meet four conditions:

  • its primary IP is either the explicit node IP (falling back to the cluster node IP) or the selected management NIC IP reported by the authenticated API, in the result's partition;
  • the mapper fingerprinted a PVE web UI there (proxmox_candidate_evidence pve_web_fingerprint, which only the mapper writes);
  • its fingerprinted title and its MACs do not contradict the node;
  • it holds no hypervisor integration id but this node's.

This step never binds by node name alone, because two clusters can share node names. It never binds a node to a device that holds another node's integration id, so a node of a second cluster at an address the first cluster's node already holds does not bind there. The sync's hostname-agreement rule (above) is a separate path. When two nodes of one result report the same address, neither node resolves through it. Exactly one device must qualify across the remaining addresses; two qualifying devices leave the node unresolved by this step.

A node found this way that already has a record holding nothing but its own integration ids and no agent can merge that record into the fingerprinted device through IdentityReconciler.merge_devices. All MergeEngine guards apply; a refused merge leaves the records separate. A successful merge is audited in merge_audit with reason hypervisor_node_candidate_match. Such a record is left from before the node's address resolved; its address was dropped because a declarative source never displaces a live holder. A record holding anything else is left alone.

IP aliases​

An IP alias records that a device was seen at an address. Syncs, the mapper and discovery observations (census, fingerprint, DPI and process sightings) report it. An alias is evidence, never identity: it never merges two identified devices.

  • Alias rows are per device. Every device seen at an address has its own row of it, filed under the device's partition, and only that device's sightings confirm it (three by default, identity_alias_confirm_threshold).
  • When a sync, an agent check-in or a mapper poll resolves a device at an address, every other device holding a confirmed alias of the address is handled. An identified holder has its alias invalidated (mark_stale), recorded as an alias_invalidated decision. An address-only holder is merged into the device by a sync and otherwise left alone.
  • A lookup that resolves an address to one alias holder (the sweep, the sync's batch lookup, the resolver and the SNMP credential lookup) takes the most recently seen holder, then the one with the most sightings, then the lowest device id. The mapper ranks by alias state first and, within a state, in the same order. The sweep's fallback to a pending alias prefers the most sightings.
  • When a declarative source (hypervisor enrichment, NetBox, AWX, generic integrations) replaces a device's own primary address, the replaced address is kept as a detected alias of the device (metadata source replaced_primary_address, replaced_by the new address), in the same transaction as the write, so a sweep that reaches the old address still attributes it to the device. The source of the batch's last update for the device decides. It is not kept when that update is an observed source (the old address is the stale side of a move), when the device was redirected or withheld, when another live device in its partition now holds the address, or when the device already has an alias row for the address (an invalidated alias is not revived). Which address becomes primary is unchanged. The same retention, with the same partition and existing-row exclusions, applies when camera inventory updates an existing camera device to a new address and when source succession moves the successor's address onto the survivor. A startup repair archives an existing row of this kind when the address is not the device's current primary IP, a non-config interface of the same device named like a container, CNI, or local-hypervisor bridge (GuestAddress.host_internal_name?/1) still carries it, and no config interface or differently named interface of that device does.

Merge policy and stability​

  • Evidence gates (Identity.MergePolicy): never merge on agent_id-only or randomized-MAC-only match sets. A globally-unique MAC is hardware identity: records that share one, or that one device's interfaces report together, converge, and the merge is recorded in merge_audit. A record linked to a conflict only through a randomized MAC drops out of the merge, recorded as a randomized_mac_link policy block.
  • Stability guards (Identity.MergeEngine, every automatic merge):
    • devices bound to different agents never merge; a conflicting IP alias is invalidated (mark_stale) instead
    • per-pair cooldown (default 24h, either direction) breaks merge oscillation; blocked re-merges alert via telemetry
  • Merges are transactional, audited (merge_audit), and move every linked record (identifiers, service checks, alerts, agents, per-agent availability, alias states, interfaces, endpoint inventory). When both devices hold an alias of one address, the merged device's row stays with it, marked replaced, and a confirmation it carried confirms the survivor's row if that row is pending or stale. unmerge_device restores a tombstoned device in place from the audit trail (the original IP is reclaimed only if unheld). Every merge records the merged-away device's own identifiers in merge_audit.details.source_identifiers; an unmerge moves back exactly those the survivor still holds, never the survivor's own. Merge rows written before that field existed restore only the merged-away device's conflict matches, or nothing.
  • Identifier ownership never changes silently: upserts do not re-point device_id on conflict; moves happen via merges or the explicit :reassign_device action.
  • A merge survivor retains the earliest non-null first_seen_time of the two devices, so merging an older identity into a newer row does not make the host newly discovered in the "Recently added devices" report.

For deliberate cross-cluster Proxmox splits, use the serviceradar.dire_remediation command help for dry-run review, execution gates, and device/source allowlists.

Lifecycle​

  • Per-(device, type) cardinality caps with supersede-by-last_seen (Identity.CardinalityCaps; defaults mac: 64, others: 8; config :serviceradar, ServiceRadar.Inventory.Identity.CardinalityCaps). Retirements are logged with values and counted in telemetry. Source-authoritative identifiers (Armis and NetBox device ids) are never capped.
  • TTL garbage collection for unseen identifiers (default 90 days; DeviceIdentifierGcWorker). Source-authoritative identifiers are never collected by age.
  • Source id retirement (Identity.SourceRetirement, run by SourceRetirementWorker after an exact Armis collection activates; on by default, Settings -> Network Services -> Discovery -> Inventory Cleanup, /settings/networks/inventory-cleanup): an Armis device id absent from N consecutive exact collections under one collection query (default 3) and unreported for T (default 24 hours) moves from device_identifiers to device_identifier_archive (reason source_absent), together with the integration_id derived from it, and a source_id_retired identity decision names the device, the id and the collections that proved the absence. A collection that is not exact counts neither way, and a change of collection query restarts the count. The record keeps the retired id as history: SourceAuthorityGuard reads the archive, so the record is still no match for an update carrying another Armis id of that source, and it never merges automatically with a record that holds or held one. Archived ids move with a merge and back with an unmerge. A pass that would retire ids from more than source_retirement_max_fraction (default 0.5) of the source instance's live records is refused and logged at error level unless the one-pass override (source_retirement_guard_override, the "Allow the next retirement or grace pass to exceed that share" checkbox on that page) is set; the refusal log line names the checkbox, the key and the page. Telemetry: [:serviceradar, :inventory, :source_retirement, :run], :retired and :refused.
  • AWX source retirement (Identity.AwxSourceRetirement, run by AwxSourceRetirementWorker after a complete AWX inventory observation commits): an AWX host the controller's latest observation no longer reports loses its AWX source. Only a complete observation counts: a partial one (an unreadable inventory or page, an expired token) makes the pass retire nothing, and so does a controller whose sync never sent a versioned aggregate. A record linked to the controller (it holds an awx:v2:<controller>:host:<id> integration id, or its metadata.awx.controller_id names the controller) is retired when its host is not reported, or when its host is reported but another record holds the host's v2 id (a record from the pre-v2 awx:host:<name> identity). Its AWX integration ids move to device_identifier_archive (reason source_absent) with a source_id_retired decision per id. A record AWX alone reported is marked source_retired when it is retired-only (see below), and that mark also archives every other identifier it holds (an address or MAC AWX gave it). An absent or superseded record that is not marked loses only its AWX source (awx leaves discovery_sources, metadata.awx is dropped, and an AWX metadata.integration_id drops with metadata.legacy_integration_ids). An id retires only once AWX last reported it at least source_retirement_min_absence_hours ago: the later of the identifier's last_seen and, for an absent host, the membership last_seen_at. A record with no AWX id row uses that membership time when it has one (another source cannot refresh it), and its last_seen_time only when it does not. The pass honours source_retirement_enabled, source_retirement_max_fraction and the override, judged against the controller's linked records. A first pass over a controller whose records are mostly stale is therefore refused until the override admits it. Hypervisor enrichment's guest-candidate query excludes a source_retired record, whatever retired it, on address, MAC, an identifier it still holds and an AWX hint, and the name index skips one. A batch that already names the record by uid still returns it. The identifier-row lookup stays unfiltered; the DIRE lifecycle note (formal/dire/README.md) says why.
  • Retired-only records (Identity.SourceRetirement): a retirement that leaves a live record with no source-authoritative id and no agent id, no identity-bearing observation within T, and not created by an operator marks it source_retired in the same transaction (ocsf_devices.source_retired_at; the pass counts them as marked). A marked record stays live but hidden: Device :inventory, the SRQL device queries and the inventory counts leave it out unless a query asks for it (include_retired:true, include_deleted:true or a source_retired: filter). An agent or source-authoritative id registered on it clears the mark. The device detail page shows when the record was marked and when it will be deleted, or why it will not be. After source_retired_grace_days (default 7) the DeviceCleanupWorker grace pass (SourceRetiredExpiry) soft-deletes it with deleted_reason source_retired and clears its address; a record named by an open de-duplication task is held until the task closes. The tombstone is retained: no sweep, discovery poll or check-in restores it. The grace pass is bounded by the retirement pass's maximum fraction and admitted by its override. Telemetry: [:serviceradar, :inventory, :source_retired_expiry, :run] and :refused.
  • A retired id reported again (Identity.SourceReactivation), whatever its archive reason: the sync ingest finds the records that held it, each followed to its merge survivor, before resolution. The id returns to one of them when exactly one qualifies: the record is live or a tombstone that was not merged away, holds no other id of the type in the id's scope, shares a hardware MAC with the update (its own, its MAC identifiers, its interface MACs, or the MAC the source last reported for the id before it retired), and agrees with the update on the source first-seen time, or on the hostname when the update was first seen no earlier than the id was last seen. The return moves the newest archived row and its integration id back, restores a tombstone through the audited restore path (releasing an address another live record holds), clears the mark and records source_id_reactivated, in one transaction. Otherwise the id is re-issued: the update is written as a new record, never to a record that held the id, and a source_id_reissued decision naming it and the holders opens a de-duplication task. Nothing merges two live records. A failed read or return withholds the updates carrying the id until the next sync run. Telemetry: [:serviceradar, :inventory, :source_reactivation, :reactivated], :reissued and :withheld.
  • Ephemeral device expiry (EphemeralDeviceExpiry, run by DeviceCleanupWorker; off by default, Settings -> Network Services -> Discovery -> Inventory Cleanup): a live device holding no strong identifier -- no agent, source-authoritative id, hardware serial or globally-unique MAC -- and unseen past the window (default 30 days) is soft-deleted as stale_ephemeral. A source id held only in a device's metadata, as a string or a number, holds it in the delete statement as well as in the candidate read. Operator-created devices and devices matching the exclusion SRQL query never expire. A pass that would expire more than ephemeral_expiry_max_fraction of live devices is refused unless ephemeral_expiry_guard_override (the "Allow the next passes to exceed that share" checkbox) is set; the guard judges the devices the pass would actually expire, and the override stays set, lifting the guard for every later pass, until it is cleared. The pass counts candidates, kept_by_evidence, kept_by_exclusion, eligible, expired and skipped_at_delete (eligible devices the delete did not expire) alike in its result, its log line and the [:serviceradar, :inventory, :ephemeral_expiry, :run] event; a refused pass emits :refused with the counts up to eligible and live_devices, and a raised pass :failed, which never stops the purge. A returning device is restored with a revival audit row: a sweep that finds its address answering restores it whatever its discovery sources, and so does a sync that reports it again; a sweep that finds the address down leaves it deleted. A sweep never writes its sighting to a tombstone it does not restore.
  • Scheduled duplicate reconciliation (Identity.DuplicateSweep) is bounded (DB-side duplicate grouping, capped merges per run) and obeys the same merge policy as ingest; schedule health is monitored so a silently-dead cron alerts.
  • Agent↔device links self-heal periodically (AgentLinkRepairWorker).

Telemetry (alert on these)​

EventMeaning
[:serviceradar, :identity_reconciler, :identifier, :rejected]malformed identifier value dropped at the boundary
[:serviceradar, :identity_reconciler, :identifier, :truncated]per-update MAC sanity cap hit
[:serviceradar, :identity_reconciler, :identifier, :retired]per-device cardinality cap enforced
[:serviceradar, :identity_reconciler, :merge, :guard_blocked]distinct-agent veto or cooldown blocked a merge (oscillation signal)
[:serviceradar, :identity_reconciler, :merge, :blocked]evidence policy blocked a merge
[:serviceradar, :identity_reconciler, :alias, :invalidated]IP alias conflicted with agent identity
[:serviceradar, :identity_reconciler, :source_identity, :source_override]a source-authoritative identifier overrode identifier matches on records holding a different one (also persisted as a source_authoritative_override conflict)
[:serviceradar, :identity_reconciler, :agent_colocation, :refused]second agent refused onto an agent-bound device
[:serviceradar, :identity_reconciler, :hostname_agreement, :refused]a sync declined to adopt an address holder whose hostname agrees (also recorded as a policy_block decision)
[:serviceradar, :inventory, :source_retirement, :refused]the mass guard refused a source id retirement pass
[:serviceradar, :inventory, :source_retirement, :retired]source ids retired from one record
[:serviceradar, :inventory, :source_retired_expiry, :refused]the mass guard refused a pass deleting source_retired records
[:serviceradar, :inventory, :source_reactivation, :reissued]a retired source id reported again was written as a new record; its de-duplication task needs review
[:serviceradar, :inventory, :source_reactivation, :withheld]updates carrying a retired source id were withheld until the next sync run
[:serviceradar, :inventory, :seed_released]an identified device took the address of sweep seeds, which were soft-deleted as seed_released
[:serviceradar, :identity_reconciler, :decision, :record_failed]an identity decision could not be written to platform.identity_decisions
[:serviceradar, :identity_reconciler, :deduplication_task, :open_failed]a de-duplication task could not be opened or updated for a recorded decision

Identity decisions​

Every decision that blocks, declines or overrides a merge is also written to platform.identity_decisions (ServiceRadar.Inventory.IdentityDecision), so it can be reviewed later instead of living only in telemetry. One row per distinct decision: the kind (policy_block, guard_block, source_block, alias_invalidated, ip_conflict, source_override, component_block), the reason, the sorted device set, the address it concerns, the latest evidence, and how often and when it was made. A repeat updates the row rather than adding one. Administrative merges are not decisions and are not recorded. Retiring a source id is recorded the same way, as a source_id_retired decision whose subject is the id. A retired id its source reports again is recorded as source_id_reactivated when it returned to its record, or as source_id_reissued when it was written as a new record, which opens a de-duplication task.

Read them with SRQL in:identity_decisions (for one device, device:<uid>), or through the trace_device_identity MCP tool. Both are read-only.

De-duplication tasks​

Every identity decision that names two or more devices also opens or updates the de-duplication task for that device set (platform.identity_deduplication_tasks, ServiceRadar.Inventory.DeduplicationTask); the scheduled duplicate sweep records each ambiguous component it declines the same way (component_block). There is exactly one task per device set for its whole life; later decisions update its count, last reason and evidence. SRQL in:deduplication_tasks lists them (status:open for open tasks); the trace_device_identity MCP tool reports a device's open tasks. The web UI review queue is at /devices/deduplication (the "De-duplication" button on the Devices page): it shows each task's devices, including ones merged away since, and the identity decisions about exactly that device set. Anyone with devices.view can read it; only operators can resolve tasks from it.

An operator resolves an open task through ServiceRadar.Inventory.Identity.Deduplication:

  • merge/4 merges every other device into a chosen survivor through the administrative merge path (reason manual_dedup_task). If a merge fails the task stays open; a retry treats devices already merged into the survivor as done.
  • mark_distinct/3 records a DistinctDeviceAssertion for every pair (platform.identity_distinct_assertions). MergeEngine then refuses every automatic merge of those pairs (guard asserted_distinct), the scheduled backfill included, and later decisions about the set open no task.
  • dismiss/3 closes it without a decision; reopen/2 reopens a dismissed task (a merged or distinct task stays resolved).

Every resolution, dismissal and reopen publishes a refresh pulse so a review queue open in another session updates; see ServiceRadar.Inventory.DeduplicationTaskNotifier.

Release gate​

test/serviceradar/inventory/identifier_cardinality_gate_test.exs (tag large_ingestion): repeated churned ingest rounds must not grow device_identifiers beyond true per-device sets — the regression gate for the 12.3M-row identifier explosion.

History and rationale: openspec/changes/refactor-device-identity-reconciliation/ (proposal, design, and the live-system investigation that drove it).