info@altius-group.ch
Froideville, Vaud
EN

The third verdict — rules that check without deciding

/ 14 min read / updated 04.09.2026

Any application that checks whether a case satisfies a set of rules reaches the same fork, usually without noticing. The rule engine returns pass or fail. Two values, a boolean, green or red.

And then the first real case arrives: the applicant did not declare their baccalaureate stream. Not because they do not have one — it is in the scanned certificate in their file — but because the field was optional and they skipped it.

With two verdicts, that is a fail. The applicant is rejected by a system that did not lack a qualification, only a form field.

This is the eligibility check in the admissions platform of ENI-ABT, an engineering school whose entry requirements are published in an official announcement, change every year, and carry legal weight. It is about eighty lines of rules, and almost every design decision in it is a decision about what the software is allowed to conclude.

The rules are not in the code

"""
Le dossier remplit-il les conditions de la voie qu'il vise ?

C'est la question que pose l'instruction, et elle n'a pas la même réponse selon
le niveau : une licence se juge sur la série du baccalauréat et l'année
d'obtention, un master sur un diplôme du supérieur, une licence professionnelle
sur un BTS ou un DUT de la même spécialité. Les conditions ne sont écrites nulle
part dans le code — elles vivent sur la voie, où l'école les règle — et c'est ce
qui permet d'ajouter une voie sans toucher à ce module.
"""

The conditions are written nowhere in the code — they live on the admission path, where the school sets them — and that is what allows a new path to be added without touching this module.

The rules are rows: voie.diplomes_acceptes, voie.series_acceptees, voie.age_maximum, voie.accepte_en_cours. The module reads them.

This is the standard argument for data-driven rules and it is right, but the reason here is sharper than “flexibility”. The entry conditions are a published legal act. They are announced by the school, they apply to a specific competition, and they are occasionally amended after publication. A rule that lives in a Python file can only be changed by a deployment — which means the school’s own admission policy is gated on a developer being available, and the history of who changed what is in a git log that no registrar can read.

The test for whether a rule belongs in code or in data is not how often it changes. It is: who is allowed to change it? If the answer is not “an engineer”, the rule is data.

Three verdicts, and the third is the point

#: Les trois verdicts. Portés par une chaîne plutôt qu'un booléen : le gabarit
#: en tire directement sa couleur, et deux booléens auraient laissé passer la
#: combinaison impossible « satisfait et à vérifier ».
SATISFAIT = "satisfait"
MANQUE = "manque"
A_VERIFIER = "a-verifier"

Satisfied, missing, and to be checked.

The docstring explains where the third one comes from:

"""
D'où trois verdicts et non deux. « Satisfait » et « non satisfait » quand la
donnée permet de conclure ; **« à vérifier »** quand elle manque, parce qu'un
renseignement absent n'est pas un manquement — c'est précisément ce que l'agent
doit aller regarder dans les pièces.
"""

An absent piece of information is not a failing — it is precisely what the officer must go and look for in the documents.

This is the distinction that a boolean cannot express, and it is not a niche one. Every checking system that reads user-supplied data has three states, whether or not it models them:

  • The rule is satisfied. The data says so.
  • The rule is not satisfied. The data says so.
  • The data does not say. Nothing is known.

Collapsing the third into the second means every gap in your input becomes an adverse finding. Collapsing it into the first means every gap becomes a silent pass. Both are wrong, and which one is worse depends only on whether your software is rejecting people or admitting them.

Note the reason given for a string rather than two booleans, because it is a general technique:

two booleans would have allowed the impossible combination “satisfied and to be checked”.

Two booleans encode four states for a domain that has three. The fourth is unreachable by correct code and reachable by incorrect code — and it will be reached, by a partial update, a serialiser default, or a template that sets one flag and forgets the other. A single field with three values makes the invalid state unrepresentable, which is strictly stronger than making it unreached.

And the string doubles as the CSS class, so the template does not re-derive the verdict it was already given. A template that recomputes "red" if not ok and not pending is a second implementation of the rule, in a language with no tests.

What a criterion is

@dataclass(frozen=True)
class Critere:
    """Une condition de la voie, et ce que le dossier y répond."""

    libelle: str
    #: Ce que la voie demande, dit en clair.
    attendu: str
    #: Ce que le dossier porte. Vide quand rien n'est déclaré.
    constate: str
    verdict: str

Four fields, and the pair attendu / constateexpected / observed — is what makes the output usable.

A verdict alone is an assertion the reader has to trust. Expected: S, TSE, TSEXP. Observed: TSECO. is an argument the reader can check, in one line, without opening the file. The officer disagreeing with the software can see immediately why it concluded what it did, which is the difference between a tool and an oracle.

