# `Relyra.Metadata.DriftDetector`
[🔗](https://github.com/szTheory/relyra/blob/v1.1.0/lib/relyra/metadata/drift_detector.ex#L1)

Drift detection for Phase 21 scheduled metadata refresh per D-18.

A "drift" is either:
  - the freshly-fetched `entityID` does not match the persisted
    `idp_entity_id` on the connection (`:entity_id_drift`), or
  - the freshly-fetched signing-cert fingerprint set contains an
    element NOT in the persisted `last_known_metadata_signing_certs`
    MapSet (`:new_signing_cert`).

On drift, the wrapper auto-suspends the source and refuses to apply
this revision. Per D-32, the new cert still stages as `:next` via the
existing certificate-inventory path — drift detection ONLY pauses the
scheduled apply pending operator review (Phase 10/12 D-08 unchanged).

Why fingerprints, not PEMs: comparing PEM strings is whitespace-sensitive
(RESEARCH Pitfall 7). A metadata reformat that re-emits the same cert
with different line wrapping would re-fire `:new_signing_cert`. Compare
MapSets of SHA-256 fingerprints only.

Pure: no I/O, no Ecto. Caller passes already-fingerprint-extracted lists.

# `drift_result`

```elixir
@type drift_result() ::
  {:ok, :no_drift}
  | {:drift,
     %{
       entity_id_changed?: boolean(),
       new_signing_certs: [String.t()],
       reason: :entity_id_drift | :new_signing_cert
     }}
```

# `diff`

```elixir
@spec diff(map(), map()) :: drift_result()
```

Compares a freshly-parsed metadata `candidate` against the stored
connection + source state.

`candidate`:
  - `:idp_entity_id` (string)
  - `:certificate_fingerprints` (list of SHA-256 hex strings)

`source_state`:
  - `:idp_entity_id` (string — from the `Connection`, not the source row)
  - `:last_known_metadata_signing_certs` (list of SHA-256 hex strings — from `MetadataSource`)

Returns `{:ok, :no_drift}` or `{:drift, %{reason: ...}}`.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
