A collector returns a list, the list contains plausible objects, and the job exits successfully. Which part of that establishes that every relevant object was read?
None of it, by itself. A list is an observation. Whether it represents the intended population is a separate claim, with its own evidence. The engineering mistake is to make both claims share the same success flag.
This distinction matters before a governance rule runs. A perfect rule applied to an incomplete input cannot repair the acquisition. Worse, a report can display every stored finding correctly while implying that nothing was missed.
Three claims that need three answers
For a paginated query, separate these questions:
| Question | Evidence needed | What it does not establish |
|---|---|---|
| Did this request succeed? | Status and a usable response | That there are no more pages |
| Did this page chain end? | Each continuation followed, ending without another continuation | That the query represents the intended population |
| Was the intended population covered? | Scope, authority, enumeration method and relevant limitations | That every property of each returned object was collected |
The last row is deliberately not a count. Two runs can return the same number of objects while returning different objects. Two endpoints can enumerate different populations while using the same noun in their documentation.
Microsoft documents that Graph collection responses can include @odata.nextLink, which clients follow until no continuation remains. It also says to use the complete returned URL rather than extracting and reconstructing its token. The API determines which paging parameters it supports. Those are protocol obligations, not a tenant-wide completeness guarantee. Microsoft Graph paging
Reproduce the quiet failure without a tenant
This Python example uses deliberately synthetic pages and no network. The strings identifying pages are fixture keys, not API endpoints. It illustrates a consumer boundary; it is not a collector from the M365 Governance as Code engine or an implementation of Graph retry policy.
class InterruptedRead(Exception):
def __init__(self, collected, cursor, reason):
self.collected = tuple(collected)
self.cursor = cursor
super().__init__(reason)
def read_pages(pages, first):
collected, seen, cursor = [], set(), first
while cursor is not None:
if cursor in seen:
raise InterruptedRead(collected, cursor, "repeated continuation")
seen.add(cursor)
page = pages[cursor]
if page["status"] != 200:
raise InterruptedRead(collected, cursor, "request failed")
if not isinstance(page.get("value"), list):
raise InterruptedRead(collected, cursor, "missing collection")
collected.extend(page["value"])
cursor = page.get("@odata.nextLink")
return collected
pages = {
"first": {"status": 200, "value": ["A"], "@odata.nextLink": "second"},
"second": {"status": 429},
}
try:
read_pages(pages, "first")
except InterruptedRead as error:
print("interrupted:", list(error.collected), error.cursor)
pages["second"] = {"status": 200, "value": ["B"]}
print("page chain ended:", read_pages(pages, "first"))The observed local output is:
interrupted: ['A'] second
page chain ended: ['A', 'B']The first result preserves the already observed object and refuses to turn a failed continuation into an empty terminal page. The second establishes that this particular fixture chain ended. Neither result establishes a real tenant population.
A production implementation also needs an explicit transport-failure path, cancellation, bounded retries, token handling and validation appropriate to its endpoint. The example deliberately omits those responsibilities so its result cannot be mistaken for a production acquisition client.
Retry is recovery, not permission to forget
Graph documents HTTP 429 as throttling and recommends respecting Retry-After. When that header is absent, its guidance recommends exponential backoff. Repeated immediate requests make recovery worse. Microsoft Graph throttling guidance
Our recommendation is to retain the acquisition attempt's state independently of the final user-facing summary. If recovery eventually succeeds, that does not require presenting an error forever. It does require retaining enough provenance to explain which request was retried and which response supplied the next continuation.
A retry budget belongs to operational policy. Exhausting it should produce an interrupted acquisition, with the already obtained observations preserved according to the owning contract. It should never silently produce the same representation as an exhausted page chain.
Test the failure that would change the conclusion
A useful test does more than assert that two pages yield two objects. Add a failure after the first successful page. Add a response missing the collection property. Add a repeated continuation. Then check whether the consumer still labels the read complete.
These are different failure modes, so the corrective action need not be identical. A repeated continuation suggests a protocol or implementation problem; throttling may be recoverable; an invalid response needs investigation. What they share is that none supplies evidence that enumeration ended normally.
Keep the raw distinction even if the interface uses simpler wording. The reader may see “Read interrupted”, but the underlying request and continuation state should remain recoverable. Do not introduce a second interpretation in a dashboard that treats stored objects as an expected population.
What to carry into the report
Carry the collection boundary beside the observations: what was requested, what completed, what was unavailable and what population was actually established. Use the Engine's existing contract when consuming its artefacts. The table above is an engineering checklist, not a replacement schema.
The same restraint appears in what Microsoft 365 usage reports establish: a report's observation surface is not interchangeable with every question someone would like it to answer.
A successful page is useful evidence. A finished page chain is stronger evidence about that query. A tenant-wide conclusion still needs its own basis.
Support independent engineering knowledge
Knowledge, Analysis, the Field Guide and the tools are free to read and use, and they stay that way. If this article saved you time, you can support the research and writing behind it. Support is voluntary and unlocks nothing.
Buy me a coffee (opens in a new tab)João Lívio's page on buymeacoffee.com