Both are strings, resolved with str(_(...)) at construction. Not lazy objects, not model instances: the criterion is a statement made at a point in time, and it gets serialised, cached, and rendered in contexts that may not have the same active language. Resolving at construction pins the sentence to the moment the examination happened.

A rule that does not apply is absent, not green

This is the design decision I would most want to transplant into other codebases.

def examiner(dossier):
    """
    Les critères varient d'une voie à l'autre — c'est tout l'objet : la série
    du bac ne se demande qu'à qui entre par le bac, l'âge qu'aux voies qui en
    opposent un. Une condition sans objet n'est pas rendue « satisfaite », elle
    est absente : une ligne verte qui ne veut rien dire use l'attention aussi
    sûrement qu'une ligne rouge de trop.
    """
    voie = dossier.voie
    lignes = _parcours(dossier)
    criteres = [
        _critere_diplome(voie, lignes),
        _critere_serie(voie, lignes),
        _critere_obtention(voie, lignes),
        _critere_age(voie, dossier),
        _critere_pieces(dossier),
    ]
    return [critere for critere in criteres if critere is not None], lignes

A condition with no object is not rendered “satisfied” — it is absent: a green line that means nothing wears out attention as surely as one red line too many.

Each _critere_* function returns None when the rule does not apply to this path, and the list comprehension drops them.

The alternative — returning SATISFAIT for an inapplicable rule — is what most implementations do, because it keeps the output shape uniform and the template simple. It produces a screen where a master’s application shows a green “Baccalaureate stream: satisfied” line, which is meaningless: the path never asked. Do that for five rules across a dozen paths and the officer is reading eleven green lines to find the two that were actually evaluated.

The claim being made is about attention, and it is worth stating as a rule: every line on a decision screen must cost the reader something to have been put there. Green noise is not free. It is a tax on finding the red, and it is paid on every file, all day.

The four criteria that can vanish, and why each one may not apply:

def _critere_serie(voie, parcours):
    """La série du baccalauréat, quand la voie en restreint la liste."""
    attendues = list(voie.series_acceptees.all())
    if not attendues:
        return None

The path does not restrict streams. There is no rule, so there is no line.

def _critere_age(voie, dossier):
    """La limite d'âge, quand la voie en oppose une."""
    if not voie.age_maximum:
        return None

No age limit on this path. Same reasoning.

And the subtlest one:

def _critere_obtention(voie, parcours):
    """
    Une voie qui accepte une formation en cours — le bac de l'année, qui se
    délivre après le concours — ne peut pas opposer l'absence d'attestation.
    Les autres, si : c'est la différence entre entrer par le baccalauréat et
    entrer sur titre.
    """
    if voie.accepte_en_cours or not parcours:
        return None
    ...
    if obtenus:
        return None

The candidate sitting this year’s baccalaureate cannot hold the certificate: it is issued after the competition. A path that admits them must not be able to hold its absence against them; a path that admits on an existing qualification must. One flag on the path, accepte_en_cours, separates the two, and the docstring names the real-world distinction — entering by the baccalaureate versus entering on a qualification — rather than describing the boolean.

This is a temporal impossibility encoded correctly, and it is the kind of rule that a generic engine cannot express. Someone had to know how the school works.

Checking the qualification, not its level

def _critere_diplome(voie, parcours):
    """
    Comparé sur le diplôme lui-même et non sur son niveau : c'est la voie qui
    énumère ce qu'elle accepte, et une licence professionnelle qui reçoit un DUT
    ne reçoit pas pour autant n'importe quel bac+2. Le niveau sert au tri des
    nomenclatures, pas à l'admission.
    """

Compared on the qualification itself and not on its level: a professional bachelor’s that accepts a DUT does not thereby accept any two-year qualification. Level is for sorting nomenclatures, not for admission.

There is a niveau field on the qualification. It is the obvious thing to compare — if candidate.level >= path.required_level is one line and reads like policy. It is not policy. A professional bachelor’s in civil engineering accepts a DUT in civil engineering; it does not accept every qualification that happens to sit at the same level in the national framework.

The general trap: an ordinal field invites a comparison the domain never authorised. The level exists to sort a dropdown. Sorting and eligibility are different relations over the same set, and the presence of <= on the type is not evidence that the domain has an order.

So the comparison is set membership, on primary keys:

cles = {d.pk for d in attendus}
portes = [ligne for ligne in parcours if ligne.diplome_id in cles]

ligne.diplome_id, not ligne.diplome.pk — no query per row, because _parcours already did the select_related. A set of primary keys, an in. The rule is exactly as expressive as the school’s list, and no more.

And when nothing matches, the criterion still reports what the candidate does have:

return Critere(
    libelle=str(_("Diplôme requis")),
    attendu=noms,
    constate=", ".join(
        sorted({ligne.diplome.libelle for ligne in parcours if ligne.diplome_id})
    ) or str(_("Aucun diplôme reconnu")),
    verdict=MANQUE,
)

