Aller au contenu

Projet indépendant de R&D · Cologne

Python et FastAPI

Un monolithe modulaire avec des frontières typées, des contrats d'import, une piste d'audit en ajout seul dans PostgreSQL et les règles comme données, avec des exemples de référence testés.

Non normatif

Version du Companion
1.0
Se rapporte à COADF Core
2.2
Statut
À jour
Dernière relecture
Version du profil
1.0
Exemples de code
Exemples de référence illustratifs

Propriétés d'architecture traitées

  • P-1Une frontière typée et validée autour de l'appel au modèle ; dans cette mise en œuvre de référence, une valeur faisant autorité n'existe qu'après vérification par une personne nommée.
  • P-4Une identité d'audit à côté du contexte OpenTelemetry ; une piste en ajout seul dans PostgreSQL dont les écritures absorbent une nouvelle tentative identique et refusent une tentative en conflit.
  • P-5La méthode d'extraction et l'emplacement dans la source accompagnent chaque valeur proposée et survivent à la sérialisation.
  • P-6Des contrats d'import et un fence de publication, chacun avec une preuve de dents.
  • P-7Un port qui appartient au domaine, et un adaptateur qui est le seul module à connaître le fournisseur.
  • P-8Des décisions issues d'un matériau de politique versionné, conservées avec leur révision ou refusées.
  • P-2Seulement à la profondeur que COADF publie. Les exemples ne portent aucune représentation de la confiance.
  • P-3Seulement à la profondeur que COADF publie : dans cette mise en œuvre de référence, une vérification par attribut par une personne nommée, une valeur rejetée laissée vide. Ni le moment où une revue est requise, ni la façon dont les revues sont organisées ne sont montrés.

Intention d'architecture

Dans un service Python, le client du modèle et le code du domaine sont souvent écrits par les mêmes personnes, dans le même dépôt, le même après-midi : c'est ainsi que le côté probabiliste et le côté qui fait autorité finissent dans un même module, à s'appeler directement. FastAPI et Pydantic rendent la frontière peu coûteuse à exprimer sous forme de types et à valider à l'exécution. Ils ont aussi des réglages par défaut qui conviennent à une API web et pas à une frontière : un modèle Pydantic ignore les champs qu'il ne déclare pas et convertit les types compatibles tant que le mode strict n'est pas activé, et FastAPI filtre chaque réponse selon son modèle de réponse sans rien signaler.

Ce profil montre une façon de tenir les propriétés COADF dans un seul service Python : un monolithe modulaire avec des contrats d'import, PostgreSQL pour la piste d'audit, OpenTelemetry pour le contexte d'exécution, Open Policy Agent derrière une interface pour la politique. Aucun n'est exigé. Chacun est un endroit où la propriété est soit tenue, soit perdue sans bruit.

Correspondance technologique

Python et FastAPI: Correspondance technologique
Propriété d'architecturePython et FastAPI
Type de frontièreModèles Pydantic figés avec extra="forbid" et strict=True ; une dataclass distincte pour les valeurs vérifiées
Validation à l'exécutionmodel_validate_json sur la réponse brute ; validation des requêtes par FastAPI, avec une réponse 422
Contrat de sortieUn response_model explicite qui déclare la provenance
Règle d'architectureContrats forbidden d'import-linter, exécutés par lint-imports
Contexte d'exécutionPropagation OpenTelemetry (inject, extract) ; contextvars copiés dans les threads
Piste d'auditPostgreSQL : insertion et lecture seules pour le rôle de l'application, des triggers qui refusent les réécritures, une clé unique dont les conflits sont comparés avant d'être comptés comme de nouvelles tentatives
Isolation des normesUn port typing.Protocol ; un adaptateur httpx qui traduit les issues HTTP du fournisseur en introuvable, indisponible et rejeté
PolitiqueOPA via son API REST derrière une petite interface ; le type de la réponse vérifié, jamais converti, et la révision exigée sur chaque décision
Fence de publicationUn balayage de la sortie construite avec un fichier de motifs conservé hors de celle-ci ; une preuve de dents contrôlée par empreinte

Pattern de référence

Un seul paquet, refapp, dans un domaine de maintenance synthétique : un modèle propose la prochaine date d'entretien et une classe de composant à partir du rapport d'intervention d'un technicien. Les modules sont les frontières.

Les modules du paquet de référence et la propriété que chacun tient
ModuleRôleNe doit pas importer
boundary.pyLe contrat qu'une réponse de modèle doit respecterrien du domaine
inference.pyAppelle un modèle ; renvoie un Proposalrecords, audit, api
formats.pyContrôles déterministes sur une valeur proposéeadaptateurs, clients HTTP
records.pyValeurs vérifiées ; le chemin d'entrée revuadaptateurs, clients HTTP
api.pyLa surface HTTP de la frontière
tracing.pyContexte d'exécution et identité d'audit
audit.py, schema.sqlLa piste en ajout seul
classification.pyLe port d'un service externeadaptateurs, clients HTTP
adapters/Le fournisseur, et seulement le fournisseur
policy.py, policy/Matériau de politique, règle et client
fence.pyLe fence de publication

P-1 · La frontière

Exemple de référence illustratifUn contrat typé autour d'un appel de modèle
Objet
Donner un contrat à un appel de modèle : ce qu'il peut renvoyer, et ce qui doit accompagner ce qu'il renvoie. Une frontière de référence volontairement partielle, construite uniquement à partir de ce que COADF publie à ce niveau.
Propriété d'architecture
Un composant probabiliste renvoie une valeur accompagnée de la manière dont elle a été obtenue, et tout le reste est rejeté à la frontière (P-1). La méthode voyage avec la valeur, et c'est ce qui rend possible une déclaration ultérieure de l'extraction automatique (P-5).
refapp/boundary.pypythonP-1 · P-5
"""Illustrative reference example: the typed boundary around a model call. A probabilistic component returns a value together with how it was obtained,never a bare value. Anything that does not parse into a Proposal is rejectedhere, before it reaches code that treats values as facts. Deliberately partial: there is no confidence field. COADF publishes that aconfidence accompanies the value, not how it is represented, so none is invented.""" from typing import Literal from pydantic import BaseModel, ConfigDict, Field # frozen: nothing downstream can edit a proposal in place.# extra="forbid": a field the contract does not name is an error, not ignored.# strict: no coercion, so "12" is not quietly accepted where an int is expected.STRICT = ConfigDict(frozen=True, extra="forbid", strict=True)  class Derivation(BaseModel):    model_config = STRICT     method: Literal["language-model"]    model: str = Field(min_length=1)  # the model identifier as deployed    prompt_revision: str = Field(min_length=1)  # the prompt template the call used    source_digest: str = Field(pattern=r"^[0-9a-f]{64}$")  # SHA-256 of the source read    source_start: int = Field(ge=0)  # where in the source the passage begins    source_end: int = Field(ge=0)  # and where it ends  class Proposal(BaseModel):    model_config = STRICT     attribute: Literal["next_service_due", "component_class"]    value: str = Field(min_length=1, max_length=64)    provenance: Derivation  def parse_model_reply(raw: str) -> Proposal:    """Parse and validate the model's JSON in one step, or raise ValidationError."""    return Proposal.model_validate_json(raw)

Ce qu'il omet délibérément

  • La confiance, délibérément. Dans COADF, P-1 veut qu'un composant probabiliste rapporte une confiance avec sa valeur ; c'est la propriété d'architecture publique. La manière dont la confiance est exprimée, attribuée et utilisée est un détail de mise en œuvre réservé de P-2, que COADF ne publie que comme principe. Cette frontière porte donc la méthode et la provenance, n'a aucun champ de confiance et n'invente aucune représentation pour elle : elle est partielle à dessein, et non complète.
  • L'appel au modèle, ses nouvelles tentatives et le prompt. La frontière est la même quel que soit le client qui a produit la chaîne.
  • La justesse de la valeur. Un schéma contrôle la forme ; il ne peut pas savoir si une date est la bonne pour cet actif.
  • Le stockage. Un Proposal est un message, pas un enregistrement.

Comment le vérifier

Fournir à l'analyseur des réponses presque correctes : un champ non déclaré, un nombre là où le contrat attend du texte, une provenance manquante, un attribut que personne n'a demandé. Chacune doit lever une exception (tests/test_boundary.py). Supprimer ensuite extra="forbid" dans une copie jetable et voir échouer le test du champ non déclaré.

