Zum Inhalt springen

Unabhängiges F&E-Projekt · Köln

Python und FastAPI

Ein modularer Monolith mit typisierten Grenzen, Import-Verträgen, einer Append-only-Prüfspur in PostgreSQL und Regeln als Daten, mit getesteten Referenzbeispielen.

Nicht normativ

Companion-Version
1.0
Bezug zu COADF Core
2.2
Status
Aktuell
Zuletzt geprüft
Profilversion
1.0
Codebeispiele
Veranschaulichende Referenzbeispiele

Behandelte Architektureigenschaften

  • P-1Eine typisierte, validierte Grenze um den Modellaufruf; in dieser Referenzimplementierung entsteht ein maßgeblicher Wert erst nach der Verifikation durch eine namentlich benannte Person.
  • P-4Eine Audit-Identität neben dem OpenTelemetry-Kontext; eine Append-only-Prüfspur in PostgreSQL, deren Schreibvorgänge eine exakte Wiederholung auffangen und eine widersprechende ablehnen.
  • P-5Die Extraktionsmethode und die Fundstelle in der Quelle reisen mit jedem vorgeschlagenen Wert und überstehen die Serialisierung.
  • P-6Import-Verträge und ein Veröffentlichungs-Fence, jeweils mit Zahnbeweis.
  • P-7Ein Port, der der Domäne gehört, und ein Adapter, der als einziges Modul den Anbieter kennt.
  • P-8Entscheidungen aus versioniertem Policy-Material, mit ihrer Revision aufbewahrt oder abgelehnt.
  • P-2Nur in der Tiefe, in der COADF es veröffentlicht. Die Beispiele enthalten keine Darstellung von Konfidenz.
  • P-3Nur in der Tiefe, in der COADF es veröffentlicht: in dieser Referenzimplementierung Verifikation auf Attributebene durch eine namentlich benannte Person, ein abgelehnter Wert bleibt leer. Wann eine Prüfung erforderlich ist und wie Prüfungen organisiert sind, wird nicht gezeigt.

Architektonische Absicht

In einem Python-Service schreiben oft dieselben Leute den Modell-Client und den Domänencode, im selben Repository, am selben Nachmittag, und so landen die probabilistische und die maßgebliche Seite in einem Modul, das sich gegenseitig direkt aufruft. FastAPI und Pydantic machen es billig, die Grenze als Typen auszudrücken und zur Laufzeit zu validieren. Sie haben aber auch Voreinstellungen, die für eine Web-API richtig und für eine Grenze falsch sind: Ein Pydantic-Modell ignoriert Felder, die es nicht deklariert, und wandelt kompatible Typen um, solange der Strict Mode nicht aktiv ist, und FastAPI filtert jede Antwort auf ihr Response-Modell, ohne sich zu beschweren.

Dieses Profil zeigt einen Weg, die COADF-Eigenschaften in einem einzelnen Python-Service einzuhalten: ein modularer Monolith mit Import-Verträgen, PostgreSQL für die Prüfspur, OpenTelemetry für den Ausführungskontext, Open Policy Agent hinter einer Schnittstelle für die Policy. Nichts davon ist erforderlich. Jedes ist eine Stelle, an der die Eigenschaft entweder gehalten wird oder stillschweigend verloren geht.

Technologiezuordnung

Python und FastAPI: Technologiezuordnung
ArchitektureigenschaftPython und FastAPI
GrenztypEingefrorene Pydantic-Modelle mit extra="forbid" und strict=True; eine eigene Dataclass für verifizierte Werte
Laufzeitvalidierungmodel_validate_json auf der rohen Antwort; FastAPI-Request-Validierung, beantwortet mit 422
AusgabevertragEin explizites response_model, das die Herkunft deklariert
Architekturregelforbidden-Verträge von import-linter, ausgeführt durch lint-imports
AusführungskontextOpenTelemetry-Propagation (inject, extract); contextvars in Threads kopiert
PrüfspurPostgreSQL: für die Anwendungsrolle nur Insert und Select, Trigger, die Umschreibungen ablehnen, ein eindeutiger Schlüssel, dessen Konflikte verglichen werden, bevor sie als Wiederholung gelten
Isolierung von StandardsEin typing.Protocol-Port; ein httpx-Adapter, der die HTTP-Ergebnisse des Anbieters in „nicht gefunden“, „nicht verfügbar“ und „abgelehnt“ übersetzt
PolicyOPA über seine REST-API hinter einer kleinen Schnittstelle; die Antwort typgeprüft, nie umgewandelt, und die Revision bei jeder Entscheidung verlangt
Veröffentlichungs-FenceEin Scan der gebauten Ausgabe mit einer Musterdatei, die außerhalb davon liegt; ein per Digest geprüfter Zahnbeweis

Referenzmuster

Ein einzelnes Paket, refapp, in einer synthetischen Wartungsdomäne: Ein Modell schlägt aus dem Servicebericht eines Technikers das nächste Servicedatum und eine Bauteilklasse vor. Die Module sind die Grenzen.

Die Module des Referenzpakets und die Eigenschaft, die jedes davon hält
ModulRolleDarf nicht importieren
boundary.pyDer Vertrag, den eine Modellantwort erfüllen mussnichts aus der Domäne
inference.pyRuft ein Modell auf; gibt ein Proposal zurückrecords, audit, api
formats.pyDeterministische Prüfungen eines vorgeschlagenen WertsAdapter, HTTP-Clients
records.pyVerifizierte Werte; der geprüfte Weg hineinAdapter, HTTP-Clients
api.pyDie HTTP-Oberfläche der Grenze
tracing.pyAusführungskontext und Audit-Identität
audit.py, schema.sqlDie Append-only-Prüfspur
classification.pyDer Port für einen externen DienstAdapter, HTTP-Clients
adapters/Der Anbieter, und nur der Anbieter
policy.py, policy/Policy-Material, Regel und Client
fence.pyDer Veröffentlichungs-Fence

P-1 · Die Grenze

