Office files change their content hash when you PATCH metadata — property demotion
Your own metadata PATCH changes an Office file's cTag and even its content hash (property demotion writes properties into the bytes), so hash/cTag skip checks reprocess forever — key the skip on lastModifiedBy.application.id instead.
Tvůj vlastní PATCH metadat změní u Office souboru cTag i content hash (property demotion zapíše vlastnosti přímo do bajtů), takže přeskakování podle hashe/cTagu zpracuje soubor pořád dokola – klíčuj přeskočení podle lastModifiedBy.application.id.
Symptom
A scheduled job processes documents and stamps a result into their metadata (a column, a marker). To skip unchanged files it compares cTag — or, being clever, the content hash (file.hashes.quickXorHash). Either way, the same documents get reprocessed every single run, forever.
Cause
Two separate traps stacked on top of each other:
cTagchanges with any item update — including your own metadata PATCH. The moment you stamp your result, you've invalidated your own skip condition.- The obvious fix — compare the content hash instead — fails specifically for Office formats (docx/xlsx/pptx): SharePoint writes document properties into the file (property demotion, the modern echo of the old Office server-side properties sync). Your metadata PATCH physically rewrites bytes inside the file, so
quickXorHashchanges too.
Net effect: from the pipeline's point of view, every document it touches "changed".
Fix
Don't derive "did someone else change it?" from generic change indicators. Use actor identity:
- Primary skip: your own marker (timestamp/state column) plus
lastModifiedBy.application.id === <your app's client id>— if the last writer was you, nothing external happened since your stamp. - Content hash only as a secondary safety net for non-Office files, where metadata writes don't touch the binary.
const item = await graph.get(`/drives/${driveId}/items/${itemId}?$select=lastModifiedBy,listItem`);
const lastAppId = item.lastModifiedBy?.application?.id;
if (alreadyStamped(item) && lastAppId === MY_CLIENT_ID) { return; } // nothing new — skip
Notes
- Diagnostic rule that would have saved us a round: when a scheduled function "runs hot" on the same items, first check whether its own write negates its own skip condition.
eTagbehaves likecTagfor this purpose — both are item-version indicators, not "content changed by someone else" indicators.- PDF and image files don't get property demotion — which makes the bug intermittent-looking if your library mixes formats. All the more reason to key on
lastModifiedBy.
Copy jobs do it too — file sizes differ after a library-to-library copy
Measured on a real migration (October 2026): a CreateCopyJobs copy of a 794-file library to another site finished cleanly and every file arrived, yet a size check said the total was off by −13 kB. Only Office files differed (23 of them: docx, pptx, xlsx), and the deltas were constant per source library and content type (−1001, −126, +141 bytes). PDFs, images and everything else matched to the byte.
Opening both ZIPs and comparing parts by CRC showed the content parts (word/document.xml, slides, sheets, media) were identical. Only metadata parts changed:
| Part | What changed |
|---|---|
customXml/item*.xml (content type schema) |
replaced by the target library's schema (13,863 → 8,178 bytes in our case) |
customXml/item*.xml (property values) |
values for columns the target doesn't have dropped (e.g. image tags, TaxCatchAll) |
customXml/itemProps*.xml |
new datastore GUIDs |
docProps/custom.xml |
ContentTypeId of the target library + empty properties added (Order, xd_Signature, TemplateUrl, _SourceUrl, …) |
docProps/core.xml |
an empty <dc:title> added |
So a copy job does not copy Office files byte for byte — SharePoint demotes the target library's schema into them on arrival.
Consequences for migration checks:
- Never compare
File/Lengthof Office files exactly between source and target. Compare file presence and count exactly; for OOXML extensions, treat a small size delta (we allow up to 64 kB — schema size grows with the number of library columns) as metadata, and report it as such instead of "size mismatch". - Keep exact comparison for non-Office files and flag any Office delta beyond the threshold — a truncated or failed copy shows up as a whole-file difference, not hundreds of bytes.
- To prove content integrity of an Office file, compare the CRCs of its content parts from the ZIP central directory, not the file hash or length.
Not only OOXML: old binary Office, Outlook messages and HTML
Measured on a second migration (November 2026, a library of 2,135 files, grouped by extension after the copy):
| Extension | Files with a different size | Delta | What changed |
|---|---|---|---|
.xls, .doc (OLE) |
28 of 32, 33 of 43 | −4.6 kB to +2 kB | metadata streams; 161 of 194 512-byte sectors unchanged in one .xls |
.msg (OLE) |
39 of 39 | −2 kB to +2.5 kB | same as .xls: 87 of 99 sectors unchanged |
.htm |
5 of 5 | +1,185 to +1,289 characters | a block <!--[if gte mso 9]><xml><o:CustomDocumentProperties>…</xml><![endif]--> with the library columns in <head>, xmlns:msdt on <html>, an empty <title> |
.pdf, .jpg, .png, .tif, .txt, .eml, .rtf, .odt, .zip |
0 | — | nothing |
The two .htm files measured in detail (7 kB and 62 kB) grew by exactly the same 1,185 characters, and the rest of the file was unchanged. So the metadata allowance in a migration check must cover old binary Office, .msg and .htm/.html too, not just OOXML. To find the formats you forgot, compare sizes for the whole library grouped by extension, not file by file.