30-second answer
migration-read records what a drive holds, at a moment you name, in a
document that can be compared against another one later. It is the half of
migration verification that has to happen before the move: a migration
cannot be verified after the fact, because decommissioning the source is the
point of the exercise and a record produced at sign-off has nothing left to
compare against.
m365-governance migration-read --drive <id> --read-id <name> `
--taken-at 2026-07-04T06:10:22Z --estate "contoso-projects" `
--out baseline.jsonWhat it needs, and what it never does
The command spends a token; it never acquires one.
M365_GOVERNANCE_GRAPH_TOKEN is not set. This command spends a token somebody
already holds; it never acquires one.That is deliberate. A collector that logs itself in decides which identity read the estate, and the identity that read it is exactly what a reader needs to weigh a missing item: what one account cannot see is absent rather than refused. The read records the identity it was given, and never the token.
--taken-at is supplied rather than read from the clock. The moment a read
was taken decides which side of a move it is, so it belongs to whoever ran
the collection, not to whichever machine happened to serialise it.
The two flags that cost
| Flag | What it adds | What it costs |
|---|---|---|
--with-versions | how many versions each item carries | one extra request per item |
--with-permissions | grants and sharing links per item | one extra request per item; Graph cannot expand permissions on a collection |
Without them, versions and permissions are out of scope for this read rather than missing from it, and the record says which of the two it was. That distinction is the whole product: a dimension nobody asked for is not a dimension that failed.
What it writes
A migration-read/1.0.0 document. Anonymised excerpt of a real run:
{
"$schema": "https://ph7x.com/schemas/m365-governance/migration-read/1.0.0",
"read_id": "baseline-001",
"taken_at": "2026-07-04T06:10:22Z",
"estate": "contoso-projects",
"produced_by": "m365-governance 1.0.0b6",
"content_digest_algorithm": "quickXorHash",
"items": {
"/plan.xlsx": { "size": 4096, "author": "a.pereira@example.test",
"content_digest": "AAAA" },
"/index.js": { "size": 512, "author": "a.pereira@example.test",
"content_digest": "BBBB" }
},
"coverage": [],
"read_by": { "kind": "application", "scopes": ["Files.Read.All"] }
}coverage is the most important field in the document and the one most
reports do not have. It carries what this read could not establish, and
why: a folder the account was refused, a walk that stopped at a depth, an item
listed by the enumeration and gone when it was read. A verification that
cannot say what it failed to read is indistinguishable from one that read
everything, and the two are the opposite of each other.
What it will not walk
An item shared from another drive is listed and never entered. Walking into it would put another estate's items under this estate's identities, and every one of them would then read as present or missing here on the strength of a read that never covered that drive. The record says so, per item:
{ "scope": "/Shared/other-drive-folder", "state": "not-supported",
"detail": "this item is shared from another drive; its contents are not
part of this estate and were not walked" }What it costs at scale
Measured, not estimated. The walk holds the whole estate in memory, which is what makes it possible to canonicalise and digest the result in one pass:
| Items | Time | Peak memory |
|---|---|---|
| 12,000 | under a second | around 5 MB |
| 250,000 | about four seconds | around 100 MB |
Roughly 410 bytes an item, linear. A verification of two reads that size peaks around 142 MB. These are figures from a synthetic corpus on one machine, not a service level.
Exit codes
0 a read was written. 1 nothing could be enumerated under the estate,
which is refused rather than returned as an empty estate. 2 the token was
not set.