Veranschaulichendes ReferenzbeispielEin typisierter Vertrag um einen Modellaufruf
Zweck
Einem Modellaufruf einen Vertrag geben: was er zurückgeben darf und was mit dem Zurückgegebenen mitkommen muss. Eine bewusst unvollständige Referenzgrenze, gebaut nur aus dem, was COADF auf dieser Ebene veröffentlicht.
Architektureigenschaft
Eine probabilistische Komponente liefert einen Wert zusammen mit der Angabe, wie der Wert gewonnen wurde, und alles andere wird an der Grenze zurückgewiesen (P-1). Die Methode reist mit dem Wert, und genau das ermöglicht später die Offenlegung maschineller Extraktion (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)

Was es bewusst weglässt

  • Konfidenz, bewusst. Nach COADF P-1 meldet eine probabilistische Komponente mit ihrem Wert eine Konfidenz; das ist die öffentliche Architektureigenschaft. Wie Konfidenz ausgedrückt, vergeben und verwendet wird, ist ein zurückgehaltenes Implementierungsdetail von P-2, das COADF nur als Prinzip veröffentlicht. Diese Grenze trägt deshalb die Methode und die Herkunft, hat kein Konfidenzfeld und erfindet keine Darstellung dafür: Sie ist absichtlich unvollständig, nicht vollständig.
  • Der Modellaufruf, seine Wiederholungen und der Prompt. Die Grenze ist dieselbe, gleich welcher Client die Zeichenkette erzeugt hat.
  • Ob der Wert stimmt. Ein Schema prüft die Form; es kann nicht wissen, ob ein Datum das richtige für dieses Asset ist.
  • Speicherung. Ein Proposal ist eine Nachricht, kein Datensatz.

So lässt es sich prüfen

Den Parser mit Antworten füttern, die fast stimmen: ein nicht deklariertes Feld, eine Zahl, wo der Vertrag Text verlangt, eine fehlende Herkunft, ein Attribut, nach dem niemand gefragt hat. Jede muss eine Ausnahme auslösen (tests/test_boundary.py). Dann in einer Wegwerfkopie extra="forbid" löschen und zusehen, wie der Test für nicht deklarierte Felder scheitert.

Adressiertes Fehlermuster

Eine Modellantwort, die mit json.loads geparst und als Dictionary gelesen wird. Ein zusätzliches "verified": true, eine umgewandelte Zahl oder eine fehlende Quelle geht direkt durch, und der Wert wird ununterscheidbar von Daten, die jemand geprüft hat.

Veranschaulichendes ReferenzbeispielDeterministische Prüfungen um einen stochastischen Wert
Zweck
Den vorgeschlagenen Wert mit Code prüfen, der überhaupt nicht vom Modell abhängt.
Architektureigenschaft
Deterministische Validierung umschließt stochastische Ausgabe: Derselbe Wert erhält immer dasselbe Urteil, und das Urteil lässt sich allein aus dem Wert reproduzieren (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

Was es bewusst weglässt

  • Prüfungen, die andere Datensätze oder andere Quellen brauchen. Dieses Beispiel prüft einen Wert gegen die Regeln seines eigenen Attributs und gegen nichts sonst.
  • Lokalisierte Datumsformate. Der Vertrag legt absichtlich eine einzige Schreibweise fest.

So lässt es sich prüfen

Ein Wert, der dem Muster entspricht, aber kein Kalenderdatum ist (2026-02-30), wird nie verifiziert (tests/test_boundary.py).

Adressiertes Fehlermuster

Darauf vertrauen, dass das Modell einen gültigen Wert erzeugt hat, weil der Prompt danach verlangt hat: Die Prüfung, die nicht geschrieben wird, ist die, die in Produktion versagt.

Veranschaulichendes ReferenzbeispielEin geprüfter Pfad in maßgebliche Datensätze
Zweck
Ein konservativer Referenzpfad, auf dem ein aus einem Modell abgeleiteter Wert maßgeblich wird: In diesem Beispiel geht jeder solche Wert an eine namentlich benannte Person, die ihn neben seiner Quelle ansieht und annimmt oder ablehnt.
Architektureigenschaft
Eine probabilistische Komponente kann nicht unmittelbar zu einer maßgeblichen Ausgabe werden (P-1). In dieser Referenzimplementierung erreicht ein aus einem Modell abgeleiteter Wert einen Datensatz nur über menschliche Verifikation, und ein abgelehnter Wert lässt das Attribut leer (P-3, in der Tiefe, in der COADF es veröffentlicht). Es ist eine Implementierung, keine COADF-Topologie.
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)

Was es bewusst weglässt

  • Wann eine Prüfung erforderlich ist. COADF veröffentlicht, dass eine Prüfung durch Konfidenz ausgelöst wird; die Regel, die darüber entscheidet, ist nicht veröffentlicht, und dieses Beispiel definiert keine. Es verifiziert jeden aus einem Modell abgeleiteten Wert, die konservative Wahl, die auch der öffentliche Fence F-03 für Werte verlangt, die ein Sprachmodell abgeleitet hat.
  • Wie Prüfungen organisiert sind. Wie Prüfungen organisiert werden (Warteschlangen, Zuweisung, Auslöser), veröffentlicht COADF nicht, und hier wird nichts davon unterstellt; dasselbe gilt für ihre Abfolge. Der Code nimmt jeweils einen Vorschlag und eine Entscheidung entgegen.
  • Die Authentifizierung der prüfenden Person. reviewer ist hier eine Zeichenkette; in einem echten System ist es eine authentifizierte Identität, geprüft dort, wo die Entscheidung eingeht.
  • Schutz dagegen, dass jemand in der Entwicklung VerifiedValue von Hand baut. Der Typ macht eine versehentliche Hochstufung sichtbar, der Import-Vertrag macht sie aus dem Inferenzmodul unmöglich, und das Code-Review deckt den Rest ab.

So lässt es sich prüfen

Ein abgelehnter Vorschlag liefert None; ein angenommener behält seine Herkunft; ein leerer Name der prüfenden Person wird zurückgewiesen (tests/test_boundary.py).

