Blueprint Append-only¶
Le blueprint append-only version 1 initialise une feature de faits immuables :
mesures, observations, écritures ou audits. Il offre une seule opération,
append, sans adapter de transport implicite.
Choisir Le Bon Modèle¶
| Modèle | Responsabilité | Primitives |
|---|---|---|
| CRUD | Modifier l'état courant d'une entité | Entity, Repository[T] |
| Append-only | Ajouter un fait sans réécrire le passé | ImmutableRecord, AppendOnlyStore[T] |
| Event sourcing | Reconstruire un agrégat depuis ses événements | Hors de ce blueprint |
ImmutableRecord n'hérite pas d'Entity : il ne porte ni version d'optimistic
locking, ni audit de modification, ni soft-delete. Le port n'expose aucun
update, delete, restore, purge ou append_many. Les queries et les
projections de lecture sont des features séparées : elles ne doivent pas ouvrir
une voie de modification des faits enregistrés.
Générer Une Feature¶
| Bash | |
|---|---|
minimal reste le profil par défaut. Le profil append-only crée un modèle
basé sur ImmutableRecord, ses commandes/résultats et port inbound, le use case
d'append, un container, des tests et une documentation locale. new Measurement
measurement-service --profile append-only est le raccourci équivalent.
Pour un record déjà déclaré dans domain/models/, appliquer uniquement la feature :
| Bash | |
|---|---|
Cette seconde forme exige une classe héritant directement de l'ImmutableRecord
d'Arclith, sans redéfinir ses champs techniques ou sa configuration. Les imports
aliasés ou qualifiés sont reconnus sans exécuter le code du projet. Une Entity
mutable est refusée ; inversement, le blueprint CRUD refuse un ImmutableRecord.
Les classes décorées ou les héritages/configurations personnalisés demandent une
composition manuelle explicite.
La feature utilise les erreurs du framework ; aucun fichier de copie des erreurs génériques n'est nécessaire. Les fichiers générés appartiennent au projet. Les tests du profil neuf démontrent l'append idempotent ; pour un modèle qui déclare déjà des champs métier, le test généré vérifie le contrat et la composition sans inventer de valeurs. Ajouter alors ses scénarios métier représentatifs.
Identité Et Temps¶
| Python | |
|---|---|
uuidest un UUIDv7 généré par défaut, ou un UUID valide fourni par l'appelant. Il identifie le fait, pas sa tentative de livraison.occurred_atest obligatoire et représente le temps métier. Il exige un fuseau horaire et est normalisé en UTC.recorded_atvautNoneavant l'append. Le store lui attribue son horodatage UTC au premier enregistrement, même si l'appelant a fourni une autre valeur. Un rejeu retourne l'horodatage initial ; le record d'entrée n'est pas modifié.
La configuration Pydantic frozen interdit la réaffectation des champs. Elle ne
rend pas les listes/dictionnaires imbriqués immuables. Le store mémoire prend une
copie profonde à l'entrée et retourne des copies profondes : modifier une
collection d'entrée, de résultat ou d'inspection ne change pas le fait stocké.
Préférer des tuples et des sous-modèles frozen dans les contrats applicatifs.
Le store vérifie aussi ce contrat à l'exécution : les champs techniques doivent
rester hérités, y compris via une sous-classe intermédiaire. Les UUID et timestamps
invalides introduits par model_construct() ou model_copy(update=...) sont
refusés avant toute écriture, avec une erreur sans valeur sensible.
Composer Et Rejouer¶
Le use case dépend du port outbound AppendOnlyStore[Measurement]. Le container
généré demande explicitement un store à son appelant. Le store append-only n'est
pas un Repository : il n'est ni sélectionné par build_repository() ni installé
par add-adapter --capability repository.
Exemple avec les modules du profil par défaut :
Si vous avez ajouté les champs sensor et value de l'exemple de modèle plus
haut, fournissez-les aussi dans la commande. Un retry doit conserver le même
UUID et le même temps métier, pas reconstruire un record avec un nouvel UUID
automatique. Conserver la même instance de store pour toutes les requêtes d'un
même périmètre d'idempotence ; ne pas en créer une par requête.
| Situation | Résultat |
|---|---|
| Nouvelle clé et nouvel UUID | AppendResult(status="appended", record=...) |
| Même clé, même type de record et même contenu canonique | duplicate, copie du fait initial |
| Même clé, autre contenu ou autre type de record | IdempotencyConflict |
| UUID déjà stocké sous une autre clé | RecordIdentityConflict |
| Clé invalide | InvalidIdempotencyKey |
| Contenu non canonisable ou record non frozen | AppendOnlyError |
| Indisponibilité d'un futur store durable | AppendStoreUnavailable |
Les clés contiennent 1 à 255 caractères, sans espaces périphériques ni caractères
Unicode de catégorie C (contrôles, formats invisibles, etc.). Elles sont traitées
exactement, sans trim ni normalisation implicite. Leur unicité est locale au store.
Les erreurs ci-dessus n'incluent ni clé, ni payload. Aucun payload n'est journalisé
par le store ou le use case généré.
Empreinte Canonique V1¶
arclith.domain.services.append_only.record_fingerprint fournit le calcul partagé :
- Lire tous les champs Pydantic validés du record, y compris ceux déclarés avec
exclude=True, et les éventuels extras. Exclure uniquement lerecorded_atde premier niveau. L'UUID,occurred_atet les données métier sont inclus. - Convertir récursivement les valeurs : dictionnaires à clés chaîne, listes et
tuples vers tableaux, sous-modèles vers leurs champs/extras, enums vers leur
valeur, UUID/date/Decimal fini vers chaînes. Normaliser les datetimes aware en
UTC avec
isoformat(). Conserver null, booléens, entiers, chaînes et floats finis. - Sérialiser avec le JSON Python :
sort_keys=True,separators=(",", ":"),ensure_ascii=True,allow_nan=False. Encoder en UTF-8, puis calculer SHA-256 et préfixer l'hexadécimal parsha256:.
Les alias, serializers de présentation, champs calculés et attributs privés ne
définissent pas le contenu persistant. Un champ masqué par un serializer reste
comparé sur sa valeur validée. Les ensembles, bytes, SecretStr, dates-heures
naïves, nombres non finis et clés de dictionnaire non textuelles sont refusés.
La récursion est bornée à 64 niveaux afin de refuser aussi les cycles. Cette
convention JSON est celle du contrat V1, pas une promesse de canonicalisation
universelle de types Python arbitraires.
Manifeste, Recettes Et Collisions¶
Le schéma de manifeste reste en version 1, sans secret, URI ou instance d'adapter :
| YAML | |
|---|---|
Il est écrit dans .arclith/features/measurement_ingestion.yaml. Avant une
première installation, un fichier applicatif existant, un répertoire cible ou un
parent bloquant annule le plan sans écriture partielle. Les liens symboliques
dans les cibles/parents générés sont refusés. Les __init__.py existants sont
préservés. Le replay complète les fichiers manquants sans écraser ceux du
développeur ; un manifeste incompatible est refusé. Le dry-run n'écrit ni fichiers,
ni manifeste, ni recette. Les recettes historiques sans profil restent minimal.
Limites Et Corrections¶
InMemoryAppendOnlyStore est une référence de test, non durable. Son verrou
protège les coroutines d'une même boucle asyncio : deux appends concurrents de
la même clé ne créent pas deux faits. inspect_records() retourne des copies en
ordre d'insertion ; c'est un outil de test, pas un port de recherche.
Il n'y a ni synchronisation entre threads/processus, ni réplication, ni reprise après redémarrage, ni éviction automatique. Les faits et clés occupent la mémoire tant que le store existe. En production, fournir un store durable avec une contrainte d'unicité et une transaction atomique sur clé, empreinte et fait. Aucun adapter MongoDB/PostgreSQL, transport FastAPI/FastMCP/RabbitMQ/LangGraph, ordre global, batch ou garantie exactly-once distribuée n'est livré dans cette V1.
Une correction est un nouveau fait avec sa propre identité et sa propre clé : compensation, invalidation ou remplacement logique, en référençant le fait initial dans le modèle métier. La projection de lecture interprète cette relation ; l'ancien fait ne doit jamais être écrasé silencieusement.