Back to flin
flin

Chaque entité se souvient de tout : le modèle temporel

Comment nous avons conçu le modèle temporel de FLIN pour que chaque entité suive automatiquement son historique complet -- zéro configuration, zéro code répétitif, voyage dans le temps inclus.

Juste A. Gnimavo (Thales) & Claude | March 26, 2026 12 min flin
EN/ FR/ ES
flintemporalversioningmemory-native

La plupart des langages de programmation traitent les données comme jetables. Vous mettez à jour un enregistrement, et l'ancienne valeur disparaît. Vous supprimez une ligne, et elle est partie pour toujours. Si vous voulez un historique, vous le construisez vous-même : tables d'audit, middleware de changelog, frameworks d'event sourcing, colonnes de version avec des triggers. Des centaines de lignes de code d'infrastructure avant de pouvoir répondre à la question la plus simple : « Quelle était cette valeur hier ? »

Nous avons décidé que FLIN serait différent. Chaque entité, dès l'instant de sa création, se souvient de chaque état qu'elle a jamais connu. Pas comme une fonctionnalité optionnelle. Pas comme un plugin. Comme la nature fondamentale des données dans le langage lui-même.

Voici l'histoire de la conception et de la construction du modèle temporel de FLIN -- la philosophie derrière, l'architecture qui le fait fonctionner, et pourquoi nous croyons que le « memory-native » est l'avenir du développement d'applications.

Le problème que nous rencontrions sans cesse

Avant FLIN, chaque application que Thales construisait chez ZeroSuite tombait sur le même pattern. Un client comptable appelait : « Le solde était faux mardi dernier, mais maintenant il est différent -- que s'est-il passé ? » Un enseignant sur Déblo signalait : « La note de mon élève a changé et je ne sais pas quand. » Un utilisateur sur n'importe quelle plateforme écrasait accidentellement des données sans aucun moyen de les récupérer.

La solution était toujours la même : construire une piste d'audit. Ajouter une table _history. Écrire des triggers. Câbler un middleware. Tester le middleware. Découvrir que le middleware rate des cas limites. Corriger les cas limites. Répéter pour chaque entité du système.

Après avoir construit des pistes d'audit pour la cinquième fois, le pattern est devenu évident. Cela ne devrait pas être du code applicatif. Cela devrait être de l'infrastructure. Mieux encore, cela devrait être le langage lui-même.

Le principe de conception : temporel à configuration zéro

Le principe de conception central était la simplicité radicale. Si vous définissez une entité dans FLIN, elle suit l'historique. Point. Pas d'annotations. Pas de configuration. Pas d'opt-in.

flinentity User {
    name: text
    email: text
}

user = User { name: "Juste", email: "[email protected]" }
save user

user.name = "Juste Gnimavo"
save user

// Both versions exist. Always.
current_name = user.name          // "Juste Gnimavo"
old_name = (user @ -1).name       // "Juste"

C'est toute l'API. Définissez votre entité. Sauvegardez-la. Mettez-la à jour. Sauvegardez-la à nouveau. Chaque état précédent est préservé et interrogeable. Le développeur écrit zéro code supplémentaire. Il n'y a pas d'annotation @Versioned, pas d'appel enable_history(), pas de migration pour ajouter une table d'historique. Le langage s'en charge.

Nous avons appelé cela « memory-native » parce que la mémoire n'est pas une fonctionnalité boulonnée sur le modèle de données de FLIN -- c'est le modèle de données. Tout comme les langages à ramasse-miettes ont libéré les développeurs de la gestion manuelle de la mémoire, le modèle temporel de FLIN libère les développeurs de la gestion manuelle de l'historique.

Comment fonctionne le versionnage automatique

Chaque opération save crée une nouvelle version. Le runtime maintient une chronologie de versions pour chaque instance d'entité :

Version 0         Version 1         Version 2         Current
-------------------------------------------------------------------------->

+------------+   +------------+   +------------+   +------------+
| price: 10  |-->| price: 12  |-->| price: 15  |-->| price: 15  |
| 10:00 AM   |   | 2:00 PM    |   | 5:00 PM    |   | (now)      |
+------------+   +------------+   +------------+   +------------+

product @ -3     product @ -2     product @ -1     product