Mode de défaillance traité

Une réponse de modèle analysée avec json.loads et lue comme un dictionnaire. Un "verified": true en trop, un nombre converti implicitement ou une source manquante passe tel quel, et la valeur devient impossible à distinguer d'une donnée que quelqu'un a vérifiée.

Exemple de référence illustratifDes contrôles déterministes autour d'une valeur stochastique
Objet
Contrôler la valeur proposée avec du code qui ne dépend en rien du modèle.
Propriété d'architecture
La validation déterministe entoure la sortie stochastique : la même valeur reçoit toujours le même verdict, et le verdict peut être reproduit à partir de la seule valeur (P-1).
refapp/formats.pypythonP-1
"""Deterministic checks on a proposed value. They read the value, never the model.""" import refrom datetime import date from refapp.boundary import Proposal _CALENDAR_DATE = re.compile(r"\d{4}-\d{2}-\d{2}")  def check_format(proposal: Proposal) -> None:    """Raise ValueError when the value is not well formed for its attribute."""    if proposal.attribute == "next_service_due":        if not _CALENDAR_DATE.fullmatch(proposal.value):            raise ValueError("next_service_due must be written YYYY-MM-DD")        date.fromisoformat(proposal.value)  # and must be a real calendar date

Ce qu'il omet délibérément

  • Les contrôles qui ont besoin d'autres enregistrements ou d'autres sources. Cet exemple contrôle une valeur au regard des règles de son propre attribut, et de rien d'autre.
  • Les formats de date localisés. Le contrat fixe à dessein une seule forme écrite.

Comment le vérifier

Une valeur qui correspond au motif sans être une date du calendrier (2026-02-30) n'est jamais vérifiée (tests/test_boundary.py).

Mode de défaillance traité

Faire confiance au modèle pour avoir produit une valeur valide parce que le prompt en demandait une : le contrôle qui n'est pas écrit est celui qui échoue en production.

Exemple de référence illustratifUn chemin avec revue vers des enregistrements faisant autorité
Objet
Un chemin de référence prudent par lequel une valeur issue d'un modèle devient une valeur faisant autorité : dans cet exemple, chacune de ces valeurs va à une personne nommée, qui l'examine à côté de sa source et l'accepte ou la rejette.
Propriété d'architecture
Un composant probabiliste ne peut pas devenir directement une sortie faisant autorité (P-1). Dans cette mise en œuvre de référence, une valeur issue d'un modèle n'atteint un enregistrement que par une vérification humaine, et une valeur rejetée laisse l'attribut vide (P-3, à la profondeur que COADF publie). C'est une mise en œuvre parmi d'autres, pas une topologie COADF.
refapp/records.pypythonP-1 · P-3
"""A reviewed path into authoritative records, one conservative reference. This example sends every model-derived value through the verification of anamed person. When a real system requires review is its own rule, which thisexample does not define; nor does it say how reviews are organised.""" from dataclasses import dataclassfrom datetime import datetime from refapp.boundary import Proposalfrom refapp.formats import check_format  @dataclass(frozen=True)class ReviewOutcome:    reviewer: str    accepted: bool    reason: str    decided_at: datetime  @dataclass(frozen=True)class VerifiedValue:    attribute: str    value: str    proposal: Proposal  # the provenance stays attached after verification    review: ReviewOutcome  def accept(proposal: Proposal, review: ReviewOutcome) -> VerifiedValue | None:    """Rejected means absent: the attribute stays empty and nothing is guessed."""    check_format(proposal)    if not review.reviewer.strip():        raise ValueError("a verification needs a named reviewer")    if not review.accepted:        return None    return VerifiedValue(proposal.attribute, proposal.value, proposal, review)

Ce qu'il omet délibérément

  • Quand la revue est requise. COADF publie que la revue est déclenchée par la confiance ; la règle qui en décide n'est pas publiée, et cet exemple n'en définit aucune. Il vérifie chaque valeur issue d'un modèle, le choix prudent, qui est aussi ce que le fence public F-03 exige des valeurs issues d'un modèle de langage.
  • Comment les revues sont organisées. COADF ne publie ni leur mise en file d'attente, ni leur attribution, ni leur déclenchement, et rien de cela n'est suggéré ici. Il en va de même de leur ordre de passage : le code prend une proposition et une décision à la fois.
  • L'authentification de la personne qui vérifie. reviewer est ici une chaîne ; dans un système réel, c'est une identité authentifiée, contrôlée là où la décision est reçue.
  • La protection contre un développeur qui construirait VerifiedValue à la main. Le type rend visible une promotion accidentelle, le contrat d'import la rend impossible depuis le module d'inférence, et la revue de code couvre le reste.

Comment le vérifier

Une proposition rejetée renvoie None ; une proposition acceptée conserve sa provenance ; un nom vide pour la personne qui vérifie est refusé (tests/test_boundary.py).

Mode de défaillance traité

Un indicateur verified sur l'objet produit par le modèle, vrai par défaut ou positionné par le code, quel qu'il soit, qui sauvegarde l'enregistrement. Valeurs inférées et valeurs vérifiées deviennent la même chose dans le stockage, et rien en aval ne peut plus les distinguer.

Exemple de référence illustratifLa frontière comme règle appliquée par le build
Objet
Faire de la frontière, au lieu d'une boîte sur un schéma, un contrôle qui fait échouer le build.
Propriété d'architecture
Le module probabiliste n'a aucun chemin d'import vers les enregistrements faisant autorité ni vers la piste d'audit, et le code du domaine n'en a aucun vers les adaptateurs de fournisseurs ou les clients HTTP (P-1, P-7). La règle s'exécute à chaque modification (P-6).
pyproject.tomltomlP-1 · P-6 · P-7
# Illustrative reference example: architecture rules as data, run by `lint-imports`. [tool.importlinter]root_package = "refapp"include_external_packages = true [[tool.importlinter.contracts]]name = "The probabilistic side cannot reach authoritative records or the audit trail"type = "forbidden"source_modules = ["refapp.inference"]forbidden_modules = ["refapp.records", "refapp.audit", "refapp.api"] [[tool.importlinter.contracts]]name = "Domain code does not depend on vendor adapters or HTTP clients"type = "forbidden"source_modules = ["refapp.records", "refapp.formats", "refapp.classification"]forbidden_modules = ["refapp.adapters", "httpx"] [tool.pytest.ini_options]testpaths = ["tests"]pythonpath = ["."]

Ce qu'il omet délibérément

  • Les imports faits par nom à l'exécution, comme importlib.import_module avec une chaîne calculée. Un contrat d'import statique contrôle les instructions d'import qu'il peut lire ; un chargeur de plugins demande un contrôle à part.
  • Les chemins de données qui ne passent pas par des imports : une connexion partagée à la base de données, une file que les deux côtés peuvent atteindre. Les droits de la base de données et la politique réseau du profil Cloud-native s'en chargent.
  • Un contrat en couches pour toute l'application. Deux contrats d'interdiction suffisent à montrer le mécanisme.

Comment le vérifier

lint-imports indique que les deux contrats sont respectés. Ajouter from refapp.records import accept à inference.py dans une copie jetable : le premier contrat est signalé comme rompu et la commande se termine avec un code de sortie non nul. Retirer la ligne, et il est de nouveau respecté.

Mode de défaillance traité

La frontière existe comme une boîte sur un schéma d'architecture et nulle part ailleurs, jusqu'au jour où quelqu'un a besoin en urgence d'une valeur de l'autre côté.

