Migrate the contract, not the fleet
A fleet-wide migration flips one shared contract and lets every script relocate at once. But a flip only reaches the scripts that ask the contract where to write. The ones that hard-wire a local path — especially the generators an orchestrator calls in-process — never appear on the list the migration runs from. You can't migrate what won't tell you it's there.
The weakness
The plan was clean. Field note № 5 had collapsed sixty hard-wired copies of one local path into a single contract module — a module that names the directory taxonomy and resolves it from the environment. Migrating the whole fleet off the protected folder was now supposed to be a single move: inject the new layout, and every script that imports the contract relocates together, behind back-compat, with a one-variable rollback.
So the deployment was modelled as a list. Enumerate the fleet, confirm each unit imports the contract, flip the environment, verify. The list was built the obvious way: read every scheduled job's command line, follow the shell wrappers they invoke, and check those files. By that list, the fleet was migrated. Then the real run went red.
One scheduled job is not one script. The daily hygiene job is a Python orchestrator: it imports two dozen audit scripts in-process and calls each as an adapter. The migration's list had named the orchestrator — and zero of the generators it imports. Those children still hard-wired the old local path. Under the real unattended runtime, twelve of them tried to write to a folder the contract had moved out from under them, and were denied. The list said migrated. The call graph said otherwise.
The wrong fix (the one the list invites)
The tempting response is to patch the twelve that turned red and re-run until it's green. It would have worked — and it would have been the same trap that built the problem:
- It treats symptoms, not the gap. The twelve that failed were the twelve that happened to run that day and happened to write. The question isn't "which broke" — it's "what else is the list blind to?"
- It trusts the wrong boundary. A list built from command lines and shell wrappers can only ever see the units the scheduler launches. Every dependency reached by an
importor asubprocesscall lives below that boundary, invisible, however carefully you read the list. - It leaves the hard-wire in place. Patching the red ones one path at a time re-creates exactly the scattered, private assumption the contract existed to kill. You'd migrate the fleet and quietly re-fork the convention in the same motion.
Chasing the red checks accepts the framing that the list was right and a few scripts were stragglers. The list was the problem.
A migration is only as deployable as the fleet is decoupled. A script that hard-wires its output doesn't just resist the move — it hides from it. The coupling and the blind spot are the same fact, seen twice.
The reframe
The unit of migration was never "a scheduled job." It was "a producer of output." And producers don't live on the scheduler's list; they live on the call graph. A generator reached three imports deep inside an orchestrator is every bit as much a producer as the one named in a plist — it just declared its dependency privately, by hard-wiring a path, instead of publicly, by asking the contract. So the real requirement isn't "migrate the fleet." It's "make every producer declare its output through the contract, and enumerate by following the calls, not the commands." Decoupling isn't a tidiness goal here. It's the precondition that makes a one-flip migration possible at all.
The removal
Three moves, each taking out a hard-wire and the blind spot it created:
- Enumerate by the call graph, not the command line. Trace what every scheduled entry transitively imports and subprocesses, and treat each producer it reaches as a migration target. Walking the orchestrator's in-process adapters surfaced eight more hard-wired producers the command-line list had never named.
- Every producer asks the contract, none assert the path. Each adapter stopped declaring a private local path and imports the shared constant instead — output, cache, and input-coupled reads alike. Once a generator asks where to write, the single flip reaches it. The decoupling is what makes the producer migratable.
- Delete the dependency, don't just redirect it. Several hard-wires were dead — a legacy credential path, a stale fallback directory — kept alive only because nothing forced the question. A migration is the moment to ask it: a coupling you can remove outright is one fewer thing the next migration has to reach. Constantly removing dependencies is cheaper than constantly migrating them.
The proof — and what the call graph surfaced
The migration only counts if the list that failed is replaced by one that can't hide a producer — and proven on the real runtime, not in a console.
The failed run wasn't a setback; it was the cheapest possible proof that a list built from command lines is structurally blind to in-process dependencies. The same trace that explained the twelve red checks found eight more producers that would have failed the next time they happened to run — caught before they ever stamped a false green. That is the difference between a migration that patches what broke and one that closes the class: you don't ask "which scripts failed," you ask "what kind of producer can this list not see," and you change the list until the answer is none.
This is the same shape as field note № 1 read from the other side. There, the move was to remove a coupling rather than harden the coupled component. Here, an unremoved coupling — a producer hard-wired to a local path — is precisely what made it invisible to the migration. Coupling doesn't only make a thing fragile. It makes the thing unreachable by the very operation meant to strengthen it.
The principle, generalised
This was one orchestrator on one Mac, but the moves aren't about Python imports:
- A migration moves a contract; a list of units just hopes to cover the fleet. Flip a shared convention and every consumer of it relocates. Patch a list and you cover exactly what was on the list.
- Enumerate by the call graph, not the entry points. Anything reached by an import, a subprocess, an include, or a dynamic dispatch is a real dependency. If your inventory stops at what the scheduler launches, it is blind by construction.
- Hard-wired dependencies don't migrate — they hide. A unit that asserts its own path can't be relocated from outside and won't show up when you go looking. Decoupling is what makes a fleet migratable.
- Let producers ask, never assert. A component that requests its resource from a contract moves when the contract moves. One that hard-codes it forks the convention the moment anything changes.
- A migration is the right time to delete, not just redirect. Every dead coupling you remove is one fewer thing the next migration has to find. The strongest fleet is the one with the fewest dependencies left to move.
You can't deploy a migration across scripts that hard-wire their own world. The flip reaches the ones that ask a shared contract where to live, and skips — silently — the ones that decided for themselves. Migrate the contract, and a decoupled fleet follows. Migrate a list, and the couplings you couldn't see stay exactly where they were.
gf.cx filesystem-contract migration ·
imports assets.gf.cx favicon + card primitives