Chaque version stocke l'état complet des champs plus des métadonnées : un numéro de version, un horodatage et l'identifiant d'entité. Les versions précédentes sont immuables -- on ne peut pas modifier l'historique, seulement le lire.

L'implémentation utilise un modèle de stockage bitemporel. Chaque version a un horodatage valid_from (quand cette version est devenue active) et un horodatage valid_to (quand elle a été remplacée). La version courante a valid_to = NULL. Cette structure rend les requêtes par plage temporelle efficaces : pour trouver l'état à un moment donné, le runtime cherche la version dont le valid_from est au moment cible ou avant et dont le valid_to est après (ou NULL).

rust// How `user @ yesterday` translates to a database query
SELECT * FROM users
WHERE id = ?
  AND valid_from <= '2026-12-30T00:00:00Z'
  AND (valid_to IS NULL OR valid_to > '2026-12-30T00:00:00Z')
ORDER BY version DESC
LIMIT 1

Ce n'est pas une requête que le développeur écrit. Le compilateur FLIN la génère à partir de la syntaxe de l'opérateur @. Le développeur écrit user @ yesterday. Le compilateur fait le reste.

L'architecture : six couches de profondeur

Faire en sorte que l'accès temporel ressemble à une primitive du langage a nécessité des changements à chaque couche du compilateur et du runtime de FLIN. Ce n'était pas une fonctionnalité qu'on pouvait boulonner à la fin -- elle devait être tissée dans le tissu du langage, du lexeur à la base de données.

Couche 1 : Lexeur. Le jeton @ a été ajouté au lexeur aux côtés de sept mots-clés temporels : now, today, yesterday, tomorrow, last_week, last_month, last_year. Ce sont des jetons de première classe, pas des littéraux de chaîne ou des fonctions de bibliothèque.

Couche 2 : Analyseur syntaxique. L'analyseur reconnaît entity @ temporal_reference et construit un noeud AST Expr::Temporal. La référence temporelle peut être un entier négatif (version relative), une chaîne de date (temps absolu) ou un mot-clé.

Couche 3 : Vérificateur de types. Le vérificateur de types valide que le côté gauche de @ est un type d'entité et le côté droit est un entier, un temps ou un texte (pour les chaînes de date). Il retourne un type d'entité optionnel parce que la version demandée pourrait ne pas exister.

rust// Type checker validation for @ expressions
match &time_ty {
    FlinType::Int | FlinType::Time | FlinType::Text => {}
    _ => return Err(TypeError::with_hint(
        format!("Expected time reference, found {}", time_ty),
        span,
        "Use a version number like -1, a time keyword like yesterday, \
         or a date string like \"2024-01-15\"",
    )),
}

Couche 4 : Générateur de code. Le générateur de code émet un bytecode différent selon le type de référence temporelle. L'accès par version relative émet OpCode::AtVersion. Les mots-clés temporels émettent OpCode::AtTime avec un octet de code temporel. Les chaînes de date émettent OpCode::AtDate.

Couche 5 : Machine virtuelle. La VM gère chaque opcode temporel en consultant l'historique des versions de l'entité et en trouvant la version correspondante. Pour l'accès relatif, elle indexe directement. Pour l'accès basé sur le temps, elle effectue une recherche binaire sur la liste de versions ordonnée par horodatage.

Couche 6 : Base de données (ZeroCore). La couche de stockage maintient une carte d'historique de versions indexée par (entity_type, entity_id). Chaque entrée est un vecteur de structs EntityVersion ordonnées par horodatage. La méthode get_history() retourne la chronologie complète ; la méthode find_at_version() retourne un instantané spécifique.

rust/// Historical version of an entity for time-travel queries
#[derive(Debug, Clone)]
pub struct EntityVersion {
    pub version: u64,
    pub timestamp: i64,
    pub fields: HashMap<String, Value>,
}

Pourquoi pas l'event sourcing ?

La comparaison évidente est l'event sourcing, et nous l'avons envisagé. En event sourcing, on stocke une séquence d'événements (UserNameChanged, UserEmailUpdated) et on reconstruit l'état en les rejouant. Le modèle temporel de FLIN est différent : nous stockons des instantanés complets à chaque version.