Exemple de référence illustratifTests négatifs : un défaut planté par test
Objet
Vérifier la frontière par ce qu'elle refuse, pas seulement par ce qu'elle accepte.
Propriété d'architecture
Chaque test plante un défaut que la frontière existe pour arrêter, si bien que supprimer une contrainte fait passer un test au rouge.
tests/test_boundary.pypythonP-1 · P-6
"""The boundary refuses what it must refuse. Each test is one defect, planted.""" import jsonfrom datetime import UTC, datetime import pytestfrom pydantic import ValidationError from refapp.boundary import parse_model_replyfrom refapp.records import ReviewOutcome, accept SOURCE = "ab" * 32  # a well-formed SHA-256 hex digest, synthetic  def reply(**changes: object) -> str:    body: dict[str, object] = {        "attribute": "next_service_due",        "value": "2026-10-01",        "provenance": {            "method": "language-model",            "model": "example-model-2026-06",            "prompt_revision": "service-report-v3",            "source_digest": SOURCE,            "source_start": 118,            "source_end": 131,        },    }    body.update(changes)    return json.dumps(body)  def review(accepted: bool, reviewer: str = "j.doe") -> ReviewOutcome:    return ReviewOutcome(reviewer, accepted, "matches the report", datetime.now(UTC))  def test_a_well_formed_reply_parses() -> None:    assert parse_model_reply(reply()).provenance.method == "language-model"  @pytest.mark.parametrize(    "defect",    [        {"verified": True},  # the model claims more than it may        {"value": 20261001},  # a number where the contract says text        {"provenance": None},  # a bare value, with no provenance at all        {"attribute": "owner_name"},  # an attribute nobody asked for    ],)def test_the_boundary_rejects_it(defect: dict[str, object]) -> None:    with pytest.raises(ValidationError):        parse_model_reply(reply(**defect))  def test_a_rejected_proposal_leaves_the_attribute_empty() -> None:    assert accept(parse_model_reply(reply()), review(accepted=False)) is None  def test_verification_keeps_the_provenance() -> None:    verified = accept(parse_model_reply(reply()), review(accepted=True))    assert verified is not None and verified.proposal.provenance.source_digest == SOURCE  def test_a_value_that_is_not_a_calendar_date_never_verifies() -> None:    with pytest.raises(ValueError):        accept(parse_model_reply(reply(value="2026-02-30")), review(accepted=True))  def test_nobody_is_not_a_reviewer() -> None:    with pytest.raises(ValueError):        accept(parse_model_reply(reply()), review(accepted=True, reviewer=" "))

Ce qu'il omet délibérément

  • Les entrées malformées générées. Une courte liste de défauts nommés est plus facile à relire, et chacun documente un mode de défaillance.
  • Tout ce qui touche à la qualité des réponses du modèle. La frontière ne rend pas un modèle juste.

Comment le vérifier

Exécuter pytest. La suite n'a de dents que si retirer une contrainte fait échouer un test ; le harnais supprime extra="forbid" dans une copie jetable et le test du champ non déclaré échoue.

Mode de défaillance traité

Une suite qui ne parcourt que le chemin nominal : chaque contrainte pourrait être supprimée sans qu'elle cesse d'être au vert.

Exemple de référence illustratifLe modèle de réponse fait partie du contrat
Objet
La surface HTTP de la frontière : ce qu'un worker d'inférence peut envoyer, et ce que reçoit l'écran de la personne qui vérifie.
Propriété d'architecture
FastAPI valide la requête au regard du modèle et filtre la réponse selon le modèle de réponse, si bien que la provenance, y compris l'emplacement du passage dont la personne qui vérifie a besoin pour voir la valeur à côté de sa source, doit être déclarée, sans quoi elle ne quitte jamais le service.
refapp/api.pypythonP-1 · P-5
"""The HTTP surface of the boundary. The response model is part of thecontract: a field it does not declare never leaves the service.""" from fastapi import FastAPI, HTTPExceptionfrom pydantic import BaseModel from refapp.boundary import Derivation, Proposal app = FastAPI()_pending: dict[str, list[Proposal]] = {}  # stand-in for real storage  class ProposalOut(BaseModel):    attribute: str    value: str    provenance: Derivation  # delete this line and every response loses it, silently  @app.post("/reports/{report_id}/proposals", status_code=201)def add_proposal(report_id: str, proposal: Proposal) -> None:    """The inference worker posts here. An undeclared field, say "verified", is a 422."""    _pending.setdefault(report_id, []).append(proposal)  @app.get("/reports/{report_id}/proposals", response_model=list[ProposalOut])def list_proposals(report_id: str) -> list[Proposal]:    """What a reviewer's screen needs: each value with the passage it came from."""    if report_id not in _pending:        raise HTTPException(status_code=404)    return _pending[report_id]

Ce qu'il omet délibérément

  • L'authentification et l'autorisation des deux points de terminaison.
  • La persistance. Un dictionnaire tient lieu de stockage.
  • Le point de terminaison de décision par lequel la personne qui vérifie accepte ou rejette ; voir records.accept.
  • Tout ordre de présentation. La liste revient dans l'ordre d'insertion. Celui-ci n'est pas propre à une revue : COADF ne publie pas comment la revue est organisée, et rien ici ne l'organise.

Comment le vérifier

Envoyer une réponse avec un champ verified en trop : 422. En envoyer une bien formée et la lister : la provenance figure dans la réponse. Déclarer ensuite un modèle de réponse sans la provenance : le même objet revient sans elle, et rien n'échoue (tests/test_api.py le reproduit).

Mode de défaillance traité

La provenance perdue à la sérialisation. Un modèle de réponse qui omet la provenance la supprime en silence, et l'écran de revue affiche une valeur sans rien pour la contrôler.

P-4 · La trace et la piste

Exemple de référence illustratifContexte d'exécution et identité d'audit, côte à côte
Objet
Transporter le contexte d'exécution à travers un message et un thread, et transporter l'identité d'audit à côté de lui, jamais à sa place.
Propriété d'architecture
Une seule identité traçable relie les étapes d'une transaction (P-4). Le contexte OpenTelemetry lui est corrélé par un attribut de span, et ne s'y substitue jamais.
refapp/tracing.pypythonP-4
"""Two identifiers travel together and are never confused. The OpenTelemetry context describes one execution: a request, a message, ajob. The audit trace_id names one transaction across all of them, for as longas its records exist. They are correlated, never substituted for each other. Correlation copies the audit trace_id into telemetry, which has its ownexporters, vendors, access rules and retention. Keep it an opaque reference:never personal data, never a secret, never something that means more outside.""" import asyncioimport contextvarsfrom collections.abc import Callablefrom concurrent.futures import Executor, Futurefrom typing import Any from opentelemetry import propagate, trace tracer = trace.get_tracer("refapp")audit_trace_id: contextvars.ContextVar[str] = contextvars.ContextVar("audit_trace_id")  def publish(send: Callable[..., None], body: dict[str, Any]) -> None:    """Producer side: execution context in headers, audit identity in the body."""    headers: dict[str, str] = {}    propagate.inject(headers)  # W3C traceparent with the default propagators    send(headers=headers, body=body)  def consume(    headers: dict[str, str], body: dict[str, Any], work: Callable[[], None]) -> None:    """Consumer side: continue the execution, and require the audit identity."""    ctx = propagate.extract(headers)    token = audit_trace_id.set(body["trace_id"])  # a KeyError, never a freshly minted id    try:        with tracer.start_as_current_span("process-report", context=ctx) as span:            span.set_attribute("refapp.audit.trace_id", body["trace_id"])  # opaque reference            work()    finally:        audit_trace_id.reset(token)  async def run_blocking(fn: Callable[..., Any], *args: Any) -> Any:    return await asyncio.to_thread(fn, *args)  # documented to carry the context along  def submit_with_context(pool: Executor, fn: Callable[..., Any], *args: Any) -> Future[Any]:    ctx = contextvars.copy_context()  # pool.submit(fn) alone does not carry the caller's    return pool.submit(ctx.run, fn, *args)

Ce qu'il omet délibérément

  • Les exportateurs et les échantillonneurs ; voir la configuration du Collector dans le profil Cloud-native.
  • Un vrai client de broker. send est n'importe quelle fonction qui prend des en-têtes et un corps.
  • L'endroit où le trace_id d'audit est créé : à la réception, une seule fois, et nulle part en aval.
  • Le baggage. L'identité d'audit voyage dans le corps du message, où elle fait partie du contrat, plutôt que dans le baggage, que l'instrumentation transmet aux services en aval, tiers compris.
  • Ce que la corrélation expose. L'attribut de span copie le trace_id d'audit dans la télémétrie, qui a ses propres exportateurs, fournisseurs, contrôles d'accès et durée de conservation, généralement plus lâches que ceux du stockage d'audit. Le classifier avant son export : une référence opaque, jamais une donnée personnelle ni un secret. Là où un identifiant d'audit porte un sens, corréler plutôt au travers d'une référence distincte et non sensible.

Comment le vérifier

Les spans du producteur et du consommateur partagent une même trace distribuée, et le span du consommateur porte l'identité d'audit. Un message sans identité d'audit lève KeyError au lieu d'en créer une nouvelle. Une soumission nue à un pool de threads perd le contexte ; le contexte copié le conserve ; asyncio.to_thread le conserve (tests/test_tracing.py).

