Skip to content

Asset Reconciliation Developer Guide

Ed Mozley edited this page Oct 4, 2026 · 1 revision

Asset reconciliation β€” Developer Guide

Since 3.1.0 Β· Built by Sandy (@srinivasansanthosh) in PR #164, merged with the changes in the review Β· User side: Assets β†’ Help β†’ Discovery & reconciliation

How FreeITSM decides whether a device reported by the inventory agent or Intune is an asset it already has. Before 3.1.0 every source matched on hostname alone, so a renamed computer arrived as a second asset with none of its history. Now a device is matched by what it is (its serial number) before what it is called.


1. πŸ“ The files

File Role
includes/services/assets.php getIgnoredServiceTags(), isUsableServiceTag(), resolveAssetIdentity() (reads only), updateAssetHostname(), reconcileAsset() (resolve + apply a rename), createDiscoveredAsset()
api/external/system-info/submit/index.php The inventory agent: reconcileAsset() then update, or createDiscoveredAsset()
api/external/software-inventory/submit/index.php The software agent: the same two calls (it only sends a hostname, so it always lands on tier 3)
includes/intune.php intuneLinkDevicesToAssets() β€” renames for linked devices, then match-or-create for unlinked ones
includes/service_context.php ActorContext::system('Inventory agent' / 'Intune') β€” the actor for changes no person made
asset-management/settings/ Discovery & reconciliation tab (the placeholder list, Cap::ASSETS_RECONCILIATION); Intune tab (company, renaming)
tests/asset-reconciliation.php 24 checks, one rolled-back transaction

πŸ”‘ Every path that creates an asset now goes through AssetsService β€” createAsset() for people, the REST API and imports; createDiscoveredAsset() for the agents and Intune. INSERT INTO assets appears in that one file and nowhere else. Keep it that way: it is what makes the tag, the transaction and the history row the same whichever source made the asset.

2. πŸͺœ The tiers β€” resolveAssetIdentity()

Tier 1  an authoritative connector link the caller already holds  -> that asset
Tier 2  a usable serial                                            -> the one asset with it
        ...but two or more assets share it                         -> no guess (ambiguous), unless serial AND name pick one
Tier 3  hostname                                                   -> that asset
        ...unless a usable serial CONTRADICTS it                   -> new asset, flagged hostname_reused
Tier 4  nothing                                                    -> new asset

It returns ['asset_id', 'matched_by', 'ambiguous', 'hostname_reused'] and writes nothing. reconcileAsset() wraps it and, for a match made by link or serial, applies a reported rename through updateAssetHostname().

πŸ”΄ The laptop refresh (tier 3)

A replacement laptop is very often given the old one's name. It reports a good serial nobody has seen, so tier 2 finds nothing β€” and a plain hostname match would then write the new serial onto the old laptop's record. One record would describe two machines: the old one's purchase date and warranty now belong to the new one, and the old one, sitting in a cupboard for reuse, has no record at all.

So a hostname match only counts if it cannot contradict the serial:

$contradicts = $serialUsable && self::isUsableServiceTag($theirs, $ignoredTags)
            && strcasecmp($theirs, $serial) !== 0;