Le compromis est délibéré. L'event sourcing est puissant pour les systèmes où les événements eux-mêmes sont le modèle de domaine principal -- transactions financières, workflows de commandes, édition collaborative. Mais pour la majorité des applications, les développeurs ne pensent pas en événements. Ils pensent en états : « Quel était le nom de cet utilisateur mardi dernier ? » Répondre à cette question dans un système event-sourcé nécessite de rejouer chaque événement jusqu'à ce point. Dans FLIN, c'est une seule recherche indexée.

Le versionnage par instantanés simplifie aussi le modèle de programmation. Il n'y a pas de concept de « projections » ou de « gestionnaires d'événements » ou d'« orchestrateurs de saga ». Vous sauvegardez une entité. Vous interrogez une version passée. C'est tout.

Le coût de stockage est plus élevé -- chaque version stocke une copie complète de tous les champs, pas seulement le delta. Nous adressons cela avec des politiques de rétention planifiées qui compactent les anciennes versions, et en pratique, la commodité de requêtes ponctuelles instantanées l'emporte largement sur le stockage supplémentaire pour les applications que FLIN cible.

Métadonnées de version : pas seulement des données, mais du contexte

Chaque version porte des métadonnées que le développeur peut accéder sans aucune API spéciale :

flinuser = User.find(id)
print(user.version_number)    // 5
print(user.created_at)        // 2026-12-31T14:30:00Z
print(user.updated_at)        // 2026-12-31T18:45:00Z

Ces métadonnées sont automatiquement remplies par le runtime. Quand vous itérez sur l'historique d'une entité, chaque version de la liste a ses propres métadonnées :

flin{for ver in product.history}
    <div class="audit-entry">
        <p>Version #{ver.version_number}</p>
        <p>Created: {ver.created_at}</p>
        <p>Price: ${ver.price}</p>
    </div>
{/for}

Construire une piste d'audit -- la fonctionnalité qui nécessitait autrefois une table dédiée, des triggers et une UI séparée -- est maintenant une boucle {for}.

La garantie d'immuabilité

Une décision de conception que nous avons longuement débattue était l'immuabilité. Pouvait-on modifier une version passée ? Notre réponse a été un non inconditionnel.

flinold_user = user @ -1

// This affects the CURRENT version only
user.name = "New Name"
save user

// Old version unchanged
print((user @ -2).name)       // Still "Juste"

Permettre la mutation des versions historiques saperait chaque cas d'usage que le modèle temporel permet. Les pistes d'audit deviennent insignifiantes si quelqu'un peut réécrire l'historique. Les exigences de conformité demandent des enregistrements immuables. Le débogage devient impossible si les états passés peuvent changer sous vos pieds.

La seule façon de « changer » l'historique est par le mot-clé destroy, qui supprime définitivement une entité et toutes ses versions -- une action délibérée et administrative équivalente au « droit à l'oubli » du RGPD. Nous couvrons cela en détail dans l'article 049.

Cas d'usage concrets

Le modèle temporel n'est pas un exercice académique. Il permet des fonctionnalités produit concrètes avec un minimum de code.

Historique des prix. Les applications e-commerce peuvent afficher l'évolution des prix sans table d'historique de prix séparée :

flinentity Product {
    name: text
    price: money
}

<div class="price-history">
    {for version in product.history.last(10)}
        <div class="price-point">
            <span>{version.created_at.format("MMM DD")}</span>
            <span>{version.price}</span>
        </div>
    {/for}
</div>

Fonctionnalité d'annulation. N'importe quelle entité peut être ramenée à son état précédent :

flinfn undo(entity) {
    previous = entity @ -1
    if previous {
        entity.name = previous.name
        entity.status = previous.status
        save entity
    }
}

Conformité et audit. Chaque changement de champ est enregistré avec un horodatage, satisfaisant les exigences réglementaires sans infrastructure supplémentaire.

Récupération de données. Les changements accidentels sont toujours récupérables parce que la version précédente existe encore.

Considérations de performance

Le modèle temporel ajoute une surcharge -- il n'y a pas lieu de le nier. Chaque opération de sauvegarde écrit deux enregistrements au lieu d'un (la nouvelle version et l'entrée d'historique). Chaque entité consomme plus de stockage à mesure que les versions s'accumulent.

Nous avons atténué cela avec plusieurs choix de conception :

