info@altius-group.ch
Froideville, Vaud
FR

A foreign key we deliberately did not create

/ 14 min de lecture / mis à jour 04.09.2026

There is a modelling decision that looks like laziness and is sometimes correct: referencing another row by a code — a string — instead of by a foreign key.

You give it up on referential integrity. In exchange you get something a foreign key cannot give you: a reference that survives the target being replaced.

This is that decision in the admissions platform of ENI-ABT, the engineering school’s application system — why it was right, the production incident where it failed, and the repair. The interesting part is not the bug. It is that the bug had two faults in it, and the second one was worse than the first.

Why not a foreign key

The domain has two objects that live on different clocks.

A voie d’admission — an admission path — belongs to a session. Each year the school declares which paths are open: direct entry to L1, entry to M1, professional entry for technicians, and so on. They are declared per campaign, they change, and the 2027 list is not the 2026 list.

A porte — a public entry point, the thing a candidate clicks on the website — crosses campaigns. “Competitive entry after the baccalaureate” is a door that exists every year. It is configured once, translated once, given its own form fields once.

So the door names its path by code:

"""
Un type d'inscription ouvert au public désigne sa voie d'admission par un
**code**, et non par une clé étrangère : les voies sont déclarées session par
session, alors qu'une porte traverse les campagnes.
"""

A publicly-open registration type designates its admission path by code, not by foreign key: paths are declared session by session, while a door crosses campaigns.

With a foreign key, opening the 2027 campaign would mean re-pointing every door at the new rows — a migration of configuration data, every year, by hand, and a door forgotten is a door pointing into last year’s campaign. With a code, the door says “the path called concours-bac, in whatever session is currently open”, and the annual campaign load is a data operation that touches only paths.

The docstring is equally clear about the bill:

"""
C'est un bon découplage, et il a un prix : rien ne garantit que le code
désigne encore quelque chose.
"""

It is good decoupling, and it has a price: nothing guarantees the code still designates anything.

This is the honest framing of a soft reference, and it is worth adopting as a habit. Not “we don’t need a FK here”, but: a soft reference is a foreign key whose integrity check you have agreed to write yourself. If you do not write it, you have not removed the constraint — you have removed the checking of the constraint.

The incident

"""
Il ne désignait plus rien. Le chargement du concours 2026-2027 a remplacé les
voies de la session par les sept portes d'entrée du communiqué officiel —
`licence-l1-direct`, `master-m1`, `ts-professionnel`… — pendant que les deux
types d'inscription livrés continuaient de viser `concours-bac` et
`admission-titre`, qui n'existaient plus.
"""

The 2026-2027 campaign load replaced the session’s paths with the seven entry routes from the official announcement. The two registration types shipped with the application still pointed at concours-bac and admission-titre. Those codes no longer existed.

What a candidate experienced:

The public form displayed normally. They filled in their twelve fields. They clicked. They got a 500.

The guard that requires a path raised an invariant violation.

Sit with the shape of that for a moment, because it is the worst possible failure geometry. Nothing failed at the point where something was wrong — the configuration. It failed at the point of maximum invested effort, for the person with the least ability to do anything about it, with a message that told them nothing. A candidate who hits that does not file a bug report. They close the tab and apply somewhere else, and the school never learns it happened.

Two faults, and the second is the one to learn from

"""
Deux fautes, et la seconde est la plus grave.

La première est la désynchronisation elle-même, que ce module répare et
prévient — le chargement d'un concours réconcilie désormais les portes qu'il
déplace.

La seconde est d'avoir traité un défaut de données comme un défaut de
programmation. Une voie absente n'est pas un invariant rompu : c'est un
référentiel incomplet, cas parfaitement ordinaire dont l'application doit
rendre compte à l'agent qui peut le corriger — pas une page blanche au
candidat, qui ne peut rien y faire et qui repart.
"""

The first is the desynchronisation itself. The second is having treated a data defect as a programming defect. A missing path is not a broken invariant: it is an incomplete reference table, a perfectly ordinary situation that the application must report to the agent who can correct it — not a blank page for the candidate, who can do nothing about it and leaves.

That distinction is the whole article, and it generalises far beyond this codebase.

An invariant violation is a statement about your code: something that the program’s logic guarantees cannot happen has happened. The correct response is to stop loudly, because continuing means operating on state you have proven you do not understand. assert, a 500, a page.

A data defect is a statement about the world: a reference table is incomplete, a code was mistyped, a campaign was loaded in the wrong order. It is not a contradiction. It is ordinary, it is frequent in any system fed by humans, and it has a named owner who can fix it in ninety seconds — an administrator with a screen.

Conflating them is extremely common, and it comes from a good instinct applied at the wrong altitude. “Fail fast, don’t paper over bad state” is right. But the raise was written by a developer thinking about a code path, and it executes in front of a nineteen-year-old who has just typed their parents’ address, in a country where they may have paid for the internet café hour.