Adressiertes Fehlermuster

Ein verified-Flag auf dem Objekt, das das Modell erzeugt hat, standardmäßig wahr oder von dem Code gesetzt, der den Datensatz gerade speichert. Inferierte und verifizierte Werte werden in der Speicherung dasselbe, und nichts weiter hinten kann sie auseinanderhalten.

Veranschaulichendes ReferenzbeispielDie Grenze als Regel, die der Build durchsetzt
Zweck
Die Grenze von einem Kasten in einem Diagramm zu einer Prüfung machen, die den Build scheitern lässt.
Architektureigenschaft
Das probabilistische Modul hat keinen Importpfad zu maßgeblichen Datensätzen oder zur Prüfspur, und Domänencode hat keinen zu Anbieteradaptern oder HTTP-Clients (P-1, P-7). Die Regel läuft bei jeder Änderung (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 = ["."]

Was es bewusst weglässt

  • Importe, die zur Laufzeit über einen Namen erfolgen, etwa importlib.import_module mit einer berechneten Zeichenkette. Ein statischer Import-Vertrag prüft die Import-Anweisungen, die er lesen kann; ein Plugin-Loader braucht eine eigene Prüfung.
  • Datenpfade, die nicht über Importe laufen: eine gemeinsame Datenbankverbindung, eine Queue, die beide Seiten erreichen. Die Datenbankberechtigungen und die Netzwerk-Policy im Cloud-native-Profil behandeln diese.
  • Ein Schichtenvertrag für die gesamte Anwendung. Zwei Verbotsverträge genügen, um den Mechanismus zu zeigen.

So lässt es sich prüfen

lint-imports meldet beide Verträge als eingehalten. In einer Wegwerfkopie from refapp.records import accept an inference.py anhängen: Der erste Vertrag wird als gebrochen gemeldet, und der Befehl endet mit einem Exit-Code ungleich null. Die Zeile entfernen, und er ist wieder eingehalten.

Adressiertes Fehlermuster

Die Grenze existiert als Kasten in einem Architekturdiagramm und nirgends sonst, bis jemand in Eile einen Wert von der anderen Seite braucht.

Veranschaulichendes ReferenzbeispielNegative Tests: je ein eingebauter Fehler
Zweck
Die Grenze an dem verifizieren, was sie zurückweist, nicht nur an dem, was sie annimmt.
Architektureigenschaft
Jeder Test baut einen Fehler ein, den die Grenze aufhalten soll, sodass das Löschen einer Einschränkung einen Test rot färbt.
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=" "))

Was es bewusst weglässt

  • Generierte fehlerhafte Eingaben. Eine kurze Liste benannter Fehler lässt sich leichter prüfen, und jeder dokumentiert ein Fehlermuster.
  • Alles über die Qualität der Antworten des Modells. Die Grenze macht ein Modell nicht richtig.

So lässt es sich prüfen

pytest ausführen. Die Suite hat nur dann Biss, wenn das Entfernen einer Einschränkung einen Test scheitern lässt; das Harness löscht extra="forbid" in einer Wegwerfkopie, und der Test für nicht deklarierte Felder scheitert.

Adressiertes Fehlermuster

Eine Suite, die nur den Gutfall füttert: Jede Einschränkung könnte gelöscht werden, und sie bliebe grün.

Veranschaulichendes ReferenzbeispielDas Response-Modell ist Teil des Vertrags
Zweck
Die HTTP-Oberfläche der Grenze: was ein Inferenz-Worker senden darf und was der Bildschirm einer prüfenden Person erhält.
Architektureigenschaft
FastAPI validiert die Anfrage gegen das Modell und filtert die Antwort auf das Response-Modell, deshalb muss die Herkunft, einschließlich der Fundstelle, die eine prüfende Person braucht, um den Wert neben seiner Quelle zu sehen, deklariert sein, sonst verlässt sie den Dienst nie.
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]

Was es bewusst weglässt

  • Authentifizierung und Autorisierung beider Endpunkte.
  • Persistenz. Ein Dictionary steht für die Speicherung.
  • Der Entscheidungsendpunkt, über den eine prüfende Person annimmt oder ablehnt; siehe records.accept.
  • Jede Darstellungsreihenfolge. Die Liste kommt in Einfügereihenfolge zurück, und diese Abfolge ist nicht als Arbeitsfolge gemeint. Wie Prüfungen organisiert sind, veröffentlicht COADF nicht; das schließt ihre Abfolge ein, und nichts hier legt sie fest.

So lässt es sich prüfen

Eine Antwort mit einem zusätzlichen Feld verified senden: 422. Eine wohlgeformte senden und auflisten: Die Herkunft steht in der Antwort. Dann ein Response-Modell ohne die Herkunft deklarieren: Dasselbe Objekt kommt ohne sie zurück, und nichts scheitert (tests/test_api.py reproduziert das).

Adressiertes Fehlermuster

Herkunft, die bei der Serialisierung verloren geht. Ein Response-Modell, das die Herkunft auslässt, verwirft sie stillschweigend, und der Prüfbildschirm zeigt einen Wert, gegen den sich nichts prüfen lässt.

P-4 · Der Trace und die Prüfspur

Veranschaulichendes ReferenzbeispielAusführungskontext und Prüfspur-Identität, nebeneinander
Zweck
Den Ausführungskontext über eine Nachricht und einen Thread hinweg tragen und die Identität der Prüfspur daneben tragen, niemals an seiner Stelle.
Architektureigenschaft
Eine nachverfolgbare Identität verbindet die Schritte eines Vorgangs (P-4). Der OpenTelemetry-Kontext wird über ein Span-Attribut mit ihr korreliert und nie an ihre Stelle gesetzt.
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)

