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 what the token says about itself, never the token:
the kind of identity (application, delegated, or not-established when the
token carries neither claim), the permissions it was granted and the tenant. It
does not record which account it was, so two people holding the same
permissions produce reads that cannot be told apart.
--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.
Options
--drive is the drive to enumerate, and --folder an item id to start
at inside it.
--read-id is required: the id this read is recorded under.
migration-verify
names the baseline and the verification read by it.
--estate is what was read, as you name it. --out writes the read
there.
The two flags that cost
| Flag | What it adds | What it costs |
|---|---|---|
--with-versions | how many versions each file carries | one extra request per file |
--with-permissions | grants and sharing links per file | one extra request per file; Graph cannot expand permissions on a collection |
Without them the read carries no versions or permissions, and a verification built from it reports those dimensions as not compared, because a read did not carry them. That is a different reason from a source that cannot provide them at all, and the record keeps the two apart: a dimension nobody asked for is not a dimension that failed. The read itself does not record whether the flags were given.
What it writes
A migration-read/1.0.0 document. This one is the baseline the engine's own
end-to-end test produces at 1.0.0b9, from simulated Graph answers; no tenant
was read:
{
"$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": "graph-migration-read via Microsoft Graph v1.0",
"coverage": [],
"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" }
},
"read_by": { "kind": "not-established", "scopes": [], "tenant": "t" },
"content_digest_algorithm": "quickXorHash"
}read_by says not-established because the test's token carries neither
roles nor scp; a real token yields application or delegated, with the
permissions it was granted.
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
The walk holds the whole estate in memory, which is what makes it possible to canonicalise and digest the result in one pass. Measured on 1 November 2026 at 1.0.0b8, on one Apple silicon machine with Python 3.14, over a synthetic estate of files in folders of 200:
| Items | Time | Peak memory of the read |
|---|---|---|
| 12,000 | 0.02 seconds | 5 MB |
| 250,000 | 0.6 seconds | 103 MB |
Roughly 410 bytes an item, linear. Comparing two reads of 250,000 items took
under two seconds and about 45 MB on top of the two reads. The times exclude
the network entirely: the Graph answers were simulated. A real read makes one
request per page of each folder's children, plus one per file for each of
--with-versions and --with-permissions, and no figure here measures those.
The engine's own test holds the read under 2,048 bytes an item; it does not
time it.
Exit codes
0 the read was taken; it is written to a file only with --out. A library
that answers with nothing in it, and refuses nothing, is an empty read and also
0. 1 Graph refused the request, or nothing could be enumerated and part of
the estate was refused, which is refused rather than returned as an empty
estate. 2 the token was not set.