The test to apply, and it is a fast one:

Who can fix this, and are they the person looking at the screen?

If the answer is “a developer”, stop loudly — nobody in front of the screen can help, and the log is the audience. If the answer is “an administrator”, then this is content: a state the application knows how to describe, with an action attached, shown to the administrator on their own screen. And the person in front of the screen — the candidate — should never have been offered a door that could not open.

Modelling the state instead of asserting it

The repair replaces the assertion with an object.

@dataclass(frozen=True)
class Etat:
    """L'état d'une porte publique vis-à-vis d'une session."""

    porte: TypeInscription
    #: La voie visée, quand le code en désigne une d'ouverte.
    voie: object = None
    #: Vraie quand la porte laisse le candidat choisir sa voie.
    au_choix: bool = False

    @property
    def utilisable(self):
        """La porte peut-elle ouvrir un dossier maintenant ?"""
        return self.au_choix or self.voie is not None

A door’s relationship to a session is now a value you can hold, not an exception you can catch. utilisablecan this door open a file right now — is a question anything may ask: the public page that decides whether to show the door, the session dashboard, a test, a health check.

Frozen, because it is a snapshot of a relationship at a moment; nothing should be tempted to mutate one and expect the world to follow.

And the state carries the sentence a human needs:

@property
def diagnostic(self):
    """Ce qu'un agent doit lire quand la porte ne marche pas."""
    if self.utilisable:
        return ""
    if self.porte.code_famille:
        return _(
            "La porte « %(porte)s » ouvre un cycle, mais aucune voie de "
            "cette session n'en déclare : les portes par cycle ne trouvent "
            "rien, et aucun candidat ne peut les emprunter. Rechargez la "
            "campagne pour poser les cycles."
        ) % {"porte": self.porte.libelle}
    return _(
        "La porte « %(porte)s » vise la voie « %(code)s », qui n'existe pas "
        "dans cette session ou n'y est pas ouverte. Aucun candidat ne peut "
        "l'emprunter."
    ) % {"porte": self.porte.libelle, "code": self.porte.code_voie}

Four properties of that message are deliberate, and each one is a thing most error messages get wrong:

  1. It names the door and the code. Not “invalid configuration”. The administrator can find both in their interface without a developer.
  2. It states the consequence in the school’s own termsno candidate can use it. That is what makes it urgent to someone who does not think in foreign keys.
  3. It gives the actionreload the campaign to set the cycles. A diagnosis without a next step is a complaint.
  4. It is a translated string. It appears in a back-office used in French, and it is content, not debug output. Content gets translated; tracebacks do not. Which of the two you chose is visible from the _().

Note also that diagnostic returns "" when the door works. It is not None-or-string, and there is no separate has_problem flag. One property, falsy when there is nothing to say, and a template can render it unconditionally.

Distinguishing a decision from a breakage

This is the subtlest part of the module and the piece I would most want a reader to take away.

elif porte.code_famille:
    # Une porte par cycle ne se casse pas sur un code de voie, et elle
    # ne se casse pas non plus parce que l'école n'ouvre pas ce cycle
    # cette année : ne pas ouvrir le master en 2027 est une décision, et
    # crier dessus à chaque campagne apprend à ne plus lire l'écran.
    #
    # Ce qui est une panne, c'est une session dont aucune voie ne
    # déclare de cycle : plus aucune porte n'y mène, personne ne peut
    # candidater, et rien ne le dirait.
    etats.append(Etat(porte=porte, au_choix=bool(familles)))

A cycle-door does not break because the school is not opening that cycle this year: not opening the master’s in 2027 is a decision, and shouting about it every campaign teaches people to stop reading the screen.

What is a breakage is a session where no path declares a cycle: no door leads anywhere, nobody can apply, and nothing would say so.

An alert that fires on a deliberate decision is worse than no alert. It is not neutral — it actively destroys the value of the alerts around it, because the administrator learns that the red text on the session page is usually nothing. The next time it is something, they will not read it either. Every team that has ignored a genuine alarm did so after being trained by a hundred false ones.

So the condition is written to separate the two:

  • Some paths declare a cycle, but not this door’s → the school chose not to open that cycle. Silence. The door is simply not offered to the public.
  • No path declares any cycle → the campaign was loaded before the cycles existed. Nobody can apply at all. That is a breakage, and it is stated.

Getting this right requires knowing the domain, and that is the point: you cannot derive the difference between a decision and a failure from the schema. Both look like an empty result set. The distinction lives in what the school meant, and it has to be written down by someone who asked.

Repairing without inventing policy

