The rename command was the last step of a drive migration: the old project-drive root to a dated backup name. Windows said no.

The code was fine. The migration’s sweep commit had already landed the environment variable that gates the projects root, and every consumer in the codebase had been updated to read from it. The rename was supposed to be the mechanical last step, the confirmation that the migration was done. We got Access Denied.

the grep came back empty

The migration began as a straightforward path move. Our project root had lived on one drive since we built this machine, and we wanted it on a faster one. The plan was simple: introduce a single environment variable, sweep every path consumer to read from it, then rename the old root as a dated archive so nothing could drift back.

The sweep commit did that work. After it landed, we grepped the repo for every hardcoded reference to the old path we could find. Nothing came back.

Then we went to rename the directory.

Our first thought was that we had missed a file reference somewhere.

what Access Denied meant here

On Windows, Access Denied on a directory rename can mean another process still has a handle open on the path. That was the case here. The filesystem was not ours to move while something else was reading it.

The obvious suspects went first. Our own coding session was running inside the old root at that moment, helping finish the migration, so the tool investigating the problem was itself holding a handle on the path we needed to rename. We closed it. The local IIS web server was also serving sites out of the old root, so we stopped that too, with an elevated service stop, not a click in a control panel.

Still denied. We handed the machine to a fresh session with nothing else open in that directory and kept going.

the search that found nothing

We asked Windows for every service whose reported path pointed at the old root. Nothing came back, and for a few minutes we took that to mean no service was involved. It didn’t mean that, and finding out why took another layer of digging.

A handful of background processes on this machine run wrapped by NSSM, the Non-Sucking Service Manager. NSSM lets you run any executable as a proper Windows service, one that survives reboots and session closes regardless of what happens on the desktop. The service control manager only ever sees NSSM’s own path. It has no idea what program NSSM is running underneath, or where that program thinks its working directory is. A path search against the service list can never find it.

The answer sat one registry key deeper, under each wrapped service’s own parameters. That is where the actual working directory lives, and no tool that only asks the service control manager will show it.

the services we had forgotten

Two of the NSSM-wrapped services on this machine, a reverse proxy and a small app server, had working directories pointing straight into the old root. We had installed both to keep background tooling alive and had not thought about either one while planning the migration. Windows services hold an open handle to their working directory by default. There is no warning, no log entry, no error surface. The service just owns the path.

We stopped both and checked the registry again. Nothing was left pointing at the old root, and the rename went through.

a second ghost, from somewhere else entirely

A few days after the rename went through, we found a scheduled task on the same machine pointed at a path that no longer existed. It had nothing to do with the drive migration. A separate reorg, done the day before the migration for unrelated reasons, had moved the folder that task’s stored arguments pointed at, and nobody had gone back to update it. We only found it because we were building something else at the time and happened to notice the task’s log had stopped updating.

It is a different failure from the one Access Denied surfaced, but it rhymes. A scheduled task’s stored arguments are set once through a GUI or a one-off command and never committed anywhere. Nothing about them shows up in a codebase search, and nothing announces when they go stale. This one came from the same kind of blind spot as the services.

the inventory that was missing

The output of this migration was a workstation inventory we can reference the next time anything needs to move: every wrapped service, its executable, its real working directory, and its log path. That inventory did not exist before. It exists now because Access Denied forced us to build it.

A codebase grep finds hardcoded paths in tracked files. It does not find a service manager’s idea of a working directory. That part of a workstation does not version-control itself, and it stays invisible until something that depends on it actually runs.

We do not know what else on this machine still points at an old path, from the migration or the reorg. The scheduled task only surfaced because we happened to be looking at something adjacent to it. Whatever else is out there is waiting on the same kind of accident.