OpérationComplexitéNotes
Version couranteO(1)Indexée par id + valid_to=NULL
Version spécifiqueO(log n)Indexée par id + version
À un moment donnéO(log n)Indexée par id + valid_from
Historique completO(n)Retourne toutes les versions

L'accès à la version courante -- l'opération qui se produit à chaque chargement de page -- est O(1). On ne paie le coût de l'historique que lorsqu'on l'interroge réellement. Et pour l'annotation @retention planifiée, les développeurs pourront définir des politiques de rétention par entité :

flinentity Metric {
    value: number

    @retention(90.days)     // Keep 90 days of history
}

Ce que nous avons construit en session 012

L'infrastructure temporelle initiale a été construite en session 012, un sprint de cinquante minutes qui a ajouté le struct EntityVersion, la carte d'historique de versions dans la VM, les opcodes de requête temporelle et les méthodes d'élagage de l'historique de versions. Dix nouveaux tests. Trois cent un tests passant au total.

Cette session a établi le squelette. Les vingt sessions suivantes ajouteraient le muscle : corriger le stub AtTime pour que les mots-clés fonctionnent réellement, implémenter la propriété .history, atteindre cent pour cent de couverture de tests, et ajouter la suite complète de fonctions de comparaison temporelle.

Le modèle temporel était la fonctionnalité la plus ambitieuse de la conception de FLIN. Il touchait chaque couche du compilateur. Il nécessitait de repenser comment les données circulent dans l'ensemble du système. Et il a tenu une promesse qu'aucun autre langage que nous connaissions ne fait : chaque entité se souvient de tout, automatiquement, pour toujours.


Ceci est la partie 1 de la série sur le modèle temporel de « How We Built FLIN », documentant la conception et l'implémentation du système de données memory-native de FLIN.

Navigation de la série : - [046] Every Entity Remembers Everything: The Temporal Model (vous êtes ici) - [047] Version History and Time Travel Queries - [048] Temporal Integration: From Bugs to 100% Test Coverage - [049] Destroy and Restore: Soft Deletes Done Right - [050] Temporal Filtering and Ordering - [051] Temporal Comparison Helpers - [052] Version Metadata Access - [053] Time Arithmetic: Adding Days, Comparing Dates - [054] Tracking Accuracy and Validation - [055] The Temporal Model Complete: What No Other Language Has

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles

Thales & Claude deblo

Le Step Zero ne suffisait pas : comment valider un constructeur sans valider le runtime a fait tomber toutes les sessions vocales de Déblo l’heure où nous avons livré le streaming caméra temps réel

La phase 14 a livré Déblo Eyes — streaming caméra temps réel via LiveKit vers Gemini Live native audio. Le premier deploy a fait tomber toutes les sessions vocales en production en quatre-vingt-dix secondes parce que notre Step 0 avait validé le constructeur sans exercer le runtime. Le build log de comment Déblo a eu des yeux, ce qu’un pré-vol incomplet a coûté, et quels points de polish ont été livrés ou reportés.

33 min May 20, 2026
debloclaude-opus-4.7claude-codegemini-live +25
Thales & Claude deblo

Le tiret cadratin qui a tué la production : comment un slogan marketing dans un header HTTP a fait tomber le chat de Déblo pendant 24 heures

Deux jours avant la soumission App Store, tout le produit chat de Déblo s’est cassé silencieusement. Pas de spinner, pas de toast, aucune erreur dans l’UI — juste un silence radio. L’incident de 24 heures se résumait à un seul « é » dans la valeur d’un header HTTP qui levait une UnicodeEncodeError avant qu’aucune requête vers OpenRouter ne quitte le backend. Post-mortem d’une fausse hypothèse, d’une trace Sentry, et d’un fix de six lignes qui a débloqué le lancement.

30 min May 19, 2026
debloclaude-opus-4.7claude-codeincident +19
Thales & Claude deblo

Six heures, d’une page blanche à la review Apple — Comment nous avons soumis Déblo à l’App Store, en direct

Marche à marche en direct de la soumission de Déblo à l’App Store iOS en six heures : ce que les validateurs d’Apple ont rejeté (un superscript Unicode), ce que nous avons corrigé (un Promotional Text gaspillé sur des marques tierces), et les rouages de l’ASO iOS que presque tout le monde rate.

30 min May 13, 2026
debloclaude-opus-4.7claude-codeapp-store +16