def reconcilier(session):
    """
    Une porte dont la voie a disparu passe **au choix** : le candidat désigne
    lui-même sa voie parmi celles qui sont ouvertes.

    C'est la seule réparation qui ne suppose rien — deviner quelle nouvelle
    voie remplace l'ancienne serait inventer une politique d'admission à la
    place de l'école.
    """

Making the door “candidate’s choice” is the only repair that assumes nothing — guessing which new path replaces the old one would be inventing an admission policy in the school’s place.

The tempting automatic repair is a mapping: concours-bac was clearly replaced by licence-l1-direct, so re-point it. It would have worked this time. It is also a piece of software deciding, on the basis of a string resemblance, which candidates are eligible for which programme — and eligibility is a legal act belonging to a public institution, published in an official announcement.

The distinction to hold on to: there is a class of decisions that a system may not make automatically, not because the automation would be unreliable, but because the decision is not the system’s to make. Admissions policy, medical triage, credit refusal, content removal. The correct automated behaviour is to put the decision in front of the person who owns it.

And the repair chosen is not a degraded mode:

"""
Le choix n'est d'ailleurs pas un pis-aller : les conditions d'entrée sont
portées par la voie — diplômes reçus, séries de bac, âge —, et le formulaire
les oppose déjà au parcours déclaré. Une porte au choix est donc aussi
exigeante qu'une porte fléchée, et elle ne se casse pas quand les codes
changent.
"""

Entry conditions are carried by the path — accepted diplomas, baccalaureate streams, age — and the form already checks them against the declared background. A choice-door is therefore as demanding as a signposted one, and it does not break when codes change.

This only works because of a property established elsewhere: the rules live on the path, not on the door. Because eligibility is enforced by the path a candidate ends up on, letting them pick the path loses no rigour. Had the rules been duplicated onto the doors, “let the candidate choose” would have been a security hole rather than a repair.

Which is an argument for putting business rules on the object they describe, made in an unexpected place: it is what determines which recoveries are available to you years later.

Refusing to report a repair that did not happen

for etat in portes_cassees(session):
    porte = etat.porte
    if not porte.code_voie:
        # Rien à réparer ici : la porte ne vise déjà plus de voie. Son cycle
        # est vide, et cela ne se répare pas en code — il faut charger la
        # campagne, ou ouvrir une voie. Le diagnostic le dit à l'agent ;
        # l'effacer d'un « réparée » serait mentir sur son état.
        continue
    porte.code_voie = ""
    porte.save(update_fields=["code_voie"])
    _demander_la_voie(porte)
    reparees.append(porte)
return reparees

Nothing to repair here… marking it “repaired” would be lying about its state.

A door whose cycle is empty is broken for a reason no code change can address — someone has to load the campaign. reconcilier skips it and does not add it to the returned list.

That continue is a small piece of integrity with a large consequence. The function returns what it repaired, and the caller shows that to an administrator. Including a door it did not fix would produce a screen saying “3 doors repaired” above a session where one of them still leads nowhere — and the administrator, reasonably, stops looking.

A repair function must return what it repaired, not what it visited. The temptation to make the number look better is real, and it is exactly how a maintenance tool becomes something nobody trusts.

The actual repair is two operations that must go together:

porte.code_voie = ""
porte.save(update_fields=["code_voie"])
_demander_la_voie(porte)

Clearing the code makes the door choice-based; _demander_la_voie adds the form field the candidate needs in order to express that choice:

ChampInscription.objects.get_or_create(
    type_inscription=porte,
    code=CodeChamp.VOIE,
    defaults={"obligatoire": True, "ordre": 0},
)

get_or_create, so re-running the reconciliation is safe. ordre: 0, so the question appears first — it is the question everything shown afterwards depends on. And obligatoire: True, because a choice-door with an optional path field is the original bug with extra steps: a file opened with no path.

Clearing the code without adding the field would produce a door that opens a form in which the candidate has no way to say how they are entering. The two lines are one operation, and the function name — ask for the path — says so.

What to take away

  1. A soft reference is a foreign key whose integrity check you promised to write. Write it, in a module, with a name.
  2. Separate data defects from invariant violations. Ask who can fix it and whether they are the person looking at the screen.
  3. Model brokenness as a value, not as an exception. Etat.utilisable can be asked by a page, a dashboard and a test; a raise can only be caught.
  4. Write diagnostics for the person who can act, naming the object, the consequence and the next step — and translate them, because they are content.
  5. Do not alert on a decision. An alarm that fires on normal operation trains people to ignore the one that matters.
  6. Automatic repair must not invent policy. When the decision belongs to someone, hand it to them; here that means letting the candidate choose, which costs nothing because the rules live on the path.
  7. Report what you repaired, never what you visited.
Prêt à démarrer ?

Parlons de votre projet

Dites-nous vos besoins en IoT, SIG ou développement sur mesure — nous vous répondons sous 24 h.