The officer sees the accepted list and the declared list side by side. Nine times out of ten the eye lands immediately on an equivalent foreign qualification, and the officer overrides — which is exactly the outcome the module was designed to make possible.

The module does not decide

"""
Ce module ne décide pas. Il rapproche ce que la voie exige de ce que le candidat
a déclaré, et rend un relevé que l'agent lit d'un coup d'œil. La décision reste
la sienne : un diplôme étranger équivalent, une dérogation, un dossier
particulier — l'application ne peut pas trancher cela, et prétendre le faire
conduirait à rejeter automatiquement des candidats recevables.
"""

This module does not decide. The decision remains the officer’s: an equivalent foreign qualification, a waiver, a particular case — the application cannot settle these, and pretending to would automatically reject admissible candidates.

examiner returns (criteres, parcours). It does not return a boolean. There is no est_eligible. Nowhere does the code reach a conclusion, and there is no function a future caller could accidentally use as one — which is the practical form the principle has to take, because the moment is_eligible() exists, something will filter on it.

The argument is not that the software would be inaccurate. It would be accurate on the ninety-five percent of files where the declared data is complete and ordinary. The argument is about the other five percent, and about who bears the cost of being in it: a candidate with a qualification from a neighbouring country, a candidate with a waiver, a candidate whose file is unusual for a reason the school has seen before and the schema has not.

Automating the ninety-five percent is worth a great deal. Automating it in a way that silently disposes of the five percent is not the same feature, and the difference between them is entirely in whether the system produces a verdict or a statement.

The bound on how much it will read comes from the same instinct:

#: Borne de lecture du parcours. Un candidat déclare un bac, parfois un BT,
#: parfois deux années de faculté ; au-delà, c'est une saisie qui a dérapé et
#: l'écran n'a pas à la dérouler.
NB_PARCOURS_MAX = 20

Twenty prior qualifications, with the reason for the number written next to it — not a defensive [:1000], but an observation about what a real applicant declares. Above it, the data is wrong, and rendering two hundred rows helps nobody.

The bug worth showing

constate=str(
    _("%(valides)s validées sur %(attendues)s")
    # Les obligatoires des deux côtés. Le constat comptait toutes les
    # pièces et le verdict les seules obligatoires : la ligne pouvait
    # donc annoncer « 5 validées sur 7 » et « satisfait » d'un même
    # souffle, ce qui fait douter de l'un comme de l'autre.
    % {"valides": dossier.pieces_exigees_validees,
       "attendues": dossier.pieces_exigees}
),
verdict=SATISFAIT if dossier.complet else MANQUE,

The observation counted all documents and the verdict counted only the mandatory ones: the line could therefore announce “5 validated out of 7” and “satisfied” in the same breath, which makes you doubt both.

The verdict was correct. The count was correct. They counted different sets, so together they were incoherent — and the officer, reading a line that contradicts itself, does not know which half to believe and therefore stops believing the screen.

That is the specific damage worth naming: an internally inconsistent display costs you more than a wrong one. A wrong number is a bug someone reports. A line that argues with itself teaches the reader that the tool is unreliable in ways they cannot enumerate, and no fix afterwards fully buys that back.

The fix is a habit rather than a patch: whenever a display shows a summary and a judgement, they must be computed from the same set, on the same line, where a reader can see that they are.

Finally, the criterion that is deliberately redundant:

def _critere_pieces(dossier):
    """
    Rappelé ici bien qu'il figure ailleurs sur l'écran : l'agent qui relit les
    conditions d'accès veut savoir en même temps si le dossier est complet, et
    le lui faire chercher plus bas est précisément ce qui fait quitter la page.
    """

Document completeness already appears elsewhere on the page. It is repeated here because the question “can I decide on this file?” has two halves — does the candidate qualify, and is the file complete — and separating them across a scroll turns one decision into two visits.

Deduplication is a good instinct about code and a poor one about interfaces. The same fact shown in two places is a repetition; the same fact needed at two moments is two requirements.

What to take away

  1. Rules whose owner is not an engineer belong in data. Frequency of change is not the test; authority is.
  2. Three verdicts, not two. Missing data is not a failure — it is the thing the human is there to go and look up.
  3. One field with three values, not two booleans. Make the invalid state unrepresentable rather than merely unreached.
  4. Report expected and observed together. A verdict is an assertion; a comparison is an argument the reader can check.
  5. Drop inapplicable rules; do not mark them green. A meaningless green line costs attention on every single file.
  6. Do not let an ordinal field become an eligibility rule. Sorting and qualifying are different relations.
  7. Return a statement, not a decision, wherever the remaining few percent are people. Do not even provide the function that would be misused.
Ready to get started?

Let's discuss your project

Tell us about your needs in IoT, GIS, or custom development — we'll get back to you within 24h.