When a supplier tells you that two of its systems are being merged, the question underneath is always the same one: what happens to the data while that is going on. The answer is not a promise. It is whatever the migration tooling does by default when it meets something it does not recognise.
We put that question to ourselves this week, and the honest version of the answer is worth publishing, because the check that made the job look cheap was a search that came back clean.
Two lineages, one database
The clinical platform VARGATES Medical is taking over the learning core built for KNOWIKA, our own learning platform. One database, one service, one connection — that part was decided first and is not the interesting part.
The interesting part is arithmetic. KNOWIKA declares 83 tables through its models. The migrations of the platform receiving it create 113. And 17 table names are owned by both systems:
users courses lessons enrollments
certificates quiz_attempts tracks groups
skills badges user_badges user_skills
course_skills track_courses invite_links xapi_statements
alembic_versionThe deciding reason is not the collision
A name collision is visible. You can look at it, argue about it, and rename something. It was not what decided the design.
The migration tool compares the models it is given against the database it is pointed at, and that comparison is symmetric. A table the models declare and the database lacks becomes a proposed create_table. A table the database has and the models do not becomes a proposed drop_table.
So the incoming core is being built into a database schema of its own, on the same database, while the outgoing system keeps the schema it already occupies until that is removed on purpose. The alternative — a second database for the duration of the transition — is open, and is the owner's call rather than an engineering one.
What was ruled out is the tempting middle: an allowlist naming the tables the comparison may touch. It puts the same outcome one forgotten entry away, on a list that has to grow every time either side adds a table.
The search that found nothing
Separating a schema is cheap if the incoming lineage never names one. So the question was asked in the cheapest available way: search its models and its migrations for a schema argument. Nothing came back. Conclusion recorded at the time — the lineage is schema-agnostic and will build wherever it is pointed.
The search was accurate. The conclusion was wrong, and the gap between those two sentences is the whole point of this piece. A result that finds nothing is a fact about the pattern that produced it, not about the world it was pointed at. Two mechanisms decide where this lineage builds, and neither of them is spelled the way the search was spelled.
77 executable search-path pins, across 34 migrations, every one of them naming
public. A function that runs with elevated rights must pin its search path — that is correct defensive practice, not a defect. But a pin that names the outgoing schema, in a function whose body queries table names unqualified, sends that function to the other system's tables.Eleven functions that one revision creates in
publicdeliberately. Which means the very first upgrade writes into the schema the design had just promised to leave alone.
The failure that raises no error
32 of those migrations read at least one of the colliding names without qualifying it. The sharpest instance is the authentication bootstrap: deployed into its own schema with the pin left intact, it would look up users in the system being replaced.
And it would not fail. The outgoing system has a user table of its own, carrying a column of the same name and shape. The lookup succeeds, and returns the wrong system's rows.
And the count itself was wrong
77 was 68 until the port was actually executed. The first number came from a pattern narrow enough to miss nine of the sites it was counting — the same mistake as the schema search, made inside the section written to describe that mistake.
It changed no decision, because the repair was a substitution over every file rather than over a list of 68. What it would have cost is a reviewer's ability to check the work by counting: nine corrected sites would have looked like nine unexplained extras.
SET search_path = pg_catalog, public, pg_temp
SET search_path TO 'pg_catalog', 'public', 'pg_temp'If you are the one evaluating a supplier
None of this is exotic. Any team moving one system's data into another's database meets the same three questions, and the answers are checkable:
What does the migration tooling propose when it meets a table it does not know about — and has anyone actually run it against a populated database to find out?
Can the guarantee test tell a correct answer from a merely plausible one? A query against the wrong table usually returns rows, not an error.
When a search comes back clean, what could that search not have seen? That question has an answer, and it is worth asking before the result is used as evidence.
We publish these because they are the questions we would want asked of us.