At 8:56 AM, the migration console was telling a partial truth about 205 client-managed domains.

We had just shipped clearer migration warnings and sent the second-notice email to all 205 domains that still had not migrated. A nightly scan sat behind the console, so the obvious question was whether that list covered the full population. It did not. The scan had been dying at its one-hour limit, and roughly one-third of the domains had never actually been checked.

Nothing had raised an alarm, and the console showed no obvious failure. We had rows for the domains the scan reached, and those rows looked like evidence of coverage. They only showed that some work had happened before the clock ran out.

That changed the audit completely.

The one-hour boundary was part of the data

A migration verifier earns its keep by making a reliable statement about a population. Once the job stops at sixty minutes without recording an incomplete run, it can no longer make that statement.

The failure went unnoticed because the timeout lived below the console’s idea of success. The scheduler stopped the process at its time limit. The console showed whatever results had been persisted. Neither component represented the missing part of the run as an outcome of its own.

That makes for a bad interface on an operational task. A blank result can mean three things:

  • a domain was checked and needs no action,
  • a domain was checked and needs migration,
  • a domain was not checked because the job never reached it.

The console could tell the first two apart. It could not tell the third from either of them, and for a migration campaign that is the central question.

Auditing the list instead of the stored rows

The audit that mattered asked whether every one of the 205 domains could be accounted for by a completed run. Asking how many scan results we had told us very little.

Those are different queries even when the storage is the same. Counting result rows tells you how much work produced data. Counting the expected audience against a terminal run state tells you whether the verifier covered its job.

The verifier needed an explicit contract closer to this:

expected_domain_count = 205
processed_domain_count = count(results for this run)
run_state = complete | incomplete | failed
coverage is valid only when:
run_state == complete
and processed_domain_count == expected_domain_count

The exact field names matter less than the mechanism they stand for. A verifier has to keep the execution outcome next to its per-domain findings. Without that, a time limit turns unprocessed work into invisible work.

That is why the audit showed false coverage. The console was not lying about the domains it displayed; we were asking it a question its data model could not answer.

What a longer timeout would and would not change

The tempting response is to extend the one-hour limit. That might make the failure less frequent, but the semantics stay the same. A larger timeout still leaves a boundary where the scan can stop before the population is covered. It only moves that boundary further out.

Treating the timeout as an ordinary infrastructure failure falls short too. This job’s partial output stood behind a customer communication that had already gone out, and an incomplete output cannot support an all-clear claim.

The stronger design makes incompleteness durable and visible. Keep the expected set for the run and track the processed set. Record a terminal outcome that cannot be mistaken for success. Then resume or rerun only from a state that preserves the gap, so a fresh-looking partial result never overwrites it.

The second-notice email had already gone to every domain still unmigrated. For roughly one-third of that audience, the verifier behind the console had never checked the domain.

The scan did more than time out. It stopped being a scan.

If a scheduled job feeds a customer communication, it should record a terminal state of complete, incomplete or failed, and compare the processed items against the expected population. Here a one-hour limit left roughly one-third of 205 domains unchecked while the console still showed results. A longer timeout only moves the boundary, so the run’s outcome has to be kept alongside its findings.