Was es bewusst weglässt

  • Exporter und Sampler; siehe die Collector-Konfiguration im Cloud-native-Profil.
  • Ein echter Broker-Client. send ist jede Funktion, die Header und einen Body entgegennimmt.
  • Wo die trace_id der Prüfspur erzeugt wird: bei der Aufnahme, einmal, und nirgends weiter hinten.
  • Baggage. Die Identität der Prüfspur reist im Nachrichten-Body, wo sie Teil des Vertrags ist, und nicht in Baggage, das die Instrumentierung an nachgelagerte Dienste weitergibt, auch an Dritte.
  • Was die Korrelation offenlegt. Das Span-Attribut kopiert die trace_id der Prüfspur in die Telemetrie, die eigene Exporter, Anbieter, Zugriffskontrollen und Aufbewahrung hat, meist lockerer als die des Prüfspur-Speichers. Sie wird vor dem Export klassifiziert: eine opake Referenz, niemals personenbezogene Daten oder ein Geheimnis. Trägt ein Prüfspur-Identifikator Bedeutung, wird stattdessen über eine separate, nicht sensible Referenz korreliert.

So lässt es sich prüfen

Producer- und Consumer-Span teilen sich einen verteilten Trace, und der Span des Consumers trägt die Identität der Prüfspur. Eine Nachricht ohne diese Identität löst KeyError aus, statt eine neue zu erzeugen. Eine bloße Übergabe an einen Thread-Pool verliert den Kontext; der kopierte Kontext erhält ihn; asyncio.to_thread erhält ihn (tests/test_tracing.py).

Adressiertes Fehlermuster

Der Trace-Identifikator wird auf halbem Weg neu erzeugt: Ein Consumer, der auf einen frischen Identifikator ausweicht, wenn das Feld fehlt, oder ein Worker-Thread, der ohne den Kontext des Aufrufers startet, hinterlässt zwei Hälften, die keine Abfrage zusammenführen kann.

Veranschaulichendes ReferenzbeispielEine Append-only-Prüfspur in PostgreSQL
Zweck
Eine Prüfspur mit den neun Feldern, die COADF veröffentlicht, so gespeichert, dass die Anwendung ergänzen und lesen kann und die Datenbank ein Umschreiben verweigert.
Architektureigenschaft
Die Spur ist append-only, und eine Korrektur ist ein neuer Eintrag (P-4). Ein Schritt hat eine Identität: Eine exakte Wiederholung ist derselbe Eintrag, und dieselbe Identität mit anderem Inhalt weist audit.py zurück.
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();

Was es bewusst weglässt

  • Manipulationsnachweis. Berechtigungen und Trigger sind Kontrollen innerhalb der eigenen Vertrauensgrenze der Datenbank: Ein Superuser umgeht Berechtigungsprüfungen, und der Eigentümer der Tabelle kann ihre Trigger deaktivieren. Um ein Umschreiben durch jemanden mit diesen Rechten zu erkennen, braucht es eine Kontrolle außerhalb der Datenbank, die dieses Beispiel nicht zeigt.
  • Aufbewahrung, Archivierung und Export.
  • Das Ereignisvokabular. event_type ist ein Pflichttext. Welche Ereignisse eine Anwendung festhält, ist ihr eigener Entwurf und bewusst nicht Teil dieses Beispiels.
  • Eine Ordnung unter Einträgen mit demselben Zeitstempel. Die Rekonstruktion ordnet nach Zeitstempel, und gleiche Zeitstempel ordnet dieses Beispiel absichtlich nicht: Kein veröffentlichtes Feld ordnet sie. Ein einziger Schreiber pro Transaktion oder eine von einem solchen vergebene Ordnung ist eine Speicherentscheidung jenseits der veröffentlichten Felder.
  • Die Migration und die Rolle, der die Tabelle gehört. Die Anwendung verbindet sich nie als diese Rolle.

So lässt es sich prüfen

Gegen ein echtes PostgreSQL: Die Anwendungsrolle kann nicht aktualisieren, löschen oder leeren; der Eigentümer stößt auf die Trigger; eine exakte Wiederholung fügt nichts hinzu; dieselbe Identität mit anderem Inhalt wird zurückgewiesen; eine Korrektur fügt einen Eintrag hinzu; und, absichtlich, der Eigentümer kann die Trigger deaktivieren und löschen (tests/test_audit.py).

Adressiertes Fehlermuster

Eine Korrektur, die als UPDATE geschrieben wird und damit die Aufzeichnung dessen zerstört, was vorher angenommen wurde, und derselbe Verlust durch TRUNCATE, das überhaupt keinen DELETE-Trigger auslöst.

