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.

PowerShell
m365-governance migration-read --drive <id> --read-id <name> `
  --taken-at 2026-07-04T06:10:22Z --estate "contoso-projects" `
  --out baseline.json

What it needs, and what it never does

The command spends a token; it never acquires one.

Text
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

FlagWhat it addsWhat it costs
--with-versionshow many versions each item carriesone extra request per item
--with-permissionsgrants and sharing links per itemone 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:

JSON
{
  "$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:

JSON
{ "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:

ItemsTimePeak memory
12,000under a secondaround 5 MB
250,000about four secondsaround 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.