Mode de défaillance traité

L'identifiant de trace régénéré en cours de route : un consommateur qui se rabat sur un identifiant neuf quand le champ manque, ou un thread de travail qui démarre sans le contexte de l'appelant, laisse deux moitiés qu'aucune requête ne peut réunir.

Exemple de référence illustratifUne piste d'audit en ajout seul dans PostgreSQL
Objet
Une piste d'audit avec les neuf champs que COADF publie, stockée de sorte que l'application puisse ajouter et lire, et que la base de données refuse les réécritures.
Propriété d'architecture
La piste est en ajout seul et une correction est une nouvelle entrée (P-4). Une étape a une seule identité : une nouvelle tentative exacte est la même entrée, et la même identité avec un autre contenu est refusée par audit.py.
schema.sqlsqlP-4
-- Illustrative reference example: an append-only audit trail in PostgreSQL.-- The nine columns are the fields COADF publishes for its audit trail schema.-- The vocabulary of event_type belongs to the application and is deliberately-- outside this example: the lesson here is storage that refuses rewrites. CREATE TABLE audit_entries (    trace_id    text        NOT NULL,    "timestamp" timestamptz NOT NULL,  -- when the step happened, not when the row arrived    event_type  text        NOT NULL,    actor       jsonb       NOT NULL,  -- {"kind": "system" | "person" | "agent", "id": …}    input       jsonb       NOT NULL,  -- what the step was about: a reference, not a copy    output      jsonb,    decision    jsonb,    source_hash text,    immutable   boolean     NOT NULL DEFAULT true CHECK (immutable),    -- The identity of one step. A retry must match the stored entry exactly;    -- refapp/audit.py refuses a second entry with this identity and other content.    UNIQUE (trace_id, "timestamp", event_type, input)); -- The application connects as a role that can add and read, nothing else.CREATE ROLE trail_appender NOLOGIN;GRANT INSERT, SELECT ON audit_entries TO trail_appender; -- Defence in depth for sessions that do hold broader rights.CREATE FUNCTION audit_entries_refuse() RETURNS trigger LANGUAGE plpgsql AS $$BEGIN    RAISE EXCEPTION 'audit_entries is append-only: % refused', TG_OP;END $$; CREATE TRIGGER audit_entries_no_rewrite    BEFORE UPDATE OR DELETE ON audit_entries    FOR EACH ROW EXECUTE FUNCTION audit_entries_refuse(); -- TRUNCATE fires no DELETE trigger, so it needs a trigger of its own.CREATE TRIGGER audit_entries_no_truncate    BEFORE TRUNCATE ON audit_entries    FOR EACH STATEMENT EXECUTE FUNCTION audit_entries_refuse();

Ce qu'il omet délibérément

  • La détection des altérations. Les droits et les triggers sont des contrôles situés à l'intérieur du périmètre de confiance propre à la base de données : un superutilisateur contourne les vérifications de permissions, et le propriétaire de la table peut désactiver ses triggers. Détecter une réécriture par quelqu'un qui détient ces droits demande un contrôle extérieur à la base de données, que cet exemple ne montre pas.
  • La conservation, l'archivage et l'export.
  • Le vocabulaire des événements. event_type est un texte obligatoire. Les événements qu'une application enregistre relèvent de sa propre conception, et restent délibérément hors de cet exemple.
  • Un ordre entre entrées de même horodatage. La reconstruction suit l'ordre des horodatages, et cet exemple ne départage volontairement pas les horodatages égaux : aucun champ publié ne les départage. Un seul écrivain par transaction, ou un ordre attribué par un tel écrivain, est une décision de stockage qui dépasse les champs publiés.
  • La migration et le rôle propriétaire de la table. L'application ne se connecte jamais sous ce rôle.

Comment le vérifier

Face à un vrai PostgreSQL : le rôle de l'application ne peut ni mettre à jour, ni supprimer, ni tronquer ; le propriétaire se heurte aux triggers ; une nouvelle tentative exacte n'ajoute rien ; la même identité avec un autre contenu est refusée ; une correction ajoute une entrée ; et, délibérément, le propriétaire peut désactiver les triggers et supprimer (tests/test_audit.py).

Mode de défaillance traité

Une correction écrite comme un UPDATE, qui détruit la trace de ce que l'on tenait pour vrai auparavant, et la même perte par TRUNCATE, qui ne déclenche aucun trigger DELETE.

Exemple de référence illustratifAjout idempotent qui refuse les conflits, reconstruction en une requête
Objet
Rendre une nouvelle tentative inoffensive et une écriture conflictuelle bruyante, et relire une transaction en une seule requête.
Propriété d'architecture
L'horodatage est apposé quand l'étape s'exécute, si bien qu'une nouvelle tentative renvoie l'entrée identique. En cas de conflit, l'entrée stockée est d'abord comparée : le même contenu est une nouvelle tentative, un autre contenu lève IdempotencyCollision. La reconstruction est une seule requête par trace_id, dans l'ordre des horodatages ; les horodatages égaux ne sont volontairement pas départagés (P-4).
refapp/audit.pypythonP-4
"""Append to the audit trail, and read one transaction back. The timestamp is taken when the step runs and travels with the entry, so aretry resends the identical row. The unique key makes that retry harmless; itmust not make a different entry disappear, so a conflict is compared withwhat is stored before it is called a retry.""" from dataclasses import dataclassfrom datetime import datetimefrom typing import Any from psycopg import Connectionfrom psycopg.types.json import Jsonb _INSERT = """    INSERT INTO audit_entries (trace_id, "timestamp", event_type, actor, input,                             output, decision, source_hash, immutable)    VALUES (%s, %s, %s, %s, %s, %s, %s, %s, true)    ON CONFLICT (trace_id, "timestamp", event_type, input) DO NOTHING"""_STORED = """    SELECT actor, output, decision, source_hash FROM audit_entries    WHERE trace_id = %s AND "timestamp" = %s AND event_type = %s AND input = %s"""  @dataclass(frozen=True)class AuditEntry:    trace_id: str    timestamp: datetime    event_type: str    actor: dict[str, Any]    input: dict[str, Any]    output: dict[str, Any] | None = None    decision: dict[str, Any] | None = None    source_hash: str | None = None  class IdempotencyCollision(Exception):    """Same step identity, different content: never a retry, never silent."""  def _json(value: dict[str, Any] | None) -> Jsonb | None:    return None if value is None else Jsonb(value)  def append(conn: Connection, entry: AuditEntry) -> bool:    """True if written; False if this exact entry is already there."""    identity = (entry.trace_id, entry.timestamp, entry.event_type, Jsonb(entry.input))    content = (entry.actor, entry.output, entry.decision, entry.source_hash)    with conn.cursor() as cur:        cur.execute(_INSERT, (entry.trace_id, entry.timestamp, entry.event_type,                              Jsonb(entry.actor), Jsonb(entry.input), _json(entry.output),                              _json(entry.decision), entry.source_hash))        if cur.rowcount == 1:            return True        cur.execute(_STORED, identity)        if cur.fetchone() != content:            raise IdempotencyCollision(f"{entry.trace_id}: {entry.event_type} differs")    return False  def chain(conn: Connection, trace_id: str) -> list[tuple[Any, ...]]:    """Every entry of one transaction, by timestamp. Entries with equal timestamps    are intentionally not ordered by this example: no published field orders them."""    with conn.cursor() as cur:        cur.execute('SELECT * FROM audit_entries WHERE trace_id = %s ORDER BY "timestamp"',                    (trace_id,))        return cur.fetchall()

Ce qu'il omet délibérément

  • La transaction qui écrit l'entrée en même temps que le changement d'état qu'elle enregistre. Écrits séparément, l'un peut exister sans l'autre.
  • L'export en JSON, que P-4 demande aussi.
  • La gestion des connexions, et le rôle qu'utilise la connexion.
  • Laquelle de deux entrées en conflit est la bonne. L'exemple refuse la seconde et garde la première ; trancher entre elles est le travail de quelqu'un, pas celui de la base de données.
  • Les écritures concurrentes d'une même étape. L'exemple a été testé avec un seul écrivain à la fois ; en situation de concurrence, vérifier ce que le niveau d'isolation laisse voir à la lecture de comparaison.