Veranschaulichendes ReferenzbeispielIdempotentes Anhängen, das einen Konflikt zurückweist, Rekonstruktion mit einer Abfrage
Zweck
Eine Wiederholung harmlos und einen widersprüchlichen Schreibvorgang laut machen und einen Vorgang mit einer einzigen Abfrage zurücklesen.
Architektureigenschaft
Der Zeitstempel wird gesetzt, wenn der Schritt läuft, sodass eine Wiederholung den identischen Eintrag erneut sendet. Bei einem Konflikt wird zuerst der gespeicherte Eintrag verglichen: Derselbe Inhalt ist eine Wiederholung, anderer Inhalt löst IdempotencyCollision aus. Die Rekonstruktion ist eine Abfrage über trace_id, geordnet nach Zeitstempel; gleiche Zeitstempel werden absichtlich nicht geordnet (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()

Was es bewusst weglässt

  • Die Transaktion, die den Eintrag zusammen mit der Zustandsänderung schreibt, die er festhält. Getrennt geschrieben, kann das eine ohne das andere existieren.
  • Export als JSON, den P-4 ebenfalls verlangt.
  • Verbindungsverwaltung und die Frage, welche Rolle die Verbindung verwendet.
  • Welcher von zwei widersprüchlichen Einträgen richtig ist. Das Beispiel weist den zweiten zurück und behält den ersten; zwischen ihnen zu entscheiden ist Aufgabe einer Person, nicht der Datenbank.
  • Nebenläufige Schreiber desselben Schritts. Das Beispiel wurde mit jeweils einem Schreiber getestet; bei Nebenläufigkeit ist zu prüfen, was die eigene Isolationsstufe den vergleichenden Lesevorgang sehen lässt.

So lässt es sich prüfen

Denselben Eintrag zweimal anhängen: True, dann False, und eine Zeile. Dieselbe Identität mit einer anderen Ausgabe anhängen: IdempotencyCollision, und der gespeicherte Eintrag bleibt unverändert (tests/test_audit.py). Mit chain() rekonstruieren.

Adressiertes Fehlermuster

Wiederholungen, die mehrdeutige Ereignisse erzeugen, und Konflikte, die verschwinden: Ein beim Einfügen genommener Zeitstempel macht jede Wiederholung zu einem neuen Eintrag, und ein bloßes ON CONFLICT DO NOTHING verwirft ohne Fehler einen anderen Eintrag, der zufällig denselben Schlüssel hat.

Veranschaulichendes ReferenzbeispielPersistenz verifizieren, nicht Logs
Zweck
Die Append-only-Eigenschaft gegen die echte Durchsetzungsschicht prüfen und die Grenze als Test festschreiben.
Architektureigenschaft
Die Eigenschaft wird dort geprüft, wo sie durchgesetzt wird, in der Datenbank; was die Kontrollen nicht verhindern, ist ebenfalls ein Test und kann so nicht vergessen werden.
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") == []

Was es bewusst weglässt

  • Eine Datenbank in der Continuous Integration. Die Tests werden ohne REFAPP_PG_DSN übersprungen, und ein Überspringen ist kein Bestehen: Der Job, der sie ausführt, muss die Datenbank bereitstellen oder die Prüfung als nicht ausgeführt melden.

So lässt es sich prüfen

Gegen ein Wegwerf-PostgreSQL ausführen: Alle sechs Tests bestehen, einschließlich der widersprüchlichen Wiederholung, die scheitern muss, und des Tests, der belegt, dass der Eigentümer löschen kann.

Adressiertes Fehlermuster

Falsche Unveränderlichkeit: ein Trigger, der als Beweis gilt, dass sich die Historie nicht ändern kann, obwohl die Rolle, der die Tabelle gehört, ihn abschalten kann.

P-7 · Der Port und der Adapter

Veranschaulichendes ReferenzbeispielEin Port im Besitz der Domäne
Zweck
Die Seite der Domäne gegenüber einem externen Klassifikationsdienst: ein Port und die Typen dahinter.
Architektureigenschaft
Externe Vokabulare bleiben hinter einem Port, der der Domäne gehört. Ein ausfallender Dienst macht den Datensatz weniger aussagekräftig und hält ihn nie auf, und der Datensatz sagt, ob der Dienst nicht verfügbar war oder die Integration defekt ist (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")

Was es bewusst weglässt

  • Was eine externe Antwort mit der Konfidenz macht. COADF P-7 sagt, dass ein externer Dienst die Konfidenz eines Attributs erhöhen und nie eine Ausgabe blockieren darf; wie die Erhöhung funktioniert, ist nicht veröffentlicht. Hier wird die Antwort mit ihrem Status festgehalten, und sonst nichts.
  • Caching und die Frage, wie lange eine Lizenz erlaubt, eine externe Antwort aufzubewahren.
  • Die Abbildung externer Codes auf die eigenen Begriffe der Domäne, die in einem echten System versionierte Daten ist. Ein neuer Anbieter oder Transport bleibt im Adapter; eine neue Version des Schemas selbst kann ändern, was Codes bedeuten, und dann ändert sich auch diese Abbildung.
  • Wer benachrichtigt wird. rejected heilt nicht durch Wiederholen und braucht deshalb eine Warnung, die unavailable vielleicht nicht braucht.

So lässt es sich prüfen

Ein Test-Double erfüllt den Port; ein Ausfall hält unavailable fest, und eine defekte Integration hält rejected fest (tests/test_classification.py). Der Import-Vertrag hält httpx und die Adapter aus diesem Modul heraus.

Adressiertes Fehlermuster

Ein Ausfall eines entfernten Dienstes, der unbeteiligte Verarbeitung blockiert: Die Domäne ruft den Anbieter direkt auf, synchron und ohne Timeout, und ein Vorfall beim Anbieter wird zum eigenen Vorfall.

Veranschaulichendes ReferenzbeispielDie Namen des Anbieters enden am Adapter
Zweck
Das einzige Modul, das die HTTP-Struktur und die Namen des Anbieters kennt.
Architektureigenschaft
classId und restricted des Anbieters werden in code und licensed der Domäne übersetzt, die Version des Schemas wird bei jedem Aufruf fixiert, und die HTTP-Ergebnisse des Anbieters werden zu drei Ergebnissen der Domäne: nicht gefunden, nicht verfügbar (vorübergehend) und abgelehnt (die Integration ist falsch). Das HTTP-Vokabular des Anbieters wird nicht zu dem der Domäne (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)

Was es bewusst weglässt

  • Wiederholungen, Circuit Breaking und Rate Limits.
  • Authentifizierung gegenüber dem Anbieter.
  • Die Lizenzbedingungen selbst und jeder lizenzierte Inhalt: Dieses Beispiel enthält keinen.
  • Eine feinere Taxonomie. Drei Ergebnisse genügen, um einen Ausfall von einer defekten Integration zu unterscheiden; ein echter Adapter braucht vielleicht mehr.

So lässt es sich prüfen

Mit httpx.MockTransport: Ein 200 wird übersetzt; ein 404 ist None; ein Timeout, ein Verbindungsfehler, ein 429 oder ein 5xx ist LookupUnavailable; ein 400, ein 401, ein nicht boolesches restricted, ein fehlendes Feld oder ein Body, der kein JSON ist, ist LookupRejected; jede Anfrage trägt die fixierte Version.

Adressiertes Fehlermuster

Das SDK des Anbieters wird zum Domänenmodell: Seine Klassen erscheinen in Signaturen der Domäne, seine Identifikatoren werden zu interner Bedeutung, und eine Lizenz- oder API-Änderung bedeutet, Domänencode neu zu schreiben. Oder jeder Fehler wird als dasselbe unavailable gemeldet, sodass eine widerrufene Zugangsberechtigung wie ein Ausfall aussieht und niemand sie behebt.

P-8 · Regeln als Daten

Veranschaulichendes ReferenzbeispielRegelmaterial mit seiner Revision
Zweck
Das Material, das eine Regel liest: welche Features jede Umgebung aktivieren darf, und die Revision dieses Materials.
Architektureigenschaft
Regeln sind Daten. Wer ändert, was erlaubt ist, ändert diese Datei, nicht die Engine und nicht die Regel (P-8).
policy/data.jsonjsonP-8
{  "refapp": {    "revision": "2026-09-01.2",    "features": {      "staging": ["report-export", "beta-search"],      "production": ["report-export"]    }  }}

Was es bewusst weglässt

  • Wie Regelmaterial auf dem Weg zur Engine geschützt und bei der Ankunft geprüft wird. Diesen Teil von P-8 veröffentlicht COADF nicht, und dieses Beispiel zeigt nichts davon.
  • Wie Revisionen vergeben werden. Die Bundle-Dokumentation von OPA selbst verwendet einen Git-Commit-Hash als Beispielrevision. Nichts in diesem Beispiel verhindert, dass sich das Material unter derselben Revision ändert; das leistet erst eine Revision, die aus dem Commit oder dem Inhalt abgeleitet wird.

So lässt es sich prüfen

beta-search in production erlauben: Die Tests, die die Antwort für die Produktion festschreiben, scheitern, während die Regel unberührt bleibt.

Adressiertes Fehlermuster

Regeln, die in mehreren Diensten dupliziert und fest codiert sind, jede nach eigenem Zeitplan geändert, sodass dieselbe Anfrage an einer Stelle erlaubt und an einer anderen abgelehnt wird.

Veranschaulichendes ReferenzbeispielEine Regel, die mit ihrer Revision antwortet
Zweck
Eine Regel, die ihr Material aus Daten liest und mit der Revision antwortet, die die Antwort erzeugt hat.
Architektureigenschaft
Die Entscheidung trägt den Nachweis der Regelversion (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,}

Was es bewusst weglässt

  • Alles über Konfidenz, Prüfung, Veröffentlichung oder Autonomie. Der Gegenstand ist bewusst generisch.
  • Bundles und Decision Logs, die in OPA von sich aus eine Bundle-Revision tragen können. Das explizite revision hält dieses Beispiel in sich geschlossen.
  • Ob eine echte Regel standardmäßig ablehnen sollte. Hier ist der Standard false; für jede echte Regel ist das eine Entscheidung der für sie verantwortlichen Person.

So lässt es sich prüfen

opa test policy/ führt sechs Tests aus, darunter einen falsch geschriebenen Eingabeschlüssel, der auf den Standard zurückfällt, statt einen Fehler auszulösen, und eine Wiederholung derselben Eingabe unter derselben Revision.

Adressiertes Fehlermuster

Ein Neuladen der Regeln, das die Bedeutung historischer Entscheidungen verändert: Ohne die Revision an der Entscheidung kann ein heute erstellter Bericht nicht sagen, welche Regeln die Antworten des letzten Monats erzeugt haben.

Veranschaulichendes ReferenzbeispielDie Entscheidung mit ihrer Revision aufbewahren oder gar nicht
Zweck
Die Seite der Anwendung: die Engine über eine kleine Schnittstelle fragen und jede Antwort zurückweisen, die ihre Revision nicht nennt.
Architektureigenschaft
Eine Entscheidung wird mit der Revision aufbewahrt, die sie erzeugt hat, oder sie wird nicht aufbewahrt (P-8). Die Antwort wird so streng geparst, wie sie aufbewahrt wird: allow muss ein Boolean sein und revision eine nicht leere Zeichenkette, weil in Python bool("false") True ist. Die Engine sitzt hinter einem Adapter, sodass der Entscheidungssatz seine Struktur behält, wenn sich die Engine ändert.
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)

Was es bewusst weglässt

  • Caching von Entscheidungen. Ein Cache muss die Revision ebenso als Schlüssel verwenden wie die Eingabe.
  • Wo die Entscheidung festgehalten wird: in der Prüfspur, als Ausgabe eines Policy-Schritts.
  • Ablehnung als allgemeine Regel. Ablehnen ist für dieses Beispiel richtig; wer für eine Entscheidung verantwortlich ist, entscheidet, was eine nicht verfügbare Engine für sie bedeutet.

So lässt es sich prüfen

Gegen einen echten OPA-Server kommt die Revision zurück, und dieselbe Eingabe wird gleich beantwortet. Gegen eine gemockte Engine ist eine gültige Ablehnung eine Entscheidung; allow als Zeichenkette "false" gesendet, ein fehlendes allow, eine leere oder nur aus Leerzeichen bestehende Revision, ein Ergebnis vom falschen Typ, eine decision_id, die keine Zeichenkette ist, ein undefinierter Pfad (OPA lässt result weg), ein Body, der kein JSON ist, und eine nicht erreichbare Engine lösen jeweils PolicyUnavailable aus (tests/test_policy.py).

Adressiertes Fehlermuster

Ein undefinierter Policy-Pfad, der als Ablehnung gelesen wird, eine fehlerhafte Antwort, die in die gegenteilige Entscheidung umgewandelt wird, oder eine Antwort ohne Revision, die trotzdem festgehalten wird: Die Entscheidung existiert und lässt sich nicht rekonstruieren.

P-6 · Ein Fence und sein Zahnbeweis

Veranschaulichendes ReferenzbeispielEin Veröffentlichungs-Fence, dessen Liste nie veröffentlicht wird
Zweck
Die gebaute Ausgabe mit einem Fence umgeben, dessen Musterliste außerhalb des veröffentlichten Baums bleibt.
Architektureigenschaft
Ein verbotener Zustand in der gebauten Ausgabe stoppt die Veröffentlichung (P-6). Nichts gescannt ist kein Bestehen, und ein Befund sagt, wo, nicht was.
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]))

