The complaint came in four words: on va à l’aveugle. We are working blind.
The admissions workflow of ENI-ABT is administrable, which was the whole point of building it that way. A statuses table, a transitions table, a document-states table. Add a step to the process and you add a row — no deployment, no developer.
The cost of that is a workflow that exists nowhere in readable form. It is spread across three tables and nobody can hold it in their head. So you discover its gaps by walking into them:
Moving from “Incomplete” to “Submitted” is not provided for by the workflow. Which is an exact, honest refusal — and useless. A candidate is stuck, an agent is looking at a button that does not work, and nothing anywhere says which way they should have gone.
Why not just draw it
The reflex is a diagram in the wiki. It fails for a reason that is structural rather than lazy:
A hand-drawn diagram would have been wrong within a month: this workflow is administrable, and that is its reason for existing.
A workflow you can change from a screen and a diagram you maintain by hand are mutually exclusive commitments. Whichever one you keep, the other rots — and the one that rots silently is the diagram, because nothing fails when it goes stale. It just gets quietly less true until someone follows it into a wall.
So: generate it. python manage.py schema_admission, writing Markdown with Mermaid blocks.
Markdown-with-Mermaid rather than SVG or a rendered PNG, for one practical reason: it reads as text in an editor, renders as a diagram on the forge, and diffs. A regenerated PNG is one opaque blob replacing another; a regenerated Markdown file shows you, in the diff, exactly which arrow appeared last week.
And the generated file says so about itself, at the top:
Do not correct this file by hand: correct the database and regenerate it. Any generated artefact that lives in a repository needs that sentence, because somebody will otherwise fix a typo in it and be baffled when the fix vanishes.
Two functions nobody wants to write
Before anything interesting, the boring part that makes the output valid:
Mermaid is a language, and a status label typed by a registrar is untrusted input to it. A label containing [ or a quote does not produce a wrong diagram; it produces a parse error, which renders as a red box where the workflow should be. Since the labels are administrable, that failure is one careless Dossier [en attente] away, and it would be blamed on the diagram rather than on the label.
Two three-line functions is the entire mitigation, and it is worth naming the general shape: anything you generate in a syntax needs escaping at the boundary, even when the syntax is “just documentation”.
The circuit, and the requirements on the arrows
The state diagram is a straight walk over two ordered querysets:
What makes it useful rather than decorative is that the arrow labels carry the preconditions, not just the action name:
Those three booleans are exactly the ones the guard enforces at runtime:
Same three fields, read by the guard and by the generator. That is the property that keeps this documentation honest: it is not a description of the rules, it is a rendering of them. There is no version of the diagram that can disagree with the code, because both read the same rows.
Statuses get two markers, and the legend is one line:
depot_ouvert is a real field with real consequences — it opens uploading in the candidate’s area — and putting it on the diagram means an administrator can see, at a glance, every status from which a candidate still has a say. That turns out to matter a great deal two sections down.
The transition that starts from anywhere
One modelling decision needs special handling when drawn:
A null depuis means from any status — that is how “cancel the application” is declared once instead of eleven times. Drawing it faithfully would mean an arrow from every node to one node, which is a hairball.
Final statuses are excluded, and so is the target itself. Both exclusions mirror the runtime: a final status is terminal, and changer_statut returns early when the new status equals the current one.
The resolution order in the guard is worth quoting alongside, because it is the subtle half of this feature:
An explicit transition beats the catch-all. Without nulls_last=True, a general “cancel from anywhere” rule with no requirements would shadow a specific “cancel from accepted” rule that demands a reason — and the reason would stop being asked for, silently, with no error anywhere.
The section that earns the command
Sections 1 to 3 draw what is there. Section 4 is the one that changes how the tool is used:
A workflow reads badly; its dead ends read worse. These were discovered by walking into them, one stuck candidate at a time.
Two checks. The first is a genuine invariant of this domain:
Read that condition as a sentence: this status invites the candidate to upload documents, and offers them no way to resubmit afterwards. That is a trap with a welcome mat. It is not detectable by looking at either table alone — you need depot_ouvert from one and the absence of a row in the other — which is exactly why nobody found these by reading the admin screens.
The second check is structural:
Statuses no transition leads to. They exist in the table, they show up in filter dropdowns, and no action in the application can put a file into one.
And here is the sentence I would keep from the whole module:
Either they are decided somewhere other than the workflow — at the deliberation, for instance — or a transition is missing.
The generator does not know which. It cannot: distinguishing “deliberately set outside the workflow” from “we forgot an arrow” requires knowing intent, and intent is not in the database. So it reports the fact and names both readings.
That restraint is what makes the section trustworthy. A checker that guessed — that called every unreachable status a bug — would cry wolf on the statuses set by the admissions board, and within two regenerations everyone would learn to skip section 4. The comment in the source says it plainly:
Telling them apart requires knowing the intent; flagging them only requires being able to read. Do the part you can do correctly, and hand the judgement to the person who has the context.
The no-findings branch is not silence either:
A section that disappears when it finds nothing is indistinguishable from a section that failed to run. Stating what was checked and found clean is what makes the absence of findings meaningful.
Drawing what is closed
The same principle runs through section 1, which maps public entry points to admission tracks:
Closed tracks are drawn as trapezoids and named in prose. Omitting them would produce a cleaner diagram and a worse one: this track exists and is closed and this track does not exist are different situations with different fixes, and a diagram that renders them identically is actively misleading during a campaign where somebody is asking why nobody is applying through a given route.
The edges are only drawn for open tracks, so the picture stays a picture of what works — while the text below it accounts for the rest.
Documentation as a design note
Section 5 does something a generated file does not usually do: it states what is missing for a feature that has not been built.
The rendered output carries the same reasoning:
This is a design note pinned to the table that motivates it, and regenerated next to the current state of that table. Put the same paragraph in a wiki page and it drifts from the data it describes; put it here and it is re-emitted beside the evidence every time somebody runs the command.
There is a limit to that trick, and it is honest to state it: the paragraph itself is hard-coded in the generator, so it does not update when the reality changes. If somebody adds document requirements to a door tomorrow, the “what is missing” text will still claim they are missing. What the generator guarantees is that the paragraph is read next to the current table — not that it is correct. That is a real improvement over a wiki, and less than it looks.
The small things that keep it readable
A diagram with two hundred nodes is not documentation, it is a stress test for the renderer. Truncating is the right call and the number should be a named constant with that sentence next to it, so the next person raising it knows what they are trading away.
Documents demanded of one specific person during their case review are counted separately and deliberately kept out of the per-track table:
Mixing them in would suggest an entire cohort is being asked for a residence certificate. The distinction costs one count() and a paragraph, and it is the difference between a table that can be acted on and one that has to be double-checked.
What carries over
- If the thing is administrable, its documentation must be generated. The two commitments cannot both be kept by hand, and the diagram is the one that rots without failing.
- Generate a text format that diffs. Markdown with Mermaid renders on the forge and shows you which arrow appeared this week; a PNG shows you that a PNG changed.
- Escape at the boundary, even for documentation. Administrable labels are untrusted input to whatever syntax you emit.
- Render the guard’s own fields onto the diagram. Reading the same rows as the runtime is what makes the picture impossible to falsify.
- Report what has no path, and refuse to guess why. Name both readings; the person with the context will pick. A checker that guesses gets ignored.
- Say “nothing found” out loud. A section that vanishes on success looks exactly like a section that crashed.