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
| Type | Confidence | Notes |
|---|---|---|
agent_id | strong | mTLS-validated agent identity; at most one connected agent per device (co-location is refused and alerted) |
armis_device_id | strong | External platform id |
integration_id | strong | Versioned + stable per external object; Proxmox admissibility and bridge formats are defined by IntegrationIdentity |
netbox_device_id | strong | External platform id |
mac | strong / medium | Atomic, 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 |
| IP | weak | Never 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.
- Strong-identifier match (priority order above;
agent_idmatches are trusted-checked against the device's bound agent) - Pre-set
sr:UUID — a hint, re-validated and canonical-followed - 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)
- IP/alias fallback — only for weak updates
- 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_conflictrecorded. 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_reasonstarting withdire_remediation). A device another automatic process deleted (asystem:actor, such as a reaper or an expiry) came back online and is restored through the audited:restoreaction, 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_evidencepve_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 analias_invalidateddecision. 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
detectedalias of the device (metadatasourcereplaced_primary_address,replaced_bythe 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 inmerge_audit. A record linked to a conflict only through a randomized MAC drops out of the merge, recorded as arandomized_mac_linkpolicy 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
- devices bound to different agents never merge; a conflicting IP
alias is invalidated (
- 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, markedreplaced, and a confirmation it carried confirms the survivor's row if that row is pending or stale.unmerge_devicerestores 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 inmerge_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_idon conflict; moves happen via merges or the explicit:reassign_deviceaction. - A merge survivor retains the earliest non-null
first_seen_timeof 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 bySourceRetirementWorkerafter 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 fromdevice_identifierstodevice_identifier_archive(reasonsource_absent), together with theintegration_idderived from it, and asource_id_retiredidentity 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:SourceAuthorityGuardreads 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 thansource_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],:retiredand:refused. - AWX source retirement (
Identity.AwxSourceRetirement, run byAwxSourceRetirementWorkerafter 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 anawx:v2:<controller>:host:<id>integration id, or itsmetadata.awx.controller_idnames 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-v2awx:host:<name>identity). Its AWX integration ids move todevice_identifier_archive(reasonsource_absent) with asource_id_retireddecision per id. A record AWX alone reported is markedsource_retiredwhen 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 (awxleavesdiscovery_sources,metadata.awxis dropped, and an AWXmetadata.integration_iddrops withmetadata.legacy_integration_ids). An id retires only once AWX last reported it at leastsource_retirement_min_absence_hoursago: the later of the identifier'slast_seenand, for an absent host, the membershiplast_seen_at. A record with no AWX id row uses that membership time when it has one (another source cannot refresh it), and itslast_seen_timeonly when it does not. The pass honourssource_retirement_enabled,source_retirement_max_fractionand 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 asource_retiredrecord, 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 itsource_retiredin the same transaction (ocsf_devices.source_retired_at; the pass counts them asmarked). 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:trueor asource_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. Aftersource_retired_grace_days(default 7) theDeviceCleanupWorkergrace pass (SourceRetiredExpiry) soft-deletes it withdeleted_reasonsource_retiredand 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 recordssource_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 asource_id_reissueddecision 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],:reissuedand:withheld. - Ephemeral device expiry (
EphemeralDeviceExpiry, run byDeviceCleanupWorker; 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 asstale_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 thanephemeral_expiry_max_fractionof live devices is refused unlessephemeral_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 countscandidates,kept_by_evidence,kept_by_exclusion,eligible,expiredandskipped_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:refusedwith the counts up toeligibleandlive_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)
| Event | Meaning |
|---|---|
[: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/4merges every other device into a chosen survivor through the administrative merge path (reasonmanual_dedup_task). If a merge fails the task stays open; a retry treats devices already merged into the survivor as done.mark_distinct/3records aDistinctDeviceAssertionfor every pair (platform.identity_distinct_assertions).MergeEnginethen refuses every automatic merge of those pairs (guardasserted_distinct), the scheduled backfill included, and later decisions about the set open no task.dismiss/3closes it without a decision;reopen/2reopens 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).