On August 3, 2026, two live endpoints in two different enrollments on our platform legitimately shared the agent id 001. Nothing was broken. A Wazuh manager had been rebuilt, the id came free, and the manager issued it again to the next agent that enrolled.
If you run a fleet, that is worth sitting with. The number your alerts arrive stamped with is not a name. It is a slot.
The id is a slot
Wazuh assigns each agent a small numeric id, and those ids are local to a single manager. When an agent is replaced, its id goes back in the pool.
The replacement itself is documented. The <force> option in the agent auth configuration, per the Wazuh configuration reference, "toggles whether or not to force the insertion of an agent if there is a duplicate name or IP address," and doing so "will remove the old agent with the same name or IP address." The conditions are tunable: how long the agent must have been disconnected, how long since it registered, whether its key mismatches.
What the documentation covers is the removal. What it does not cover, on that page or on the agent life cycle page, is what becomes of the departed agent's id. We checked both, in August 2026, and neither addresses id assignment or reuse.
In practice the id gets issued again. Public discussion of this exists and frames it as housekeeping: threads about agents changing ids and losing their assigned groups, guides on cleaning up duplicate agent ids. That was our framing too, and it is too small. A recycled id is not a tidiness problem. It is a data model problem, and in a fleet serving more than one customer it becomes a tenancy problem.
What a query does after that
Consider the most ordinary thing you can write: WHERE wazuh_agent_id = '001'.
In a fleet where that id has only ever meant one machine, it returns one endpoint and everything downstream of it is fine. The moment the id is reissued it returns two, in different enrollments, and every dashboard, every alert-to-endpoint join, every "show me this machine's history" built on that predicate is mixing two machines with nothing to do with each other. Nothing errors. The query is still valid. It is answering a different question than the one you asked.
The alert feed is the sharp end. Alerts arrive stamped with agent.id, sometimes buffered, sometimes late. An alert generated while 001 meant one machine can land after 001 means a different one. Key on the id alone and the older alert attaches to the newer machine, in the newer organization, and nothing in the result looks wrong.
Identity is a pair
The fix is not a lookup table or a cleanup job. It is admitting that the id was never the identity.
In our registry an endpoint is keyed by the pair (wazuhAgentId, enrollmentEpoch). The epoch is when that enrollment began, so the pair names which occupant of slot 001 you mean. It is the ON CONFLICT target of the inventory upsert and the join key attribution uses.
That has a blunt consequence for anyone querying a fleet: a filter on the agent id alone returns rows across organizations. Carry the epoch, or join through the registry and filter on the organization. There is no version of this where the bare id is safe.
There is a second half to identity here. Ids are manager-local, so an alert from a foreign manager that happens to reuse a number you also use has to miss. Attribution checks a derived manager-side name alongside the number, of the form warlock_<first 55 chars of machineId>_<sha256>. Within one system the number is not sufficient. Across two it is meaningless.
The alert places itself
Once identity is a pair, the question becomes which epoch owns a given alert. The answer is carried by the alert.
Attribution takes the alert's own event timestamp and selects the greatest enrollment epoch at or before it, which is the interval that contained the event. Not the newest epoch, which is the tempting default and the wrong one. A late or buffered alert from an earlier holder of a recycled id cannot be pulled forward into the current holder's tenancy, because its own timestamp places it in the earlier interval.
Rows come back newest epoch first, so the selection is one scan:
if (eventTimestamp !== null) {
// rows are newest-epoch first, so the first epoch at or before the event time
// is the greatest such epoch: the interval that contained the event.
return rows.find((row) => row.enrollmentEpoch.getTime() <= eventTimestamp.getTime());
}
One row is not proof
Here is the case that is easy to get wrong, and the reason this is a post rather than a one-line schema change.
When a lookup returns exactly one endpoint for an id, the natural reading is "no recycling here, take it." That is correct most of the time, and it has to be: agent and manager clocks drift, and a legitimate alert whose event time falls slightly before its own registration still needs to attribute.
But a single row is also what you see when a recycled id's earlier holder was never inventoried. The earlier occupant left nothing behind. The only row is the newer organization's. Take it directly and the earlier occupant's late alert lands in the newer organization, which is precisely the failure the pair key exists to prevent.
So the single-row path enforces the same containment, with a bounded tolerance for skew:
const SINGLE_ENROLLMENT_SKEW_MS = ms.minutes(5);
An alert older than a lone enrollment by more than five minutes does not attribute. It belongs to a holder nobody recorded.
Refuse rather than guess
When no epoch owns an alert, attribution does not choose one. It quarantines the alert with a reason of epoch_mismatch and stops.
That is a deliberate trade, and it is the decision most worth copying. A quarantined alert is a visible gap in one customer's feed. A guessed alert is an invisible error in two. The first is an operational problem and it is recoverable: after the next inventory sync, a drain re-runs attribution over parked rows, writes the ones that now resolve, and leaves the rest parked with an incremented attempt counter. The detection write dedupes on organization and alert id, so re-draining after a partial failure or an interrupted run does not double-count.
Revoked registry rows stay in the candidate set for the same reason. They can never receive an alert and they are never authorities. They exist so that a recycled id has something to match against instead of falling through to its newer owner.
The manager's own view of group membership overrides none of this. A mismatch between what the manager reports and what the registry holds raises an operator incident and quarantines the alert. It cannot re-parent an endpoint. Postgres is authoritative for tenancy, and the manager volume is a cache.
Where else this lives
None of this is really about Wazuh.
The shape is general: an upstream system hands you a compact handle from a bounded pool and reclaims it when the thing it pointed at goes away. Process ids do this. Container ids and names get reused. Session ids, DHCP leases, ticket numbers, and slot numbers in any fixed-size table do this. The handle is unique at any single instant and ambiguous over time, which is the exact property that makes it a bad primary key and a comfortable one.
Three questions are worth asking about any identifier you did not mint yourself.
Does the issuer ever reclaim it? If so, it is a slot, not a name.
Is it unique everywhere, or only within one issuer? If only within one, the issuer belongs in the key too.
When you resolve a historical record against it, are you resolving as of now or as of then? Anything carrying a timestamp needs to be placed at its own time, not at the present.
If the issuer reclaims handles and your records outlive them, then the identity is the handle plus the interval it was valid for. Adding that interval later is a migration and a backfill. Assuming the handle was enough is a bug that stays quiet until the day a manager gets rebuilt and two machines answer to 001.