Both serials usable and different β†’ a different machine β†’ tier 4, with hostname_reused = the old asset's id. createDiscoveredAsset() records that in the new asset's history (hostname_reused, value #<id>) so a person sees it and renames or retires the old one.

The case this must not break: an asset entered without a serial (by hand, or before the agent ran) still matches by name. That is how the agent finds a hand-entered asset the first time, and fills its serial in.

The ambiguity guard (tier 2)

Two assets with the same serial (old duplicates, cloned VMs) are a question for a person. The resolver does not pick one. It accepts a match only if serial and hostname together identify exactly one β€” Sandy's original fell back to the hostname alone, which could still pick the wrong twin.

3. πŸ™ˆ Placeholder serials

DEFAULT_IGNORED_SERVICE_TAGS β€” TO BE FILLED BY O.E.M., DEFAULT STRING, NONE, SYSTEM SERIAL NUMBER, NOT SPECIFIED, 123456789 β€” is what a motherboard reports when the maker never filled the field in. Matching on one would merge every such machine into one asset.

The list is editable (asset_reconciliation_ignored_serials, one per line, compared case-insensitively). A stored list replaces the defaults rather than adding to them, so an administrator can remove one that turns out to be a real serial on their estate.

4. ✏️ Renames β€” updateAssetHostname()

  • Hostname is unique per company, so a rename onto a name another asset in the asset's own company already has is skipped and reported as hostname_conflict. The asset's own company β€” not the caller's β€” because a person may have moved the asset since the source last saw it.
  • Truncated to 50 characters (assets.hostname is VARCHAR(50); Intune names can be 256).
  • Recorded in history through auditWrite() with no analyst (ActorContext::system() has actorId 0, written as NULL) and the source in the new value: LON-LT-042 (inventory agent).
  • πŸ”‘ The history write is best-effort. Before Database Verification has relaxed asset_history.analyst_id (it was NOT NULL), the write fails β€” and an agent's whole report must not fail over its own audit row. It is logged with error_log() instead.

5. πŸ†• Creating β€” createDiscoveredAsset()

Takes a whitelist of discovered columns, the company, the source and an optional hostname_reused. It runs through AssetTagsService::createWithTag(), so a company with automatic tags switched on gets one here too, inside the same transaction as the insert.

πŸ”΄ Its history row is asset_discovered, not asset_created. Database Verification's last_seen repair (#1583, api/system/db_verify.php) treats an asset_created row as proof that a person or an import made the record, and blanks last_seen wherever it equals first_seen β€” which it does on every machine found by an agent. Writing asset_created here would have blanked last_seen on every newly discovered machine at the next verification. Don't "tidy" the two names together.

6. ☁️ Intune β€” intuneLinkDevicesToAssets()

Behaviour
An existing link (intune_devices.asset_id) Trusted, wherever the asset now is. The MSP workflow is: a stub lands in one company, an analyst moves it to the client it belongs to (Moving an asset between companies). A link is never second-guessed because its asset is in a different company β€” a person put it there.
A renamed device Found with one query (device_name β‰  the asset's hostname), not a reconcile per device per sync. The asset takes the new name only if intune_sync_hostnames is on (below).
An unlinked device reconcileAsset() β€” serial, then name β€” in the company set under Settings β†’ Intune. Unset (every install after upgrade) matches across all companies, exactly as the old hostname-only link did. No match β†’ createDiscoveredAsset() in that company (Default if unset).

intune_sync_hostnames β€” off after an upgrade

On the install this was developed against, 65 of 582 linked devices had been renamed or reassigned in Intune since they were linked (NWUNIFI01 in Intune, still BWBUNIFI01 on the asset). Renaming them is the right result β€” it's what the feature is for β€” but doing it silently on the first sync after an upgrade breaks the rule that an upgrade changes nothing until somebody chooses to.

So it's a switch on the Intune tab. freeitsm.sql seeds it '1' (a new install has nothing to rename) and Database Verification seeds '0'; INSERT IGNORE means a new install keeps its '1' and an upgraded one gets '0'.

7. 🏒 Companies

  • The agents match only within their key's company (tenant_id <=> ?, NULL-safe so a Default-company key matches NULL rows). Unchanged from before.
  • Intune β€” see the table above. resolveAssetIdentity(..., $anyCompany = true) drops the company condition; nothing else should pass it.
  • idx_assets_tenant_service_tag (tenant_id, service_tag) serves tier 2.

8. βœ… Tests

php tests/asset-reconciliation.php β€” 24 checks inside one transaction that is always rolled back, rows prefixed ZZREC-: placeholders; a renamed machine found by serial and renamed, with an analyst-less history row; the ambiguity guard with a positive control; the laptop refresh (new asset, flagged, old serial untouched) with a positive control; a rename collision; createDiscoveredAsset() writing asset_discovered; company isolation and "any company"; and Intune β€” a moved asset keeps its link, an unlinked device links by serial with no stub, and renaming follows the setting. It runs against the install's real Intune devices too, inside the transaction, and leaves them as they were.

9. Extending it

  • Another source (vCenter, Jamf, a network scan): build an ActorContext::system('<source>'), call reconcileAsset(), and createDiscoveredAsset() on no match. Don't write an INSERT.
  • A stronger identifier β€” the SMBIOS UUID is better than the serial for virtual machines (cloned VMs can share a serial). It would slot in between tier 1 and tier 2. Not MAC addresses: docks and Wi-Fi randomisation move them between machines.

See also: Asset tag numbering β€” Developer Guide Β· The review (PR #164) Β· Inventory agent Β· Multi-Tenancy β€” Developer Guide Β· Developer Tests β€” Assets

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally