Hello @My Music Lab ,
I ran your snippet as-is (baseline pre-created as a disabled task, writes enabled, the two placeholders filled). It reproduces what you describe, and it does so every time: it ends with UpdateReadback = UNCONFIRMED and RestoreReadback = RESTORATION_UNCONFIRMED even though the RegisterTask(TASK_UPDATE) call actually succeeded and the candidate definition was applied. So the write and the timing are not the issue here, the comparison is.
The reason is that when you pass an SDDL in the 7th parameter, the service writes it back into the task XML as a <SecurityDescriptor> element under <RegistrationInfo>. Your baseline was registered without an SDDL and your candidate is built by hand, so neither contains that node, while the readback after the update does. That single injected element is enough to make Same-Definition(observed, candidate) return false, which throws CANDIDATE_NOT_CONFIRMED, and then the restore branch matches neither original nor candidate and falls through to RESTORATION_UNCONFIRMED. The diff is exactly this:
candidate: <RegistrationInfo><Description>...</Description><URI>...</URI>...
observed : <RegistrationInfo><SecurityDescriptor>O:BAG:...</SecurityDescriptor><Description>...
So the fix is on the comparison side: compare a readback against another readback rather than against your hand-built candidate, and strip the <RegistrationInfo><SecurityDescriptor> node before comparing (you already verify the descriptor separately through GetSecurityDescriptor), or just compare the specific fields you changed.
On your four remaining questions. There is no documented atomicity or rollback for RegisterTask with TASK_UPDATE; it overwrites the definition in place and does not keep the old one for you, so you should treat it as applied only on a SUCCEEDED HRESULT. Keep in mind a few success codes are not S_OK (for example SCHED_S_SOME_TRIGGERS_FAILED, 0x0004131B), and in PowerShell only a failed HRESULT throws, so your try/catch to UNCONFIRMED is already correct. There is also no version, ETag or conditional-update token on the API, so the gap between inspecting and restoring is a genuine TOCTOU window with no supported guard beyond re-reading right before you restore and staying fail-closed, which is what you do.
For comparison in general, the service canonicalizes the XML on registration: it expands the default Settings, adds URI, drops RunLevel when it is LeastPrivilege, resolves UserId to a SID, and injects the SecurityDescriptor as above. Because of that, only a readback-versus-readback comparison is reliable; that canonical form is deterministic and re-registering it is idempotent. Ordinal comparison of the SDDL string is fine in practice since the descriptor you pass round-trips unchanged, just note that GetSecurityDescriptor(7) includes inherited (ID) ACEs. And if a call hangs instead of throwing, there is no per-call timeout in COM and no API that tells you the previous RegisterTask has committed, so the practical pattern is to run the call on a worker thread with your own timeout and, on timeout, open a fresh ITaskService::Connect and read the current definition to decide rather than restoring blindly.
One thing worth flagging for the "zero running instances" check: GetInstances only returns instances running at or below the caller's security context, so a count of zero is only trustworthy when you run elevated or in the same context as the task principal.
Overall your fail-closed readback approach is the right one, precisely because Task Scheduler gives you no atomic update, no rollback and no version token. The only real defect is the whole-document comparison tripping over the injected SecurityDescriptor. Your flag choice (TASK_UPDATE | TASK_IGNORE_REGISTRATION_TRIGGERS | TASK_DONT_ADD_PRINCIPAL_ACE) is fine and keeps the security state untouched.
If you want to dig into the details, the relevant documentation is ITaskFolder::RegisterTask for the flags and return codes, IRegisteredTask for the full member set (which confirms there is no version or conditional-update token), and IRegisteredTask::GetInstances for the security-context note on counting running instances.
I hope this helps clarify what is happening. If you adjust the comparison as described and still see an unconfirmed result, feel free to share the updated readback and I will be glad to take another look. If you found my response helpful or informative, I would greatly appreciate it if you could follow this guide for your confirmation.
Thank you.