Firestore Security Rules - How can I check that a field is/isn't being modified?

Your rules will be a lot more readable and maintainable if you create a custom function to check for updates. For example:

service cloud.firestore {
  match /databases/{database}/documents {
    function isUpdatingField(fieldName) {
      return (!(fieldName in resource.data) && fieldName in request.resource.data) || resource.data[fieldName] != request.resource.data[fieldName];
    }

    match /users/{userId} {
      // Read rules here ...
      allow write: if !isUpdatingField("role") && !isUpdatingField("adminOnlyAttribute");
    }
  }
}

I solved it by using writeFields. Please try this rule.

allow write: if !('role' in request.writeFields);

In my case, I use list to restrict updating fields. It works, too.

allow update: if !(['leader', '_created'] in request.writeFields);

So in the end, it seems I was assuming that resource.data.nonExistentField == null would return false, when it actually returns an Error (according to this and my tests). So my original solution may have been running into that. This is puzzling because the opposite should work according to the docs, but maybe the docs are referring to a value being "non-existent", rather than the key -- a subtle distinction.

I still don't have 100% clarity, but this is what I ended up with that worked:

function isAddingRole() {
  return !('role' in resource.data) && 'role' in request.resource.data;
}

function isChangingRole() {
  return 'role' in resource.data && 'role' in request.resource.data && resource.data.role != request.resource.data.role;
}

function isEditingRole() {
  return isAddingRole() || isChangingRole();
}

Another thing that still puzzles me is that, according to the docs, I shouldn't need the && 'role' in request.resource.data part in isChangingRole(), because it should be inserted automatically by Firestore. Though this didn't seem to be the case, as removing it causes my write to fail for permissions issues.

It could likely be clarified/improved by breaking the write out into the create, update, and delete parts, instead of just allow write: if !isEditingOwnRole() && (isOwnDocument() || isAdmin());.