I built Migration Control Tower and wrote this post for the purposes of entering the All Things Agentic Hackathon. Everything below is from the actual build — including the bug that nearly let the system approve a migration that never moved a single row.
I did not set out to build a migration tool. I set out to build the thing that supervises migration tools, and the difference turned out to be the whole project.
Here is the problem as I understand it. A large company decides to move off SQL Server, or Oracle, or a pile of Airflow DAGs nobody has read in three years, onto BigQuery. What actually happens next is a spreadsheet. Someone inventories the tables by hand. Someone else guesses which downstream reports break. A migration runs over a weekend, a row count gets eyeballed, and everyone agrees it looks fine. The risk is not that the technology fails. The risk is that nobody can prove what happened.
So the product is not one migration. It is a migration control plane: connect an estate, discover it, assess it, plan it, execute it, reconcile it deterministically, learn from prior runs, and cut over only inside explicit policy.
Here is a four-minute run of the whole thing before I ask you for two thousand words:
The decision that shaped everything else
The first real design question was not which framework to use. It was: what is a language model actually allowed to decide?
The tempting answer, especially in an agentic hackathon, is "as much as possible." Give the agent tools, give it the run, let it drive. I tried a version of that early and it felt incredible for about a day. Then I asked myself what I would say if a reconciliation passed and someone asked me why. The honest answer would have been "the model said it looked right," and that is not an answer you can give someone about their customer data.
So I wrote down a rule and let the entire architecture fall out of it:
Models interpret. Deterministic Python decides.
Gemini reads code, documents and failures, and explains what they mean. Authorization, state transitions, row counts, schema and hash comparisons, policy enforcement and approval verification are ordinary Python. A model can propose or explain. It can never be the thing that decides whether an action is allowed or a check passed.
This is not a hedge against models being bad. It is about where the burden of proof sits. When reconciliation fails, I want the failure to be a number compared to a number.
There is a security consequence I did not anticipate when I wrote the rule, and it is my favourite thing about the project. Prompt injection is structurally inert here rather than defended against. tools/policy_engine.py takes no free-text estate content as input — not table comments, not column descriptions, not DAG docstrings. There is no channel through which a malicious table comment could reach an authorization decision, because the authorization path never reads estate text at all. I did not write an injection filter. I removed the pipe.
The second rule came later, out of frustration:
Estates are configuration, not code.
Onboarding a second estate means adding an adapter, a Migration Pack, an estate document and one registry entry. It never means editing the core agents.
I knew from experience that this claim rots. Someone — me, at 1am, three days from a deadline — drops an if source == "postgres" inside the Planner because it is right there and it works. Every other test still passes. The claim is now false and nothing tells you.
So a test holds the line instead of a reviewer. tests/test_clean_estate_onboarding.py greps every file under agents/ for any mention of the second source. One special-case branch anywhere in an agent fails the build. It is a crude test and I like it more than any elegant one in the suite, because it is the only one that can catch me being lazy.
Seven agents that cannot call each other
The fleet is Discovery, Lineage, Risk & Compliance, Planner, Validation & Reconciliation, Cutover, and a Finance Impact agent, coordinated by an orchestrator.
The constraint I put on myself: agents never import each other. There is no from agents.discovery.agent import ... anywhere in the dispatch path. The orchestrator calls registry.invoke_capability("discovery.catalog.estate", ...), and tools/registry.py resolves the highest-version APPROVED card advertising that capability and dynamically imports its handler.
+------------------------------------------+
CONTROL PLANE | Discovery -> Lineage -> Risk -> Planner |
reasoning, | -> Validation -> Cutover |
policy, plans | resolved by CAPABILITY, never by import |
+--------------------+---------------------+
| authorized, scoped request
v
+------------------------------------------+
DATA PLANE | extract -> load -> deterministic |
bulk movement | reconciliation (counts, sums, hashes) |
no LLM involved +--------------------+---------------------+
v
BigQuery
Detail from the agent fleet diagram — open the complete version (3840×2160) to see the dispatch path and evidence chain.
That diagram is the clearest picture of the first rule I have. The agents that reason are boxed off in one lane; the ones that decide control outcomes are in another; and the annotation between them is the whole design: AI cannot approve, transition state, change facts, or declare PASS.
For most of the build this felt like ceremony. A registry lookup to call a function in the next folder is objectively more code than calling the function.
The Finance Impact agent is where it paid for itself. It is owned by a different team ("Finance Systems"), published by one identity and approved by another, and the orchestrator has no knowledge of the module at all. It is discovered through a wildcard impact.assessment.* capability query. Deprecate its registry card and the orchestrator logs "no approved provider" and carries on with the run. That is the difference between a fleet and a monolith with seven files: I can add a department's agent to a running system without touching the system.
Publishing is deliberately two-step — publish() to DRAFT, approve() to APPROVED — and approve() refuses when approved_by == published_by. The same separation of duties the human cutover gate enforces, applied to the agents themselves. You cannot ship your own agent into production alone.
The orchestrator owns one Firestore document per run and a hard-coded legal transition graph. Illegal transitions raise. There are no silent state jumps:
REQUESTED -> DISCOVERED -> ANALYZED -> RISK_ASSESSED -> PLANNED -> MIGRATING
-> VALIDATING -> (FAILED -> INVESTIGATING -> REMEDIATING -> VALIDATING)
-> PASSED -> READY_FOR_APPROVAL -> APPROVED -> CUTOVER -> MONITORING
-> COMPLETE
That FAILED -> INVESTIGATING -> REMEDIATING loop is the interesting part. A seeded row-loss defect fails reconciliation, the recovery path pulls similar past incidents out of cross-run memory, and remediation is deterministic. The model explains the failure. It does not fix it.
The bug that almost shipped
This is the part I actually want to tell you about.
Late in the build I was watching a run go green and something bothered me. Reconciliation passed on a target table. It passed fast. Faster, I thought, than a table should take to load.
Here is what I had built without noticing. BigQuery landing-table names are stable across runs — customers_dim is customers_dim on run 1 and on run 47. Reconciliation checked the target table, found it, compared it, and passed.
But a target table existing does not mean this run created it. A previous run's customers_dim, sitting in the dataset from yesterday, would satisfy today's reconciliation with correct row counts, correct hashes, correct everything — while the current run had never reached the wave that was supposed to load it. Every check I had written was correct. Every check was answering the wrong question.
The system could have gone all the way to a human approval screen, presenting a clean green reconciliation, for a migration in which zero rows moved.
The fix took three commits and each one taught me something different.
fc0d004 — evidence must belong to this run. I added tools/execution_gate.py, whose docstring states the rule plainly:
BigQuery landing-table names are stable across runs. A target table may therefore exist even when the current run never reached the wave that was supposed to create it. Reconciliation must not interpret that stale table as evidence of a successful load.
A scheduled target now counts as covered only when a COMPLETED execution manifest stored beneath this run matches it. The gate deliberately evaluates Firestore manifests before any source or target query runs — you cannot be fooled by a table you never looked at. And a durable data_plane_blocker on the run document always wins, even when matching tables exist. If something went wrong in the data plane, the presence of plausible-looking tables cannot argue its way past it.
dc77ae4 — the gate belongs at the approval boundary too. Gating validation was not enough. The check had to exist at the point of consequence. Approval now returns a 409 rather than allowing cutover on stale evidence:
Cutover approval is blocked: this run does not have complete current-run data-plane evidence for {tables}. Start a fresh run.
Note that it does not offer to fix itself. It tells the operator to start a fresh run, because the honest recovery from "I cannot prove this loaded" is to load it again.
485f418 — and then the subtler version of the same bug. With the gate in place I went looking for the same mistake elsewhere, and found it in the evaluations endpoint. It accepted any completed data-plane report as a scale measurement:
report.get("status") == "COMPLETED"
and report.get("rows_moved") is not None
and report.get("bytes_moved") is not None
A job that completed successfully and moved zero rows satisfies that condition perfectly. 0 is not None. I was reporting a genuine zero as a measurement of scale.
report.get("status") == "COMPLETED"
and int(report.get("rows_moved") or 0) > 0
and int(report.get("bytes_moved") or 0) > 0
The same commit stamps run_id, execution_id, estate_id, source_table and target_table into both the COMPLETED and FAILED manifest writes in tools/data_plane_job/run_job.py. Without those fields the gate has nothing to match a manifest against a target — the safety rule and the data model had to land together.
Here is the reconciliation path with the gate in place. Note what comes first — before any source or target query, before a single row is compared:
Detail from the validation and reconciliation diagram — open the complete version (3840×2160) for the full check sequence.
Every scheduled target has a current-run COMPLETED execution? If no, persist data_plane_blocker and the run stays durably FAILED. The row counts and hashes downstream are excellent checks, and they never get the chance to answer a question that was never valid to ask.
The lesson generalises well past this project, and it is the thing I would tell anyone building an autonomous system:
"The artifact exists" is not evidence that "this run created it."
An autonomous system is a machine for producing confident conclusions. Every check you write is really a claim about causation, and it is very easy to write a check that verifies a state while you believe you are verifying an action. The state was real. The row counts were real. The hashes matched. And it meant nothing.
Smaller guardrails, same instinct
A few others in the same spirit, each of which came from a specific moment of distrust:
Credentials are references, never values. Estate documents carry a Secret Manager reference or the name of an environment variable. tools/secret_resolver.py resolves it at connect time and wraps the result in a type whose repr redacts itself, so logging the object cannot leak the password. ConnectionProfile is a closed schema, and 422 responses are stripped of the rejected input — otherwise refusing a submitted password echoes it straight back into the caller's console and logs. The onboarding wizard has no password input anywhere in it, asserted by both a component test and a browser test.
The fallback is loud. When Secret Manager is unavailable, resolution falls back to the declared environment variable and logs a WARNING naming which path answered. A fallback firing unnoticed gives you a working connection to the wrong database, which is so much worse than an error.
The Postgres fixture runs on port 5433, not 5432. A developer machine often already has Postgres running. A fixture that silently connects to your real database is worse than one that refuses to start.
Blocked beats guessed. A table with a composite primary key is blocked with a stated reason rather than migrated on a guess, because the extractor orders by a single key and reconciliation compares ordered key lists. A table with no numeric column records aggregate_check: not_applicable rather than comparing against a fabricated zero. Refusing to answer is a feature.
Degradation is a ladder, and it announces itself. ADK import failure falls back to a direct tool call. Vertex AI failure falls back to a deterministic narrative template. Gemini vision failure falls back to hardcoded schema matching. Every rung logs which path answered, because a silently degraded system that still returns plausible output is the worst failure mode available.
What it is built on
- Gemini 3.7 Flash for bounded structured reasoning, Gemini 3.5 Flash for the cited read-only assistant, and Google ADK as the agent framework
- Pub/Sub as the event backbone and Firestore for run state, the Agent Registry, policy decisions, idempotency records and cross-run memory
- Cloud Run Jobs extracting from Cloud SQL into BigQuery, with every comparison in deterministic code
- Nine Cloud Run services, each with its own service account —
sa-discovery,sa-lineage,sa-risk,sa-planner,sa-validation,sa-cutover,sa-finance-impact,sa-orchestrator,sa-control-tower-ui— plussa-pubsub-invokerfor OIDC push - FastAPI typed API behind an Oracle JET 20.1.3 / Preact / TypeScript Redwood console: eleven operational routes plus an estate onboarding wizard
- Terraform, Cloud Build, Docker, OpenTelemetry, Cloud Trace and Cloud Monitoring
One of seven trust boundaries — open the complete deployment diagram (3840×2160) for all of them.
Seven trust boundaries, and only the UI/API lane is internet-facing. The thing I would point at is the rightmost column: durable state, evidence and observability as a first-class boundary rather than a logging afterthought. Every tap on that rail is a direct write.
Roles come from Firebase custom claims and are scoped per estate, which was a mid-build correction. A global operator role was defensible with one estate; with several it means someone onboarded for one customer can act on another's data.
Test counts, since they are the honest measure of how much of this I actually believe: 556 backend tests (run against live Firestore), 37 component tests, and 25 Playwright + axe browser tests.
What I did not build
I would rather tell you this than have you find it:
- Execution against Postgres is unproven. Its pack is assessment-mode by design. Discovery, planning and reconciliation are exercised; no Postgres-to-BigQuery load has run.
-
Secret Manager is unproven live. Local runs use the documented environment fallback, and
health_checksays so explicitly. - Workers are in-process, not a managed runtime. The consumers run as threads inside the API process behind a Firestore lease. That is one rung below the production shape, and I am not pretending otherwise.
- The data plane executor is an interface with one in-memory implementation. A Dataflow-backed executor is not attempted.
- Scale figures are bounded. The harness measures 100–500 synthetic definitions. The 20,000 figure in the design is a control-plane planning benchmark, not 20,000 migrated pipelines, and I will not let it be read as one.
- Firebase custom claims cap near 1000 bytes, so per-estate role grants stop scaling past roughly 15–20 estates.
I put this section in the README before I put it in this post. A system whose entire thesis is "prove what happened" does not get to be vague about its own limits.
What I would tell myself at the start
Three things.
Decide what the model is not allowed to do, first. Every architectural question after that answered itself. I never had to debate whether an agent could approve a cutover.
Write the test that catches your future laziness. The grep test for estate special-casing is ugly and it is the most valuable test in the suite, because it defends a property no other test can see.
Be suspicious of the checks that pass. I spent most of the build debugging failures. The most dangerous bug in the project was in a check that was passing, quickly and correctly, and answering a question I had never meant to ask.
Links
- Demo video (4 min): https://youtu.be/GZRn5jb-eCk
- Source: https://github.com/Nikhil0075/MIGRATION-CONTROL-TOWER
- Project site: https://migration-control-tower.vercel.app/
- Hackathon: https://allthingsagentichackathon.devpost.com/
I created this piece of content for the purposes of entering the All Things Agentic Hackathon, in the Fortified Enterprise Fleet category. Migration Control Tower was built on Google Cloud with Gemini and the Google Agent Development Kit.
If you have built agentic systems that touch production data, I would genuinely like to know where you drew the line between what the model decides and what your code decides. I suspect everyone draws it somewhere different.











Top comments (0)