Comment le vérifier

Ajouter deux fois la même entrée : True, puis False, et une seule ligne. Ajouter la même identité avec une autre sortie : IdempotencyCollision, et l'entrée stockée reste inchangée (tests/test_audit.py). Reconstruire avec chain().

Mode de défaillance traité

Des nouvelles tentatives qui produisent des événements ambigus, et des conflits qui disparaissent : un horodatage pris au moment de l'insertion fait de chaque nouvelle tentative une nouvelle entrée, et un ON CONFLICT DO NOTHING nu écarte sans erreur une entrée différente qui se trouve partager la clé.

Exemple de référence illustratifVérifier la persistance, pas les journaux
Objet
Contrôler la propriété d'ajout seul face à la couche qui l'applique réellement, et figer la limite dans un test.
Propriété d'architecture
La propriété est affirmée là où elle est appliquée, dans la base de données ; ce que les contrôles n'empêchent pas fait aussi l'objet d'un test, pour que personne ne puisse l'oublier.
tests/test_audit.pypythonP-4 · P-6
"""Append-only, checked against a real PostgreSQL. Skipped without one. Set REFAPP_PG_DSN to a database this test may create a table in. The eventtypes are synthetic. The last test is deliberate: it shows what the controls doNOT stop.""" import osimport pathlibfrom datetime import UTC, datetime import psycopgimport pytestfrom psycopg import errors from refapp.audit import AuditEntry, IdempotencyCollision, append, chain DSN = os.environ.get("REFAPP_PG_DSN")pytestmark = pytest.mark.skipif(not DSN, reason="REFAPP_PG_DSN is not set")SCHEMA = (pathlib.Path(__file__).parent.parent / "schema.sql").read_text()  @pytest.fixturedef conn():    with psycopg.connect(DSN, autocommit=True) as owner:        owner.execute("DROP TABLE IF EXISTS audit_entries")        owner.execute("DROP FUNCTION IF EXISTS audit_entries_refuse")        owner.execute("DROP ROLE IF EXISTS trail_appender")        owner.execute(SCHEMA)        yield owner        owner.execute("DROP TABLE audit_entries")        owner.execute("DROP FUNCTION audit_entries_refuse")        owner.execute("DROP ROLE trail_appender")  def step(event_type: str, at: datetime, **fields: object) -> AuditEntry:    return AuditEntry(trace_id="tr-3", timestamp=at, event_type=event_type,                      actor={"kind": "system", "id": "intake-worker"},                      input={"report": "r-3"}, **fields)  def test_a_retry_of_the_same_step_is_the_same_entry(conn) -> None:    entry = step("intake", datetime(2026, 9, 1, 8, 0, tzinfo=UTC), source_hash="ab" * 32)    assert append(conn, entry) is True    assert append(conn, entry) is False  # the retry    assert len(chain(conn, "tr-3")) == 1  def test_same_identity_other_content_is_never_a_silent_retry(conn) -> None:    at = datetime(2026, 9, 1, 8, 0, tzinfo=UTC)    append(conn, step("decision", at, output={"next_service_due": "2026-10-01"}))    with pytest.raises(IdempotencyCollision):        append(conn, step("decision", at, output={"next_service_due": "2026-11-01"}))    [stored] = chain(conn, "tr-3")    assert stored[5] == {"next_service_due": "2026-10-01"}  # the first entry, unchanged  def test_a_correction_is_a_new_entry(conn) -> None:    first = datetime(2026, 9, 1, 8, 0, tzinfo=UTC)    append(conn, step("decision", first, output={"next_service_due": "2026-10-01"}))    corrected = {"event_type": "decision", "timestamp": first.isoformat()}    append(conn, AuditEntry(        trace_id="tr-3", timestamp=datetime(2026, 9, 2, 9, 30, tzinfo=UTC),        event_type="decision", actor={"kind": "person", "id": "j.doe"},        input={"attribute": "next_service_due", "corrects": corrected},        output={"next_service_due": "2026-11-01"},        decision={"action": "corrected", "reason": "the report was misread"}))    assert len(chain(conn, "tr-3")) == 2  # both stay; the later one supersedes  def test_the_application_role_cannot_rewrite_or_erase(conn) -> None:    append(conn, step("intake", datetime(2026, 9, 1, 8, 0, tzinfo=UTC)))    conn.execute("SET ROLE trail_appender")    try:        for statement in ("UPDATE audit_entries SET actor = '{}'", "DELETE FROM audit_entries",                          "TRUNCATE audit_entries"):            with pytest.raises(errors.InsufficientPrivilege):                conn.execute(statement)    finally:        conn.execute("RESET ROLE")  def test_even_the_owner_meets_the_triggers(conn) -> None:    append(conn, step("intake", datetime(2026, 9, 1, 8, 0, tzinfo=UTC)))    for statement in ("UPDATE audit_entries SET actor = '{}'", "DELETE FROM audit_entries",                      "TRUNCATE audit_entries"):        with pytest.raises(errors.RaiseException):            conn.execute(statement)  def test_what_the_controls_do_not_stop(conn) -> None:    """The owner can switch the triggers off. The app must never connect as owner."""    append(conn, step("intake", datetime(2026, 9, 1, 8, 0, tzinfo=UTC)))    conn.execute("ALTER TABLE audit_entries DISABLE TRIGGER USER")    conn.execute("DELETE FROM audit_entries")    assert chain(conn, "tr-3") == []

Ce qu'il omet délibérément

  • Une base de données dans l'intégration continue. Les tests sont ignorés sans REFAPP_PG_DSN, et un test ignoré n'est pas un test réussi : le job qui les exécute doit fournir la base de données, ou signaler le contrôle comme non exécuté.

Comment le vérifier

Exécuter face à un PostgreSQL jetable : les six tests réussissent, y compris la nouvelle tentative conflictuelle qui doit échouer et celui qui prouve que le propriétaire peut supprimer.

Mode de défaillance traité

Une fausse immuabilité : un trigger pris pour la preuve que l'historique ne peut pas changer, alors que le rôle propriétaire de la table peut le désactiver.

P-7 · Le port et l'adaptateur

Exemple de référence illustratifUn port qui appartient au domaine
Objet
Le côté domaine d'un service de classification externe : un port, et les types qui se trouvent derrière lui.
Propriété d'architecture
Les vocabulaires externes restent derrière un port qui appartient au domaine. Un service défaillant rend l'enregistrement moins informatif sans jamais l'arrêter, et l'enregistrement indique si le service était indisponible ou si l'intégration est cassée (P-7).
refapp/classification.pypythonP-7
"""A port for an external classification service, and the domain's use of it. The domain speaks only these types. It never sees the vendor's client, itsresponse shape or its identifiers, so a new vendor or a new transport is anadapter change. A new version of the scheme itself can change what a codemeans, and then the domain's mapping changes too.""" from dataclasses import dataclassfrom typing import Literal, Protocol  @dataclass(frozen=True)class Classification:    scheme: str  # which external scheme answered    version: str  # a code means something only within one version    code: str    licensed: bool  # licensed content is marked, so exports can keep it apart  class LookupUnavailable(Exception):    """Transient: a timeout, an outage, a server error. It may work later."""  class LookupRejected(Exception):    """Not transient: refused credentials, a bad request, an answer in a shape    nobody agreed to. Retrying does not help; somebody has to fix it."""  class ClassificationLookup(Protocol):    def lookup(self, component_class: str) -> Classification | None: ...  @dataclass(frozen=True)class Enrichment:    classification: Classification | None    status: Literal["found", "not-found", "unavailable", "rejected"]  def enrich(lookup: ClassificationLookup, component_class: str) -> Enrichment:    """A failing service makes the record less informative. It never stops the    record, and the status keeps an outage apart from a broken integration."""    try:        found = lookup.lookup(component_class)    except LookupUnavailable:        return Enrichment(None, "unavailable")    except LookupRejected:        return Enrichment(None, "rejected")    return Enrichment(found, "found" if found is not None else "not-found")