Was es bewusst weglässt

  • Die Muster. Das Beispiel liefert nur einen synthetischen Sentinel mit. Eine echte Musterliste ist eine Karte dessen, was geschützt wird, und deshalb veröffentlicht COADF P-6 seine eigene nicht.
  • Semantische Prüfung. Ein Text-Fence sieht keinen Mechanismus, der sich in Kontrollfluss, umbenannten Bezeichnern oder der Geometrie eines Diagramms ausdrückt. Er ist notwendig und nicht hinreichend.
  • Binärformate und Inhalte, die zur Laufzeit abgerufen werden.

So lässt es sich prüfen

Der Zahnbeweis in tests/test_fence.py.

Adressiertes Fehlermuster

Ein Fence, der besteht, weil er nichts gelesen hat: eine leere Musterdatei, ein Build-Verzeichnis ohne Dateien oder eine Musterdatei, die in der Build-Ausgabe gelandet ist.

Veranschaulichendes ReferenzbeispielZahnbeweis
Zweck
Einen synthetischen Sentinel einbauen, zusehen, wie der echte Eintrittspunkt scheitert, die exakten Bytes wiederherstellen, zusehen, wie er besteht.
Architektureigenschaft
Ein Fence verdient seinen Platz erst, wenn man ihn auf dem echten Pfad hat scheitern sehen (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

Was es bewusst weglässt

  • Echtes geschütztes Vokabular. Der Sentinel ist synthetisch und bedeutet nichts.
  • Jede Behauptung, ein bestehender Fence mache die Ausgabe sicher. Der Beweis zeigt, dass der Pfad des Wächters funktioniert, nicht, dass die semantische Prüfung vollständig ist.

So lässt es sich prüfen

Ausführen. Dann in einer Wegwerfkopie scan vorzeitig zurückkehren lassen und zusehen, wie der Test scheitert.

Adressiertes Fehlermuster

Eine Wiederherstellung, die nicht exakt ist. Eine Wiederherstellung aus dem Gedächtnis oder vom Stand eines Branches kann Inhalte stillschweigend ändern oder verlieren; erst der Vergleich des Digests vorher und nachher belegt, dass das Fixture zurückgekommen ist.

Fehlermuster

  1. Pydantics Voreinstellungen als Grenze behandelt

    Standardmäßig ignoriert ein Modell Felder, die es nicht deklariert, und außerhalb des Strict Mode wandelt es kompatible Werte um. Für eine API ist das nachsichtig; für eine Grenze bedeutet es, dass eine Antwort, die "verified": true behauptet, oder eine als Text gesendete Zahl spurlos durchgeht. Auf Grenzmodellen extra="forbid" und den Strict Mode setzen und beides testen.

  2. Ein Response-Modell, das die Herkunft verwirft

    FastAPI filtert jede Antwort auf ihr Response-Modell. Ein Response-Modell, das für den Bildschirm geschrieben wurde, ohne die Herkunftsfelder, entfernt sie aus jeder Antwort, und die prüfende Person sieht einen Wert ohne Quelle.

  3. model_construct in einem Hot Path

    Es baut ein Modell ohne Validierung. Der Geschwindigkeit wegen eingeführt, wird es zu dem Weg, der die Grenze umgeht.

  4. Kontext, der an einem Thread-Pool endet

    asyncio.to_thread gibt den aktuellen Kontext weiter; eine bloße Übergabe an einen Executor tut es nicht, und run_in_executor ebenso wenig, es sei denn, der Aufrufer kopiert den Kontext vorher, so wie to_thread selbst es tut. Spans und die Audit-Identität verschwinden dann genau dort, wo die Arbeit am langsamsten ist.

  5. Eine Default-Factory, die Trace-Kennungen erzeugt

    Field(default_factory=uuid4) auf einem Nachrichtenmodell sieht harmlos aus und macht aus jeder fehlenden Kennung eine neue, unzusammenhängende. Nach der Aufnahme ist die Audit-Identität Pflicht und hat nie einen Standardwert.

  6. Audit-Schreibvorgänge auf nach der Antwort verschoben

    Die Background Tasks von FastAPI laufen, nachdem die Antwort zurückgegeben wurde. Ein dort geschriebener Audit-Eintrag kann mit dem Prozess verloren gehen, nachdem dem Client bereits mitgeteilt wurde, dass der Schritt gelungen ist. Ihn in derselben Transaktion wie die Änderung schreiben.

  7. Ein ORM-Modell für Vorschläge und Datensätze

    Eine Statusspalte auf der Entität, die alle lesen. An dem Tag, an dem jemand einen Filter vergisst, erscheinen Vorschläge als Tatsachen.

  8. Ein Protocol als Laufzeitprüfung verstanden

    Ein typing.Protocol ist ein statischer Vertrag; eine Laufzeitprüfung dagegen stellt nur fest, dass die Methoden existieren. Das Verhalten prüfen die Vertragstests.

  9. Übersprungene Tests, gelesen als bestandene Tests

    Ein Datenbanktest, der ohne Verbindungszeichenfolge übersprungen wird, macht aus einer fehlenden Datenbank in der CI einen grünen Lauf. Zählen, was ausgeführt wurde, und den Job scheitern lassen, wenn die Audit-Tests nicht gelaufen sind.

Verifikation

  • Unit-Test

    Bestanden, wenn: pytest führt die negativen Grenztests aus: nicht deklariertes Feld, falscher Typ, fehlende Herkunft, unerwartetes Attribut, unmögliches Datum, abgelehnte Prüfung.

    Zahnbeweis: extra="forbid" in einer Wegwerfkopie löschen: Der Test für das nicht deklarierte Feld schlägt fehl. Das Harness tut das bei jedem Lauf.

  • Architekturtest

    Bestanden, wenn: lint-imports hält beide Verträge ein.

    Zahnbeweis: from refapp.records import accept in inference.py einbauen: Der Vertrag ist gebrochen, und der Befehl endet mit einem Exit-Code ungleich null.

  • Vertragstest

    Bestanden, wenn: Die API lehnt nicht deklarierte Felder mit 422 ab, und ihre Antworten tragen die Herkunft.

    Zahnbeweis: Ein Response-Modell ohne das Herkunftsfeld gibt das Objekt ohne es zurück; der Test, der sein Vorhandensein prüft, schlägt fehl.

  • Integrationstest

    Bestanden, wenn: Gegen ein echtes PostgreSQL: Die Anwendungsrolle kann die Prüfspur nicht umschreiben, der Eigentümer stößt auf die Trigger, exakte Wiederholungen werden aufgefangen, eine widersprechende Wiederholung wird abgelehnt, Korrekturen kommen hinzu.

    Zahnbeweis: Der letzte Audit-Test deaktiviert als Eigentümer die Trigger und löscht, und belegt damit, was die Kontrollen nicht aufhalten.

  • Integrationstest

    Bestanden, wenn: Gegen einen echten OPA-Server trägt die Entscheidung ihre Revision und lässt sich identisch wiederholen; gegen eine gemockte Engine werden Antworten ohne Revision oder mit falschem Typ abgelehnt.

    Zahnbeweis: allow mit bool() umwandeln, statt seinen Typ zu prüfen: Der Test, der die Zeichenkette "false" sendet, schlägt fehl. Das Harness tut das bei jedem Lauf.

  • End-to-End-Test

    Bestanden, wenn: Der Veröffentlichungs-Fence besteht auf der gebauten Ausgabe.

    Zahnbeweis: Ein synthetischer Sentinel wird eingebaut, der Fence schlägt fehl, die exakten Bytes werden wiederhergestellt und verglichen, und der Fence besteht.

Alternative Umsetzungen

  • Andere Validierungsbibliotheken. attrs mit cattrs oder msgspec ergeben dieselbe Grenze, mit anderen Abwägungen bei Geschwindigkeit und Strenge. Die Eigenschaft ist der strikte, validierte Typ, der die Herkunft mitführt, nicht die Bibliothek.
  • Andere Frameworks. Serializer von Django REST framework oder Litestar drücken dieselbe Grenze aus. Das Verhalten beim Filtern der Ausgabe unterscheidet sich je Framework; es im eigenen testen.
  • Unveränderlichkeit auf Anwendungsebene (ein Repository, das nur einfügt, ohne Update-Methode) anstelle von Datenbank-Triggern, wo die Datenbank geteilt ist oder die Trigger nicht verwaltet werden können. Schwächer gegenüber einem zweiten Client und einfacher zu betreiben.
  • Regeltabellen in PostgreSQL statt OPA, wo es wenige Regeln gibt und das Team die Datenbank ohnehin verantwortet. Die Anforderung an die Revision ist dieselbe.

Abwägungen

  • Strenge kostet Reibung. Strikte Modelle lehnen Eingaben ab, die nachsichtige repariert hätten, und jede Ablehnung verlangt eine Entscheidung. Der Strict Mode ist für JSON-Eingaben zudem lockerer als für Python-Objekte: Datumstypen akzeptieren auch im Strict Mode Zeichenketten.
  • Zwei Kennungen kosten Aufmerksamkeit. Jede Entwicklerin und jeder Entwickler muss wissen, welche gerade vorliegt. Im Code unterschiedliche Namen dafür wählen, wie es die Beispiele tun, und nie die eine Kennung zum Befüllen der anderen verwenden.
  • Append-only, von der Datenbank durchgesetzt, bindet an die Funktionen der Datenbank. Trigger und Grants sind hier spezifisch für PostgreSQL; eine zweite Datenbank braucht ihr eigenes Gegenstück und ihre eigenen Tests.
  • Eine Policy-Engine hinter HTTP bringt einen Netzwerk-Hop und ein Fehlermuster mit. Der Client muss für jede Entscheidung festlegen, was eine nicht erreichbare Engine bedeutet.

Grenzen

  • Die Beispiele dienen der Veranschaulichung, sind klein und synthetisch. Sie lassen Authentifizierung, Autorisierung, Migrationen, Connection-Pooling, asynchronen Datenbankzugriff und Deployment weg.
  • Sie wurden in der oben genannten Umgebung getestet, am 11. September 2026, und auf nichts anderem.
  • Der gezeigte einzelne Prozess ist eine Topologie. Das Cloud-native-Profil zeigt, was sich ändert, wenn die probabilistische Seite getrennt deployt wird.
  • Nichts hier zeigt, wie Konfidenz dargestellt wird, wann eine Prüfung erforderlich ist oder wie die Prüfung organisiert ist; diese Teile von P-2 und P-3 veröffentlicht COADF nicht.

Was dieses Profil nicht begründet

Wer diesem Profil folgt, begründet damit weder die Einhaltung regulatorischer Anforderungen noch eine Zertifizierung oder ein Verfahren zur Bewertung der Übereinstimmung mit Rechtsvorschriften, und nichts hier wird von COADF vorausgesetzt. Das Ausführen dieser Beispiele belegt, dass diese Beispiele gelaufen sind.

Getestete Referenzumgebung

  • Python 3.12.13 and 3.14.4 · jeder Beispieltest auf beiden ausgeführt
  • FastAPI 0.141.1 · mit Starlette 1.6.0 und dessen Test-Client
  • Pydantic 2.13.5
  • httpx 0.28.1 · einschließlich des Mock-Transports
  • OpenTelemetry API and SDK 1.44.0 · In-Memory-Span-Exporter
  • import-linter 2.15 · Verträge eingehalten und durch einen eingebauten Import gebrochen
  • psycopg 3.3.5
  • PostgreSQL 18.6 · ein lokaler Wegwerf-Cluster; die Audit-Tests laufen gegen ihn
  • Open Policy Agent 1.20.2 · opa test, opa check --strict und ein laufender Server für den Client-Test
  • pytest 9.1.1

Quellen

COADF Engineering Companion 1.0 · nicht normativ · bezieht sich auf COADF Core 2.2

Veröffentlichungsrechte vorbehalten. Für den COADF Engineering Companion 1.0 und seine Referenzbeispiele wird derzeit keine öffentliche Lizenz erteilt.

Schutzrechts- und Veröffentlichungsstatus