Ce qu'il omet délibérément

  • Ce qu'une réponse externe fait à la confiance. Dans COADF, P-7 dit qu'un service externe peut relever la confiance d'un attribut et ne peut jamais faire barrage à une sortie ; la manière dont ce relèvement fonctionne n'est pas publiée. Ici, la réponse est enregistrée, avec son statut, et rien de plus.
  • La mise en cache, et la durée pendant laquelle une licence permet de conserver une réponse externe.
  • La correspondance entre les codes externes et les concepts propres au domaine, qui est une donnée versionnée dans un système réel. Un nouveau fournisseur ou un nouveau transport reste dans l'adaptateur ; une nouvelle version du référentiel de classification lui-même peut changer ce que signifient les codes, et cette correspondance change alors aussi.
  • Qui est prévenu. rejected ne se résout pas par de nouvelles tentatives, et appelle donc une alerte dont unavailable peut se passer.

Comment le vérifier

Une doublure de test satisfait le port ; une panne enregistre unavailable et une intégration cassée enregistre rejected (tests/test_classification.py). Le contrat d'import tient httpx et les adaptateurs hors de ce module.

Mode de défaillance traité

Une panne distante qui bloque des traitements sans rapport : le domaine appelle directement le fournisseur, de façon synchrone et sans délai d'expiration, et un incident chez le fournisseur devient un incident de l'application.

Exemple de référence illustratifLes noms du fournisseur s'arrêtent à l'adaptateur
Objet
Le seul module qui connaît la forme HTTP du fournisseur et les noms du fournisseur.
Propriété d'architecture
Les champs classId et restricted du fournisseur sont traduits en code et licensed du domaine, la version du référentiel est épinglée à chaque appel, et les résultats HTTP du fournisseur deviennent trois résultats du domaine : non trouvé, indisponible (transitoire) et rejeté (l'intégration est erronée). Le vocabulaire HTTP du fournisseur ne devient pas celui du domaine (P-7).
refapp/adapters/http_classification.pypythonP-7
"""The adapter: the only module that knows the vendor's HTTP shape.""" import httpx from refapp.classification import Classification, LookupRejected, LookupUnavailable  class HttpClassificationLookup:    def __init__(self, client: httpx.Client, scheme: str, version: str) -> None:        self._client = client  # base URL and timeout are set by whoever builds the client        self._scheme = scheme        self._version = version     def lookup(self, component_class: str) -> Classification | None:        try:            response = self._client.get(                f"/classes/{component_class}", params={"release": self._version}            )        except httpx.TransportError as exc:  # timeouts and connection failures            raise LookupUnavailable() from exc        if response.status_code == 404:            return None        if response.status_code == 429 or response.is_server_error:            raise LookupUnavailable()        if response.status_code != 200:  # our request or our credentials are wrong            raise LookupRejected(f"vendor refused the request: {response.status_code}")        return self._translate(response)     def _translate(self, response: httpx.Response) -> Classification:        """The vendor's names stop here, and so does any shape we did not agree to."""        try:            body = response.json()            code, restricted = body["classId"], body["restricted"]        except (ValueError, KeyError, TypeError) as exc:            raise LookupRejected("unexpected vendor response") from exc        if not isinstance(code, str) or not code or not isinstance(restricted, bool):            raise LookupRejected("unexpected vendor response")        return Classification(self._scheme, self._version, code, licensed=restricted)

Ce qu'il omet délibérément

  • Les nouvelles tentatives, les disjoncteurs et les limites de débit.
  • L'authentification auprès du fournisseur.
  • Les conditions de licence elles-mêmes, et tout contenu soumis à licence : cet exemple n'en contient aucun.
  • Une taxonomie plus fine. Trois résultats suffisent à distinguer une panne d'une intégration cassée ; un adaptateur réel peut en demander davantage.

Comment le vérifier

Avec httpx.MockTransport : un 200 est traduit ; un 404 donne None ; un délai dépassé, une erreur de connexion, un 429 ou un 5xx donne LookupUnavailable ; un 400, un 401, un restricted non booléen, un champ manquant ou un corps qui n'est pas du JSON donne LookupRejected ; chaque requête porte la version épinglée.

Mode de défaillance traité

Le SDK du fournisseur qui devient le modèle du domaine : ses classes apparaissent dans les signatures du domaine, ses identifiants prennent un sens interne, et un changement de licence ou d'API oblige à réécrire du code du domaine. Ou bien chaque échec signalé comme le même unavailable, si bien qu'un identifiant d'accès révoqué ressemble à une panne et que personne ne le corrige.

P-8 · Les règles comme données

Exemple de référence illustratifLe matériau de politique, avec sa révision
Objet
Le matériau que lit une règle : les fonctionnalités que chaque environnement peut activer, et la révision de ce matériau.
Propriété d'architecture
Les règles sont des données. Changer ce qui est permis change ce fichier, et non le moteur ni la règle (P-8).
policy/data.jsonjsonP-8
{  "refapp": {    "revision": "2026-09-01.2",    "features": {      "staging": ["report-export", "beta-search"],      "production": ["report-export"]    }  }}

Ce qu'il omet délibérément

  • La manière dont le matériau de politique est protégé en chemin vers le moteur et contrôlé à son arrivée. COADF ne publie pas cette partie de P-8, et cet exemple n'en montre rien.
  • La manière dont les révisions sont attribuées. La documentation des bundles d'OPA prend elle-même un hash de commit Git comme exemple de révision. Rien dans cet exemple n'empêche le matériau de changer sous la même révision ; c'est le fait de dériver la révision du commit ou du contenu qui l'empêche.

Comment le vérifier

Autoriser beta-search en production : les tests qui figent la réponse de production échouent, alors que la règle n'a pas été touchée.

Mode de défaillance traité

Des règles dupliquées et codées en dur dans plusieurs services, chacune modifiée à son propre rythme, si bien que la même requête est permise à un endroit et refusée à un autre.

Exemple de référence illustratifUne règle qui répond avec sa révision
Objet
Une règle qui lit son matériau dans les données, et qui répond avec la révision qui a produit la réponse.
Propriété d'architecture
La décision porte la provenance de la version des règles (P-8).
policy/feature_access.regoregoP-8
# Illustrative reference example: which features an environment may enable.# The rule reads policy material from data; changing what is allowed is a data# change, and the rule below stays exactly as it is.package refapp.feature_access default allow := false allow if input.feature in data.refapp.features[input.environment] # The answer carries the revision of the material that produced it.decision := {	"allow": allow,	"revision": data.refapp.revision,}

Ce qu'il omet délibérément

  • Tout ce qui touche à la confiance, à la revue, à la publication ou à l'autonomie. Le sujet est délibérément générique.
  • Les bundles et les journaux de décision, qui dans OPA peuvent porter d'eux-mêmes une révision de bundle. Le revision explicite garde cet exemple autonome.
  • La question de savoir si une règle réelle doit refuser par défaut. Ici, la valeur par défaut est false ; pour chaque règle réelle, c'est une décision qui revient à son propriétaire.

Comment le vérifier

opa test policy/ exécute six tests, dont une clé d'entrée mal orthographiée qui retombe sur la valeur par défaut au lieu de lever une erreur, et un rejeu de la même entrée sous la même révision.

Mode de défaillance traité

Un rechargement des règles qui change le sens de décisions historiques : sans la révision sur la décision, un rapport calculé aujourd'hui ne peut pas dire quelles règles ont produit les réponses du mois dernier.

Exemple de référence illustratifGarder la décision avec sa révision, ou ne pas la garder
Objet
Le côté application : interroger le moteur au travers d'une petite interface, et refuser toute réponse qui ne nomme pas sa révision.
Propriété d'architecture
Une décision est conservée avec la révision qui l'a produite, ou elle n'est pas conservée (P-8). La réponse est analysée aussi strictement qu'elle est conservée : allow doit être un booléen et revision une chaîne non vide, car en Python bool("false") vaut True. Le moteur se trouve derrière un adaptateur, si bien que l'enregistrement de décision garde sa forme si le moteur change.
refapp/policy.pypythonP-8
"""Ask a policy engine, and keep the answer together with its revision. The application depends on this small interface, not on the engine. SwapOpen Policy Agent for rule tables or a decision service and only the adapterchanges; the decision record keeps its shape.""" from dataclasses import dataclassfrom typing import Any import httpx  @dataclass(frozen=True)class PolicyDecision:    allow: bool    revision: str  # which policy material produced this answer    decision_id: str | None  # present when the engine keeps decision logs  class PolicyUnavailable(Exception):    """No usable decision. What that means is the decision owner's call; for    this feature-access example the caller refuses the feature."""  class OpaFeatureAccess:    _PATH = "/v1/data/refapp/feature_access/decision"     def __init__(self, client: httpx.Client) -> None:        self._client = client     def decide(self, environment: str, feature: str) -> PolicyDecision:        try:            response = self._client.post(                self._PATH, json={"input": {"environment": environment, "feature": feature}}            )            response.raise_for_status()            body = response.json()        except (httpx.HTTPError, ValueError) as exc:  # unreachable, refused, not JSON            raise PolicyUnavailable("no answer from the policy engine") from exc        return _decision(body)  def _decision(body: Any) -> PolicyDecision:    """Types are checked, never coerced: bool("false") is True."""    result = body.get("result") if isinstance(body, dict) else None    if result is None:  # OPA omits "result" when the path is undefined        raise PolicyUnavailable("undefined: no decision, which is not a denial")    if not isinstance(result, dict):        raise PolicyUnavailable("a result in an unexpected shape")    allow, revision = result.get("allow"), result.get("revision")    decision_id = body.get("decision_id")    if not isinstance(allow, bool):        raise PolicyUnavailable("allow is not a boolean")    if not isinstance(revision, str) or not revision.strip():        raise PolicyUnavailable("no decision that names its revision")    if decision_id is not None and not isinstance(decision_id, str):        raise PolicyUnavailable("decision_id is not a string")    return PolicyDecision(allow, revision, decision_id)

Ce qu'il omet délibérément

  • La mise en cache des décisions. Un cache doit avoir dans sa clé la révision aussi bien que l'entrée.
  • L'endroit où la décision est enregistrée : dans la piste d'audit, comme sortie d'une étape de politique.
  • Le refus comme règle universelle. Refuser est le bon choix pour cet exemple ; le propriétaire de chaque décision décide de ce que signifie pour elle un moteur indisponible.

Comment le vérifier

Face à un vrai serveur OPA, la révision revient et la même entrée reçoit la même réponse. Face à un moteur simulé, un refus valide est une décision ; allow envoyé sous la forme de la chaîne "false", un allow manquant, une révision vide ou blanche, un résultat du mauvais type, un decision_id qui n'est pas une chaîne, un chemin non défini (OPA omet result), un corps qui n'est pas du JSON et un moteur injoignable lèvent chacun PolicyUnavailable (tests/test_policy.py).

Mode de défaillance traité

Un chemin de politique non défini lu comme un refus, une réponse malformée convertie en la décision opposée, ou une réponse sans révision enregistrée malgré tout : la décision existe et ne peut pas être reconstruite.

P-6 · Un fence, et sa preuve de dents

Exemple de référence illustratifUn fence de publication dont la liste n'est jamais publiée
Objet
Poser un fence sur la sortie construite, avec une liste de motifs qui reste hors de l'arborescence publiée.
Propriété d'architecture
Un état interdit dans la sortie construite arrête la publication (P-6). N'avoir rien analysé n'est pas une réussite, et un constat dit où, pas quoi.
refapp/fence.pypythonP-6
"""A publication fence over a built site. The patterns it matches are read from a file kept outside the build output:a list of what is watched is a map of what is protected, so the list itselfis never published and a finding reports where, not what.""" import pathlibimport reimport sys SCANNED_SUFFIXES = {".html", ".txt", ".json", ".js", ".xml", ".svg"}  def load_patterns(pattern_file: pathlib.Path) -> list[re.Pattern[str]]:    lines = pattern_file.read_text(encoding="utf-8").splitlines()    kept = [line for line in lines if line and not line.startswith("#")]    return [re.compile(line, re.IGNORECASE) for line in kept]  def scan(build_dir: pathlib.Path, patterns: list[re.Pattern[str]]) -> tuple[int, list[str]]:    scanned, findings = 0, []    for path in sorted(build_dir.rglob("*")):        if not path.is_file() or path.suffix not in SCANNED_SUFFIXES:            continue        scanned += 1        text = path.read_text(encoding="utf-8", errors="replace")        for number, line in enumerate(text.splitlines(), start=1):            if any(p.search(line) for p in patterns):                findings.append(f"{path.relative_to(build_dir)}:{number}")    return scanned, findings  def main(build_dir: str, pattern_file: str) -> int:    build = pathlib.Path(build_dir).resolve()    patterns_path = pathlib.Path(pattern_file).resolve()    if build in patterns_path.parents:        print("fence: the pattern file is inside the build output", file=sys.stderr)        return 2    patterns = load_patterns(patterns_path)    scanned, findings = scan(build, patterns)    if not patterns or scanned == 0:  # nothing checked is not the same as nothing found        print(f"fence: {len(patterns)} patterns, {scanned} files: refusing", file=sys.stderr)        return 2    for finding in findings:        print(f"fence: match at {finding}", file=sys.stderr)    print(f"fence: {scanned} files, {len(findings)} findings", file=sys.stderr)    return 1 if findings else 0  if __name__ == "__main__":    sys.exit(main(*sys.argv[1:3]))

Ce qu'il omet délibérément

  • Les motifs. L'exemple ne fournit qu'une sentinelle synthétique. Une vraie liste de motifs est une carte de ce qui est protégé, et c'est pourquoi COADF P-6 ne publie pas la sienne.
  • La revue sémantique. Un fence textuel ne peut pas voir un mécanisme exprimé par le flot de contrôle, par des identifiants renommés ou par la géométrie d'un schéma. Il est nécessaire et non suffisant.
  • Les formats binaires, et le contenu récupéré à l'exécution.

Comment le vérifier

La preuve de dents dans tests/test_fence.py.

Mode de défaillance traité

Un fence qui réussit parce qu'il n'a rien lu : un fichier de motifs vide, un répertoire de build sans fichiers, ou un fichier de motifs qui s'est retrouvé dans la sortie du build.

Exemple de référence illustratifPreuve de dents
Objet
Planter une sentinelle synthétique, voir échouer le vrai point d'entrée, restaurer les octets exacts, le voir réussir.
Propriété d'architecture
Un fence ne mérite sa place qu'une fois qu'on l'a vu échouer sur le vrai chemin (P-6).
tests/test_fence.pypythonP-6
"""Proof of teeth for the publication fence: plant a synthetic sentinel, watchthe real entry point fail, restore the exact bytes, watch it pass.""" import hashlib from refapp.fence import main # Not a real term of anything. It exists only to be caught.SENTINEL = "PLANTED-EXAMPLE-TOKEN-7F3Q"  def test_the_fence_has_teeth(tmp_path) -> None:    site = tmp_path / "site"    site.mkdir()    page = site / "index.html"    page.write_text("<main><p>Ordinary published copy.</p></main>", encoding="utf-8")    patterns = tmp_path / "patterns.txt"    patterns.write_text(SENTINEL + "\n", encoding="utf-8")    original = page.read_bytes()     assert main(str(site), str(patterns)) == 0     page.write_bytes(original + f"<p>{SENTINEL}</p>".encode())  # plant    assert main(str(site), str(patterns)) == 1     page.write_bytes(original)  # restore the exact bytes, and prove it    assert hashlib.sha256(page.read_bytes()).digest() == hashlib.sha256(original).digest()    assert main(str(site), str(patterns)) == 0  def test_nothing_scanned_is_not_a_pass(tmp_path) -> None:    (tmp_path / "empty-site").mkdir()    patterns = tmp_path / "patterns.txt"    patterns.write_text(SENTINEL + "\n", encoding="utf-8")    assert main(str(tmp_path / "empty-site"), str(patterns)) == 2  def test_a_pattern_file_inside_the_build_is_refused(tmp_path) -> None:    site = tmp_path / "site"    site.mkdir()    (site / "index.html").write_text("<p>copy</p>", encoding="utf-8")    leaked = site / "patterns.txt"    leaked.write_text(SENTINEL + "\n", encoding="utf-8")    assert main(str(site), str(leaked)) == 2

Ce qu'il omet délibérément

  • Un vrai vocabulaire protégé. La sentinelle est synthétique et ne signifie rien.
  • Toute affirmation selon laquelle un fence qui réussit rendrait la sortie sûre. La preuve montre que le chemin de la garde fonctionne, pas que la revue sémantique est complète.

Comment le vérifier

L'exécuter. Faire ensuite revenir scan prématurément dans une copie jetable, et le voir échouer.

Mode de défaillance traité

Une restauration qui n'est pas exacte. Restaurer de mémoire, ou depuis la tête d'une branche, peut modifier ou perdre du contenu en silence ; comparer le digest avant et après est ce qui prouve que la fixture est revenue.

Modes de défaillance

  1. Les réglages par défaut de Pydantic pris pour une frontière

    Par défaut, un modèle ignore les champs qu'il ne déclare pas, et hors du mode strict il convertit les valeurs compatibles. Pour une API, c'est de la tolérance ; pour une frontière, cela signifie qu'une réponse qui affirme "verified": true, ou un nombre envoyé sous forme de texte, passe sans laisser de trace. Activer extra="forbid" et le mode strict sur les modèles de frontière, et tester les deux.

  2. Un modèle de réponse qui perd la provenance

    FastAPI filtre chaque réponse selon son modèle de réponse. Un modèle de réponse écrit pour l'écran, sans les champs de provenance, les retire de chaque réponse, et la personne qui vérifie voit une valeur sans source.

  3. model_construct dans un chemin critique

    Il construit un modèle sans validation. Introduit pour la vitesse, il devient le chemin qui contourne la frontière.

  4. Un contexte qui s'arrête à un pool de threads

    asyncio.to_thread propage le contexte courant ; une soumission directe à un exécuteur ne le fait pas, pas plus que run_in_executor, sauf si l'appelant copie d'abord le contexte, comme le fait to_thread lui-même. Les spans et l'identité d'audit disparaissent alors à l'endroit précis où le travail est le plus lent.

  5. Une fabrique par défaut qui génère des identifiants de trace

    Field(default_factory=uuid4) sur un modèle de message a l'air inoffensif et transforme chaque identifiant manquant en un identifiant nouveau et sans rapport. En aval de la réception, l'identité d'audit est obligatoire et n'a jamais de valeur par défaut.

  6. Écritures d'audit reportées après la réponse

    Les tâches d'arrière-plan de FastAPI s'exécutent après l'envoi de la réponse. Une entrée d'audit écrite là peut disparaître avec le processus alors que le client a déjà appris que l'étape avait réussi. L'écrire dans la même transaction que la modification.

  7. Un seul modèle ORM pour les propositions et les enregistrements

    Une colonne de statut sur l'entité que tout le monde lit. Le jour où quelqu'un oublie un filtre, des propositions apparaissent comme des faits.

  8. Un protocole pris pour une vérification à l'exécution

    Un typing.Protocol est un contrat statique ; une vérification à l'exécution contre lui ne contrôle que l'existence des méthodes. Le comportement, ce sont les tests de contrat qui le contrôlent.

  9. Des tests ignorés lus comme des tests réussis

    Un test de base de données qui est ignoré faute de chaîne de connexion transforme une base absente dans la CI en exécution au vert. Compter ce qui s'est exécuté, et faire échouer le job quand les tests d'audit n'ont pas tourné.

Vérification

  • Test unitaire

    Réussit quand : pytest exécute les tests négatifs de frontière : champ non déclaré, type erroné, provenance manquante, attribut inattendu, date impossible, revue rejetée.

    Preuve de dents : Supprimer extra="forbid" dans une copie jetable : le test du champ non déclaré échoue. Le harnais le fait à chaque exécution.

  • Test d'architecture

    Réussit quand : lint-imports tient les deux contrats.

    Preuve de dents : Planter from refapp.records import accept dans inference.py : le contrat est rompu et la commande se termine avec un code non nul.

  • Test de contrat

    Réussit quand : L'API refuse les champs non déclarés avec 422, et ses réponses portent la provenance.

    Preuve de dents : Un modèle de réponse sans le champ de provenance renvoie l'objet sans lui ; le test qui affirme sa présence échoue.

  • Test d'intégration

    Réussit quand : Contre un vrai PostgreSQL : le rôle de l'application ne peut pas réécrire la piste, le propriétaire se heurte aux triggers, les nouvelles tentatives identiques sont absorbées, une nouvelle tentative en conflit est refusée, les corrections s'ajoutent.

    Preuve de dents : Le dernier test d'audit désactive les triggers en tant que propriétaire et supprime, ce qui démontre ce que les contrôles n'arrêtent pas.

  • Test d'intégration

    Réussit quand : Contre un vrai serveur OPA, la décision porte sa révision et se rejoue à l'identique ; contre un moteur simulé, les réponses sans révision ou d'un type erroné sont refusées.

    Preuve de dents : Convertir allow avec bool() au lieu de vérifier son type : le test qui envoie la chaîne "false" échoue. Le harnais le fait à chaque exécution.

  • Test de bout en bout

    Réussit quand : Le fence de publication passe sur la sortie construite.

    Preuve de dents : Une sentinelle synthétique est plantée, le fence échoue, les octets exacts sont restaurés et comparés, et le fence passe.

Réalisations alternatives

  • D'autres bibliothèques de validation. attrs avec cattrs, ou msgspec, offrent la même frontière avec d'autres compromis de vitesse et de rigueur. La propriété est le type strict, validé et porteur de provenance, pas la bibliothèque.
  • D'autres frameworks. Les serializers de Django REST framework ou Litestar expriment la même frontière. Le filtrage de la sortie diffère d'un framework à l'autre ; il faut le tester dans le sien.
  • Une immuabilité au niveau de l'application (un repository en insertion seule, sans méthode de mise à jour) à la place des triggers de base de données, là où la base est partagée ou les triggers ne peuvent pas être gérés. Plus faible face à un second client, et plus simple à exploiter.
  • Des tables de règles dans PostgreSQL au lieu d'OPA, là où les règles sont peu nombreuses et l'équipe possède déjà la base. L'exigence de révision est la même.

Compromis

  • La rigueur coûte de la friction. Les modèles stricts rejettent des entrées que des modèles laxistes auraient réparées, et chaque rejet demande une décision. Le mode strict est aussi plus souple pour une entrée JSON que pour des objets Python : les types de date acceptent des chaînes même en mode strict.
  • Deux identifiants coûtent de l'attention. Chaque développeur doit savoir lequel il a sous les yeux. Les nommer différemment dans le code, comme le font les exemples, et ne jamais utiliser l'un pour remplir l'autre.
  • Un ajout seul imposé par la base de données lie aux fonctionnalités de cette base. Les triggers et les droits sont ici propres à PostgreSQL ; une seconde base de données demande son propre équivalent, et ses propres tests.
  • Un moteur de politiques derrière HTTP ajoute un saut réseau et un mode de défaillance. Le client doit décider ce que signifie un moteur injoignable, pour chaque décision.

Limites

  • Les exemples sont illustratifs, petits et synthétiques. Ils laissent de côté l'authentification, l'autorisation, les migrations, le pool de connexions, l'accès asynchrone à la base de données et le déploiement.
  • Ils ont été testés dans l'environnement indiqué ci-dessus, le 11 septembre 2026, et nulle part ailleurs.
  • Le processus unique montré est une topologie parmi d'autres. Le profil Cloud-native montre ce qui change quand le côté probabiliste est déployé séparément.
  • Rien ici ne montre comment la confiance est représentée, quand une revue est requise, ni comment la revue est organisée ; COADF ne publie pas ces parties de P-2 et P-3.

Ce que ce profil n'établit pas

Suivre ce profil n'établit ni la conformité réglementaire, ni une certification, ni une évaluation de la conformité, et COADF n'exige rien de ce qui figure ici. Exécuter ces exemples établit que ces exemples se sont exécutés.

Environnement de référence testé

  • Python 3.12.13 and 3.14.4 · chaque test d'exemple exécuté sur les deux
  • FastAPI 0.141.1 · avec Starlette 1.6.0 et son client de test
  • Pydantic 2.13.5
  • httpx 0.28.1 · y compris son transport simulé
  • OpenTelemetry API and SDK 1.44.0 · exportateur de spans en mémoire
  • import-linter 2.15 · contrats tenus, puis rompus par un import planté
  • psycopg 3.3.5
  • PostgreSQL 18.6 · un cluster local jetable ; les tests d'audit s'exécutent contre lui
  • Open Policy Agent 1.20.2 · opa test, opa check --strict, et un serveur réel pour le test du client
  • pytest 9.1.1

Sources

COADF Engineering Companion 1.0 · non normatif · se rapporte à COADF Core 2.2

Droits de publication réservés. Aucune licence publique n'est accordée à ce jour pour le COADF Engineering Companion 1.0 ni pour ses exemples de référence.

Statut de propriété intellectuelle et de publication