En esta página
Propiedades de arquitectura tratadas
- P-1Una frontera tipada y validada alrededor de la llamada al modelo; en esta implementación de referencia, un valor autorizado solo existe tras la verificación de una persona con nombre.
- P-4Una identidad de auditoría junto al contexto de OpenTelemetry; una traza de solo inserción en PostgreSQL cuyas escrituras absorben un reintento exacto y rechazan uno en conflicto.
- P-5El método de extracción y la ubicación en la fuente viajan con cada valor propuesto y sobreviven a la serialización.
- P-6Contratos de importación y un fence de publicación, cada uno con su prueba de dientes.
- P-7Un puerto que pertenece al dominio y un adaptador que es el único módulo que conoce al proveedor.
- P-8Decisiones a partir de material de políticas versionado, conservadas con su revisión de la política o rechazadas.
- P-2Solo con la profundidad que publica COADF. Los ejemplos no contienen ninguna representación de la confianza.
- P-3Solo con la profundidad que publica COADF: en esta implementación de referencia, verificación por atributo de una persona con nombre, y un valor rechazado que queda vacío. No se muestra cuándo se requiere revisión ni cómo se organizan las revisiones.
Intención de arquitectura
En un servicio Python, el cliente del modelo y el código de dominio suelen escribirlos las mismas personas, en el mismo repositorio y la misma tarde, y así el lado probabilístico y el lado autorizado acaban en un mismo módulo, llamándose directamente. FastAPI y Pydantic hacen que la frontera sea barata de expresar como tipos y de validar en tiempo de ejecución. También tienen valores por defecto que son correctos para una API web y erróneos para una frontera: un modelo de Pydantic ignora los campos que no declara y convierte tipos compatibles salvo que el modo estricto esté activado, y FastAPI filtra cada respuesta según su modelo de respuesta sin avisar.
Este perfil muestra una manera de mantener las propiedades de COADF en un único servicio Python: un monolito modular con contratos de importación, PostgreSQL para la traza de auditoría, OpenTelemetry para el contexto de ejecución y Open Policy Agent detrás de una interfaz para las políticas. Nada de esto es obligatorio. Cada pieza es un lugar donde la propiedad se mantiene o se pierde en silencio.
Correspondencia tecnológica
| Propiedad de arquitectura | Python y FastAPI |
|---|---|
| Tipo de frontera | Modelos de Pydantic congelados con extra="forbid" y strict=True; una dataclass distinta para los valores verificados |
| Validación en tiempo de ejecución | model_validate_json sobre la respuesta en bruto; validación de peticiones de FastAPI, respondida con 422 |
| Contrato de salida | Un response_model explícito que declara la procedencia |
| Regla de arquitectura | Contratos forbidden de import-linter, ejecutados con lint-imports |
| Contexto de ejecución | Propagación de OpenTelemetry (inject, extract); contextvars copiadas a los hilos |
| Traza de auditoría | PostgreSQL: solo inserción y selección para el rol de la aplicación, triggers que rechazan reescrituras y una clave única cuyos conflictos se comparan antes de contar como reintentos |
| Aislamiento de normas | Un puerto typing.Protocol; un adaptador httpx que convierte los resultados HTTP del proveedor en no encontrado, no disponible y rechazado |
| Política | OPA a través de su API REST detrás de una interfaz pequeña; el tipo de la respuesta se comprueba y nunca se convierte, y cada decisión exige la revisión de la política |
| Fence de publicación | Un escaneo de la salida construida con un archivo de patrones guardado fuera de ella; una prueba de dientes comprobada por digest |
Patrón de referencia
Un único paquete, refapp, en un dominio de mantenimiento sintético: un modelo propone la próxima fecha de servicio y una clase de componente a partir del informe de servicio de un técnico. Los módulos son las fronteras.
| Módulo | Función | No puede importar |
|---|---|---|
boundary.py | El contrato que debe cumplir una respuesta del modelo | nada del dominio |
inference.py | Llama a un modelo; devuelve un Proposal | records, audit, api |
formats.py | Comprobaciones deterministas sobre un valor propuesto | adaptadores, clientes HTTP |
records.py | Valores verificados; la entrada que pasa por revisión | adaptadores, clientes HTTP |
api.py | La superficie HTTP de la frontera | |
tracing.py | Contexto de ejecución e identidad de auditoría | |
audit.py, schema.sql | La traza de solo inserción | |
classification.py | El puerto hacia un servicio externo | adaptadores, clientes HTTP |
adapters/ | El proveedor, y solo el proveedor | |
policy.py, policy/ | Material de políticas, regla y cliente | |
fence.py | El fence de publicación |
P-1 · La frontera
- Propósito
- Dar a una llamada al modelo un contrato: qué puede devolver y qué tiene que acompañar a lo que devuelve. Una frontera de referencia deliberadamente parcial, construida solo con lo que COADF publica en este nivel.
- Propiedad de arquitectura
- Un componente probabilístico devuelve un valor junto con la forma en que se obtuvo, y cualquier otra cosa se rechaza en la frontera (P-1). El método viaja con el valor, y eso es lo que hace posible declarar más adelante la extracción automática (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)Lo que omite a propósito
- La confianza, a propósito. En COADF P-1, un componente probabilístico comunica una confianza junto con su valor; esa es la propiedad de arquitectura pública. Cómo se expresa, se asigna y se usa la confianza es un detalle de implementación reservado de P-2, que COADF publica solo como principio. Por eso esta frontera lleva el método y la procedencia, no tiene campo de confianza y no inventa ninguna representación para ella: es parcial a propósito, no completa.
- La llamada al modelo, sus reintentos y el prompt. La frontera es la misma sea cual sea el cliente que produjo la cadena.
- Si el valor es correcto. Un esquema comprueba la forma; no puede saber si una fecha es la correcta para este activo.
- El almacenamiento. Un
Proposales un mensaje, no un registro.
Cómo verificarlo
Dar al parser respuestas casi correctas: un campo no declarado, un número donde el contrato dice texto, una procedencia ausente, un atributo que nadie pidió. Cada una tiene que lanzar una excepción (tests/test_boundary.py). Después, borrar extra="forbid" en una copia desechable y ver cómo falla la prueba del campo no declarado.
Modo de fallo que aborda
Una respuesta del modelo analizada con json.loads y leída como diccionario. Un "verified": true de más, un número convertido a la fuerza o una fuente ausente pasan sin obstáculo, y el valor se vuelve indistinguible de un dato que alguien comprobó.
- Propósito
- Comprobar el valor propuesto con código que no depende en absoluto del modelo.
- Propiedad de arquitectura
- La validación determinista rodea la salida estocástica: el mismo valor recibe siempre el mismo veredicto, y el veredicto puede reproducirse a partir del valor por sí solo (P-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 dateLo que omite a propósito
- Comprobaciones que necesitan otros registros u otras fuentes. Este ejemplo comprueba un valor frente a las reglas de su propio atributo y nada más.
- Formatos de fecha localizados. El contrato fija a propósito una única forma escrita.
Cómo verificarlo
Un valor que encaja con el patrón pero no es una fecha del calendario (2026-02-30) nunca se verifica (tests/test_boundary.py).
Modo de fallo que aborda
Confiar en que el modelo produjo un valor válido porque el prompt lo pedía: la comprobación que no se escribe es la que falla en producción.
- Propósito
- Un camino de referencia conservador por el que un valor derivado del modelo se vuelve autorizado: en este ejemplo, cada uno de esos valores va a una persona con nombre, que lo mira junto a su fuente y lo acepta o lo rechaza.
- Propiedad de arquitectura
- Un componente probabilístico no puede convertirse directamente en una salida autorizada (P-1). En esta implementación de referencia, un valor derivado del modelo llega a un registro solo mediante verificación humana, y un valor rechazado deja el atributo vacío (P-3, con la profundidad que publica COADF). Es una implementación, no una topología de COADF.
"""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)Lo que omite a propósito
- Cuándo se requiere revisión. COADF publica que la revisión la dispara la confianza; la regla que lo decide no se publica, y este ejemplo no define ninguna. Verifica todo valor derivado del modelo, la opción conservadora, que es también lo que el fence público F-03 exige de los valores derivados de un modelo de lenguaje.
- Cómo se organizan las revisiones. COADF no publica si las revisiones se ponen en cola, se asignan o se disparan, y aquí no se da a entender nada de ello. Tampoco se publica ni se da a entender la secuencia en que se atienden: el código toma una propuesta y una decisión cada vez.
- La autenticación de la persona revisora. Aquí
revieweres una cadena; en un sistema real es una identidad autenticada, comprobada donde se recibe la decisión. - La protección frente a un desarrollador que construya
VerifiedValuea mano. El tipo hace visible una promoción accidental, el contrato de importación la hace imposible desde el módulo de inferencia, y la revisión de código cubre el resto.
Cómo verificarlo
Una propuesta rechazada devuelve None; una aceptada conserva su procedencia; un nombre vacío de persona revisora se rechaza (tests/test_boundary.py).
Modo de fallo que aborda
Un indicador verified en el objeto que produjo el modelo, verdadero por defecto o fijado por cualquier código que guarde el registro. Los valores inferidos y los verificados pasan a ser lo mismo en el almacenamiento, y nada aguas abajo puede distinguirlos.
- Propósito
- Convertir la frontera, de un recuadro en un diagrama, en una comprobación que hace fallar el build.
- Propiedad de arquitectura
- El módulo probabilístico no tiene ningún camino de importación hacia los registros autorizados ni hacia la traza de auditoría, y el código de dominio no tiene ninguno hacia los adaptadores de proveedores ni hacia los clientes HTTP (P-1, P-7). La regla se ejecuta en cada cambio (P-6).
# 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 = ["."]Lo que omite a propósito
- Las importaciones hechas por nombre en tiempo de ejecución, como
importlib.import_modulecon una cadena calculada. Un contrato de importación estático comprueba las sentencias de importación que puede leer; un cargador de plugins necesita una comprobación propia. - Los caminos de datos que no pasan por importaciones: una conexión de base de datos compartida, una cola a la que llegan ambos lados. De ellos se ocupan los permisos de la base de datos y la política de red del perfil Cloud-native.
- Un contrato por capas para toda la aplicación. Dos contratos de prohibición bastan para mostrar el mecanismo.
Cómo verificarlo
lint-imports informa de que ambos contratos se mantienen. Añadir from refapp.records import accept a inference.py en una copia desechable: el primer contrato aparece como roto y el comando termina con un código distinto de cero. Al quitar la línea, vuelve a mantenerse.
Modo de fallo que aborda
La frontera existe como recuadro en un diagrama de arquitectura y en ningún otro sitio, hasta que alguien necesita con prisa un valor del otro lado.
Se apoya en
- Propósito
- Verificar la frontera por lo que rechaza, no solo por lo que acepta.
- Propiedad de arquitectura
- Cada prueba planta un defecto que la frontera existe para detener, así que borrar una restricción pone una prueba en rojo.
"""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=" "))Lo que omite a propósito
- Entrada malformada generada. Una lista corta de defectos con nombre es más fácil de revisar, y cada uno documenta un modo de fallo.
- Nada sobre la calidad de las respuestas del modelo. La frontera no hace que un modelo acierte.
Cómo verificarlo
Ejecutar pytest. La suite tiene dientes solo si al quitar una restricción falla una prueba; el harness borra extra="forbid" en una copia desechable y la prueba del campo no declarado falla.
Modo de fallo que aborda
Una suite que solo recorre el camino feliz: se podrían borrar todas las restricciones y seguiría en verde.
- Propósito
- La superficie HTTP de la frontera: qué puede enviar un worker de inferencia y qué recibe la pantalla de la persona revisora.
- Propiedad de arquitectura
- FastAPI valida la petición contra el modelo y filtra la respuesta según el modelo de respuesta, así que la procedencia, incluida la ubicación del pasaje que la persona revisora necesita para ver el valor junto a su fuente, tiene que declararse o nunca sale del servicio.
"""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]Lo que omite a propósito
- La autenticación y la autorización de ambos endpoints.
- La persistencia. Un diccionario hace las veces de almacenamiento.
- El endpoint de decisión con el que la persona revisora acepta o rechaza; véase
records.accept. - Ningún orden de presentación. La lista vuelve en el orden de inserción. Esa no es la secuencia de ninguna revisión: COADF no publica en qué secuencia se hace la revisión, y aquí nada la fija.
Cómo verificarlo
Enviar una respuesta con un campo verified de más: 422. Enviar una bien formada y listarla: la procedencia está en la respuesta. Después, declarar un modelo de respuesta sin la procedencia: el mismo objeto vuelve sin ella, y nada falla (tests/test_api.py lo reproduce).
Modo de fallo que aborda
Procedencia perdida en la serialización. Un modelo de respuesta que omite la procedencia la descarta en silencio, y la pantalla de revisión muestra un valor sin nada con lo que contrastarlo.
P-4 · La traza de ejecución y la traza de auditoría
- Propósito
- Llevar el contexto de ejecución a través de un mensaje y de un hilo, y llevar la identidad de auditoría a su lado, nunca en su lugar.
- Propiedad de arquitectura
- Una sola identidad trazable une los pasos de una transacción (P-4). El contexto de OpenTelemetry se correlaciona con ella mediante un atributo de span, y nunca la sustituye.
"""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)Lo que omite a propósito
- Exportadores y muestreadores; véase la configuración del Collector en el perfil Cloud-native.
- Un cliente de broker real.
sendes cualquier función que reciba cabeceras y un cuerpo. - Dónde se acuña el
trace_idde auditoría: en la recepción, una sola vez, y en ningún punto posterior. - Baggage. La identidad de auditoría viaja en el cuerpo del mensaje, donde forma parte del contrato, y no en el baggage, que la instrumentación transmite a los servicios posteriores, terceros incluidos.
- Lo que expone la correlación. El atributo de span copia el
trace_idde auditoría en la telemetría, que tiene sus propios exportadores, proveedores, controles de acceso y retención, normalmente más laxos que los del almacén de auditoría. Hay que clasificarlo antes de exportarlo: una referencia opaca, nunca datos personales ni un secreto. Donde un identificador de auditoría tenga significado, la correlación debe hacerse en su lugar mediante una referencia aparte y no sensible.
Cómo verificarlo
Los spans del productor y del consumidor comparten una traza distribuida, y el span del consumidor lleva la identidad de auditoría. Un mensaje sin la identidad de auditoría lanza KeyError en lugar de acuñar una nueva. Un envío directo a un pool de hilos pierde el contexto; el contexto copiado lo conserva; asyncio.to_thread lo conserva (tests/test_tracing.py).
Modo de fallo que aborda
El identificador de traza regenerado a mitad de camino: un consumidor que recurre a un identificador nuevo cuando falta el campo, o un hilo de trabajo que arranca sin el contexto de quien lo llama, deja dos mitades que ninguna consulta puede unir.
Se apoya en
W3C: Trace Context, the traceparent header · OpenTelemetry: SDK environment variables: OTEL_PROPAGATORS · OpenTelemetry: Context propagation · OpenTelemetry: Baggage: security considerations · OpenTelemetry Python: opentelemetry-api: runtime context (source) · Python Software Foundation: asyncio: Task and to_thread · CPython: Lib/asyncio/threads.py
- Propósito
- Una traza de auditoría con los nueve campos que publica COADF, almacenada de modo que la aplicación pueda añadir y leer y la base de datos rechace las reescrituras.
- Propiedad de arquitectura
- La traza es de solo inserción y una corrección es una entrada nueva (P-4). Un paso tiene una sola identidad: un reintento exacto es la misma entrada, y
audit.pyrechaza la misma identidad con otro contenido.
-- 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();Lo que omite a propósito
- Evidencia de manipulación. Los privilegios y los triggers son controles dentro de la propia frontera de confianza de la base de datos: un superusuario se salta las comprobaciones de permisos, y el propietario de la tabla puede desactivar sus triggers. Detectar una reescritura hecha por alguien con esos derechos requiere un control fuera de la base de datos, que este ejemplo no muestra.
- Retención, archivado y exportación.
- El vocabulario de eventos.
event_typees texto obligatorio. Qué eventos registra una aplicación es cuestión de su propio diseño, y queda deliberadamente fuera de este ejemplo. - Un orden entre entradas con la misma marca de tiempo. La reconstrucción ordena por marca de tiempo, y este ejemplo deja intencionadamente sin ordenar las marcas de tiempo iguales: ningún campo publicado las ordena. Un único escritor por transacción, o un orden asignado por uno, es una decisión de almacenamiento que va más allá de los campos publicados.
- La migración y el rol propietario de la tabla. La aplicación nunca se conecta con ese rol.
Cómo verificarlo
Contra un PostgreSQL real: el rol de la aplicación no puede actualizar, borrar ni truncar; el propietario se topa con los triggers; un reintento exacto no añade nada; la misma identidad con otro contenido se rechaza; una corrección añade una entrada; y, deliberadamente, el propietario puede desactivar los triggers y borrar (tests/test_audit.py).
Modo de fallo que aborda
Una corrección escrita como UPDATE, que destruye el registro de lo que se creía antes, y la misma pérdida con TRUNCATE, que no dispara ningún trigger DELETE.
Se apoya en
PostgreSQL Global Development Group: Privileges · PostgreSQL Global Development Group: TRUNCATE · PostgreSQL Global Development Group: CREATE TRIGGER · PostgreSQL Global Development Group: Trigger functions in PL/pgSQL · PostgreSQL Global Development Group: Role attributes · PostgreSQL Global Development Group: ALTER TABLE: DISABLE TRIGGER · PostgreSQL Global Development Group: JSON types: jsonb indexing · PostgreSQL Global Development Group: Date/time types
- Propósito
- Hacer inofensivo un reintento y ruidosa una escritura en conflicto, y leer una transacción completa en una sola consulta.
- Propiedad de arquitectura
- La marca de tiempo se pone cuando se ejecuta el paso, así que un reintento reenvía la entrada idéntica. Ante un conflicto, primero se compara la entrada almacenada: el mismo contenido es un reintento, otro contenido lanza
IdempotencyCollision. La reconstrucción es una consulta portrace_id, ordenada por marca de tiempo; las marcas de tiempo iguales quedan intencionadamente sin ordenar (P-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()Lo que omite a propósito
- La transacción que escribe la entrada junto con el cambio de estado que registra. Escritos por separado, uno puede existir sin el otro.
- La exportación como JSON, que P-4 también pide.
- La gestión de conexiones, y qué rol usa la conexión.
- Cuál de dos entradas en conflicto es la correcta. El ejemplo rechaza la segunda y conserva la primera; decidir entre ellas es tarea de alguien, no de la base de datos.
- Escritores concurrentes del mismo paso. El ejemplo se probó con un escritor cada vez; con concurrencia, hay que comprobar qué deja ver el nivel de aislamiento a la lectura que compara.
Cómo verificarlo
Insertar la misma entrada dos veces: True, luego False, y una sola fila. Insertar la misma identidad con otra salida: IdempotencyCollision, y la entrada almacenada no cambia (tests/test_audit.py). Reconstruir con chain().
Modo de fallo que aborda
Reintentos que producen eventos ambiguos y conflictos que desaparecen: una marca de tiempo tomada en el momento de la inserción convierte cada reintento en una entrada nueva, y un ON CONFLICT DO NOTHING a secas descarta sin error una entrada distinta que por casualidad comparte la clave.
- Propósito
- Comprobar la propiedad de solo inserción contra la capa que realmente la aplica, y fijar la limitación como prueba.
- Propiedad de arquitectura
- La propiedad se afirma donde se aplica, en la base de datos; lo que los controles no detienen también es una prueba, para que no pueda olvidarse.
"""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") == []Lo que omite a propósito
- Una base de datos en la integración continua. Las pruebas se omiten sin
REFAPP_PG_DSN, y una prueba omitida no es una prueba superada: el job que las ejecuta tiene que proporcionar la base de datos, o informar de la comprobación como no ejecutada.
Cómo verificarlo
Ejecutar contra un PostgreSQL desechable: las seis pruebas pasan, incluido el reintento en conflicto que tiene que fallar y la prueba que demuestra que el propietario puede borrar.
Modo de fallo que aborda
Falsa inmutabilidad: un trigger tomado como prueba de que la historia no puede cambiar, cuando el rol propietario de la tabla puede desactivarlo.
P-7 · El puerto y el adaptador
- Propósito
- El lado del dominio de un servicio de clasificación externo: un puerto y los tipos que hay detrás.
- Propiedad de arquitectura
- Los vocabularios externos quedan detrás de un puerto que pertenece al dominio. Un servicio que falla hace el registro menos informativo y nunca lo detiene, y el registro dice si el servicio no estaba disponible o si la integración está rota (P-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")Lo que omite a propósito
- Lo que una respuesta externa hace con la confianza. COADF P-7 dice que un servicio externo puede elevar la confianza de un atributo y nunca puede bloquear una salida; cómo funciona esa elevación no se publica. Aquí la respuesta se registra, con su estado, y nada más.
- La caché, y durante cuánto tiempo permite una licencia conservar una respuesta externa.
- La correspondencia entre los códigos externos y los conceptos propios del dominio, que en un sistema real son datos versionados. Un proveedor o un transporte nuevos se quedan en el adaptador; una versión nueva del propio sistema de clasificación puede cambiar lo que significan los códigos, y entonces esta correspondencia también cambia.
- A quién se avisa.
rejectedno se cura reintentando, así que necesita una alerta queunavailablequizá no necesite.
Cómo verificarlo
Un doble de prueba satisface el puerto; una caída registra unavailable y una integración rota registra rejected (tests/test_classification.py). El contrato de importación mantiene httpx y los adaptadores fuera de este módulo.
Modo de fallo que aborda
Una caída remota que bloquea procesamiento no relacionado: el dominio llama al proveedor directamente, de forma síncrona y sin timeout, y un incidente del proveedor se convierte en un incidente propio.
- Propósito
- El único módulo que conoce la forma HTTP del proveedor y los nombres del proveedor.
- Propiedad de arquitectura
classIdyrestricteddel proveedor se traducen acodeylicenseddel dominio, la versión del sistema de clasificación se fija en cada llamada, y los resultados HTTP del proveedor se convierten en tres resultados de dominio: no encontrado, no disponible (transitorio) y rechazado (la integración está mal). El vocabulario HTTP del proveedor no se convierte en el del dominio (P-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)Lo que omite a propósito
- Reintentos, circuit breaking y límites de tasa.
- La autenticación ante el proveedor.
- Los propios términos de la licencia, y cualquier contenido con licencia: este ejemplo no contiene ninguno.
- Una taxonomía más fina. Tres resultados bastan para distinguir una caída de una integración rota; un adaptador real puede necesitar más.
Cómo verificarlo
Con httpx.MockTransport: un 200 se traduce; un 404 es None; un timeout, un error de conexión, un 429 o un 5xx es LookupUnavailable; un 400, un 401, un restricted no booleano, un campo ausente o un cuerpo que no es JSON es LookupRejected; cada petición lleva la versión fijada.
Modo de fallo que aborda
El SDK del proveedor convertido en modelo de dominio: sus clases aparecen en las firmas del dominio, sus identificadores adquieren significado interno, y un cambio de licencia o de API obliga a reescribir código de dominio. O cada fallo notificado como el mismo unavailable, de modo que una credencial revocada parece una caída y nadie la arregla.
P-8 · Reglas como datos
- Propósito
- El material que lee una regla: qué funcionalidades puede activar cada entorno, y la versión de este material.
- Propiedad de arquitectura
- Las reglas son datos. Cambiar lo que está permitido cambia este archivo, no el motor ni la regla (P-8).
{ "refapp": { "revision": "2026-09-01.2", "features": { "staging": ["report-export", "beta-search"], "production": ["report-export"] } }}Lo que omite a propósito
- Cómo se protege el material de políticas en su camino hacia el motor y cómo se comprueba al llegar. COADF no publica esa parte de P-8, y este ejemplo no muestra nada de ella.
- Cómo se asignan las versiones. La propia documentación de bundles de OPA usa un hash de commit de Git como versión de ejemplo. Nada en este ejemplo impide que el material cambie bajo la misma versión; lo que lo impide es derivar la versión del commit o del contenido.
Cómo verificarlo
Permitir beta-search en production: fallan las pruebas que fijan la respuesta de producción, mientras la regla sigue intacta.
Modo de fallo que aborda
Reglas duplicadas y codificadas a mano en varios servicios, cada una cambiada a su propio ritmo, de modo que la misma petición se permite en un sitio y se rechaza en otro.
- Propósito
- Una regla que lee su material de los datos y responde con la versión que produjo la respuesta.
- Propiedad de arquitectura
- La decisión lleva la procedencia de la versión de la regla (P-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,}Lo que omite a propósito
- Nada sobre confianza, revisión, publicación o autonomía. El tema es deliberadamente genérico.
- Los bundles y los logs de decisión, que en OPA pueden llevar por sí mismos una versión del bundle. El campo
revisionexplícito mantiene este ejemplo autocontenido. - Si una regla real debería rechazar por defecto. Aquí el valor por defecto es
false; para cada regla real, esa decisión corresponde a su responsable.
Cómo verificarlo
opa test policy/ ejecuta seis pruebas, entre ellas una clave de entrada mal escrita que cae en el valor por defecto en lugar de producir un error, y una repetición de la misma entrada bajo la misma versión.
Modo de fallo que aborda
Una recarga de reglas que cambia el significado de decisiones históricas: sin la versión en la decisión, un informe calculado hoy no puede decir qué reglas produjeron las respuestas del mes pasado.
- Propósito
- El lado de la aplicación: preguntar al motor a través de una interfaz pequeña, y rechazar cualquier respuesta que no nombre su versión.
- Propiedad de arquitectura
- Una decisión se conserva con la versión que la produjo, o no se conserva (P-8). La respuesta se analiza con el mismo rigor con que se conserva:
allowtiene que ser un booleano yrevisionuna cadena no vacía, porque en Pythonbool("false")esTrue. El motor está detrás de un adaptador, así que el registro de decisión conserva su forma si cambia el motor.
"""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)Lo que omite a propósito
- La caché de decisiones. Una caché tiene que usar como clave la versión además de la entrada.
- Dónde se registra la decisión: en la traza de auditoría, como salida de un paso de política.
- El rechazo como regla universal. Rechazar es lo correcto para este ejemplo; el responsable de cada decisión decide qué significa para ella un motor no disponible.
Cómo verificarlo
Contra un servidor OPA real, la versión vuelve y la misma entrada responde del mismo modo. Contra un motor simulado, una denegación válida es una decisión; allow enviado como la cadena "false", un allow ausente, una versión vacía o en blanco, un resultado del tipo equivocado, un decision_id que no es una cadena, una ruta no definida (OPA omite result), un cuerpo que no es JSON y un motor inalcanzable lanzan cada uno PolicyUnavailable (tests/test_policy.py).
Modo de fallo que aborda
Una ruta de política no definida leída como rechazo, una respuesta malformada convertida a la fuerza en la decisión contraria, o una respuesta sin versión registrada de todos modos: la decisión existe y no puede reconstruirse.
P-6 · Un fence y su prueba de dientes
- Propósito
- Poner un fence a la salida construida, con una lista de patrones que queda fuera del árbol publicado.
- Propiedad de arquitectura
- Un estado prohibido en la salida construida detiene la publicación (P-6). No haber escaneado nada no cuenta como éxito, y un hallazgo dice dónde, no qué.
"""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]))Lo que omite a propósito
- Los patrones. El ejemplo incluye solo un centinela sintético. Una lista real de patrones es un mapa de lo que se protege, y por eso COADF P-6 no publica la suya.
- La revisión semántica. Un fence de texto no puede ver un mecanismo expresado mediante flujo de control, identificadores renombrados o la geometría de un diagrama. Es necesario y no suficiente.
- Formatos binarios, y contenido obtenido en tiempo de ejecución.
Cómo verificarlo
La prueba de dientes en tests/test_fence.py.
Modo de fallo que aborda
Un fence que pasa porque no leyó nada: un archivo de patrones vacío, un directorio de build sin archivos, o un archivo de patrones que ha acabado dentro de la salida del build.
- Propósito
- Plantar un centinela sintético, ver fallar el punto de entrada real, restaurar los bytes exactos, verlo pasar.
- Propiedad de arquitectura
- Un fence se gana su sitio solo cuando se le ha visto fallar en el camino real (P-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)) == 2Lo que omite a propósito
- Vocabulario protegido real. El centinela es sintético y no significa nada.
- Cualquier afirmación de que un fence que pasa hace segura la salida. La prueba muestra que el camino del guardián funciona, no que la revisión semántica esté completa.
Cómo verificarlo
Ejecutarla. Después, hacer que scan retorne antes de tiempo en una copia desechable y verla fallar.
Modo de fallo que aborda
Una restauración que no es exacta. Restaurar de memoria, o desde la cabeza de una rama, puede cambiar o perder contenido en silencio; comparar el digest antes y después es lo que demuestra que el fixture volvió.
Modos de fallo
Los valores por defecto de Pydantic tomados por una frontera
Por defecto, un modelo ignora los campos que no declara y, fuera del modo estricto, convierte valores compatibles. Para una API eso es tolerancia; para una frontera significa que una respuesta que afirma
"verified": true, o un número enviado como texto, pasa sin dejar rastro. Hay que fijarextra="forbid"y el modo estricto en los modelos de frontera, y probar ambas cosas.Un modelo de respuesta que descarta la procedencia
FastAPI filtra cada respuesta según su modelo de respuesta. Un modelo de respuesta escrito para la pantalla, sin los campos de procedencia, los elimina de todas las respuestas, y la persona revisora ve un valor sin fuente.
model_constructen una ruta críticaConstruye un modelo sin validación. Introducido por velocidad, se convierte en la ruta que se salta la frontera.
Un contexto que se detiene en un pool de hilos
asyncio.to_threadpropaga el contexto actual; un envío directo a un executor no lo hace, y tampocorun_in_executor, salvo que quien llama copie antes el contexto, como hace el propioto_thread. Los spans y la identidad de auditoría desaparecen entonces justo donde el trabajo es más lento.Una default factory que acuña identificadores de traza
Field(default_factory=uuid4)en un modelo de mensaje parece inofensivo y convierte cada identificador que falta en uno nuevo y sin relación. Después de la recepción, la identidad de auditoría es obligatoria y nunca recibe un valor por defecto.Escrituras de auditoría aplazadas hasta después de la respuesta
Las tareas en segundo plano de FastAPI se ejecutan después de devolver la respuesta. Una entrada de auditoría escrita ahí puede perderse con el proceso cuando al cliente ya se le ha dicho que el paso tuvo éxito. Hay que escribirla en la misma transacción que el cambio.
Un solo modelo ORM para propuestas y registros
Una columna de estado en la entidad que todos leen. El día que alguien olvida un filtro, las propuestas aparecen como hechos.
Un protocolo tomado por una comprobación en tiempo de ejecución
Un
typing.Protocoles un contrato estático; una comprobación en tiempo de ejecución contra él solo constata que los métodos existen. El comportamiento es lo que comprueban las pruebas de contrato.Pruebas que se omiten, leídas como pruebas que pasan
Una prueba de base de datos que se omite cuando falta la cadena de conexión convierte una base de datos ausente en la CI en una ejecución en verde. Hay que contar lo que se ejecutó y hacer fallar el job cuando las pruebas de auditoría no se ejecutaron.
Verificación
Prueba unitaria
Pasa cuando:
pytestejecuta las pruebas negativas de la frontera: campo no declarado, tipo erróneo, procedencia ausente, atributo inesperado, fecha imposible, revisión rechazada.Prueba de dientes: Borrar
extra="forbid"en una copia desechable: la prueba del campo no declarado falla. El arnés lo hace en cada ejecución.Prueba de arquitectura
Pasa cuando:
lint-importsmantiene ambos contratos.Prueba de dientes: Introducir
from refapp.records import accepteninference.py: el contrato se rompe y el comando termina con un código distinto de cero.Prueba de contrato
Pasa cuando: La API rechaza con 422 los campos no declarados, y sus respuestas llevan la procedencia.
Prueba de dientes: Un modelo de respuesta sin el campo de procedencia devuelve el objeto sin él; la prueba que afirma su presencia falla.
Prueba de integración
Pasa cuando: Contra un PostgreSQL real: el rol de la aplicación no puede reescribir la traza, el propietario se topa con los triggers, los reintentos exactos se absorben, un reintento en conflicto se rechaza y las correcciones se suman.
Prueba de dientes: La última prueba de auditoría desactiva los triggers como propietario y borra, lo que demuestra lo que los controles no impiden.
Prueba de integración
Pasa cuando: Contra un servidor OPA real, la decisión lleva su revisión de la política y se reproduce de forma idéntica; contra un motor simulado, se rechazan las respuestas sin revisión de la política o de tipo erróneo.
Prueba de dientes: Convertir
allowconbool()en lugar de comprobar su tipo: la prueba que envía la cadena"false"falla. El arnés lo hace en cada ejecución.Prueba de extremo a extremo
Pasa cuando: El fence de publicación pasa sobre la salida construida.
Prueba de dientes: Se introduce un centinela sintético, el fence falla, se restauran los bytes exactos y se comparan, y el fence pasa.
Realizaciones alternativas
- Otras bibliotecas de validación. attrs con cattrs, o msgspec, ofrecen la misma frontera con otros compromisos de velocidad y rigor. La propiedad es el tipo estricto, validado y portador de procedencia, no la biblioteca.
- Otros frameworks. Los serializadores de Django REST framework o Litestar expresan la misma frontera. El filtrado de la salida se comporta distinto en cada framework; hay que probarlo en el propio.
- Inmutabilidad a nivel de aplicación (un repositorio de solo inserción, sin método de actualización) en lugar de triggers de base de datos, cuando la base de datos es compartida o los triggers no pueden gestionarse. Más débil frente a un segundo cliente, y más sencilla de operar.
- Tablas de reglas en PostgreSQL en lugar de OPA, cuando las reglas son pocas y el equipo ya es dueño de la base de datos. La exigencia de registrar la revisión de la política es la misma.
Compromisos
- El rigor cuesta fricción. Los modelos estrictos rechazan entradas que los laxos habrían reparado, y cada rechazo exige una decisión. El modo estricto es además más permisivo con la entrada JSON que con los objetos Python: los tipos de fecha aceptan cadenas incluso en modo estricto.
- Dos identificadores cuestan atención. Cada desarrollador tiene que saber cuál está mirando. Conviene nombrarlos de forma distinta en el código, como hacen los ejemplos, y no usar nunca uno para rellenar el otro.
- La inserción exclusiva impuesta por la base de datos ata a las funciones de esa base de datos. Aquí, los triggers y los permisos son específicos de PostgreSQL; una segunda base de datos necesita su propio equivalente y sus propias pruebas.
- Un motor de políticas detrás de HTTP añade un salto de red y un modo de fallo. El cliente tiene que decidir qué significa un motor inalcanzable, para cada decisión.
Limitaciones
- Los ejemplos son ilustrativos, pequeños y sintéticos. Dejan fuera la autenticación, la autorización, las migraciones, el pool de conexiones, el acceso asíncrono a la base de datos y el despliegue.
- Se probaron en el entorno indicado arriba, el 11 de septiembre de 2026, y en ningún otro.
- El proceso único que se muestra es una topología. El perfil Cloud-native muestra qué cambia cuando el lado probabilístico se despliega por separado.
- Nada de lo que aquí figura muestra cómo se representa la confianza, cuándo se requiere revisión ni cómo se organiza la revisión; COADF no publica esas partes de P-2 y P-3.
Lo que este perfil no establece
Seguir este perfil no establece cumplimiento normativo, certificación ni evaluación de la conformidad, y COADF no exige nada de lo que aquí figura. Ejecutar estos ejemplos establece que estos ejemplos se ejecutaron.
Entorno de referencia probado
- Python 3.12.13 and 3.14.4 · cada prueba de los ejemplos se ejecutó en ambas
- FastAPI 0.141.1 · con Starlette 1.6.0 y su cliente de pruebas
- Pydantic 2.13.5
- httpx 0.28.1 · incluido su transporte simulado
- OpenTelemetry API and SDK 1.44.0 · exportador de spans en memoria
- import-linter 2.15 · contratos cumplidos, y rotos por una importación introducida a propósito
- psycopg 3.3.5
- PostgreSQL 18.6 · un clúster local desechable; las pruebas de auditoría se ejecutan contra él
- Open Policy Agent 1.20.2 · opa test, opa check --strict y un servidor en marcha para la prueba del cliente
- pytest 9.1.1
Fuentes
- Pydantic: ConfigDict.extra · documentación oficial · Pydantic 2.13 · Comprobado el 2026-09-11
- Pydantic: Strict mode · documentación oficial · Pydantic 2.13 · Comprobado el 2026-09-11
- Pydantic: Models: faux-immutability · documentación oficial · Pydantic 2.13 · Comprobado el 2026-09-11
- Pydantic: Performance: model_validate_json · documentación oficial · Pydantic 2.13 · Comprobado el 2026-09-11
- Pydantic: Models: creating models without validation · documentación oficial · Pydantic 2.13 · Comprobado el 2026-09-11
- FastAPI: Handling errors: request validation · documentación oficial · FastAPI 0.141 · Comprobado el 2026-09-11
- FastAPI: Response model: return type and data filtering · documentación oficial · FastAPI 0.141 · Comprobado el 2026-09-11
- FastAPI: Background tasks · documentación oficial · FastAPI 0.141 · Comprobado el 2026-09-11
- import-linter: Contract types · documentación oficial · import-linter 2.15 · Comprobado el 2026-09-11
- Python Software Foundation: asyncio: Task and to_thread · documentación del lenguaje · Python 3.14 · Comprobado el 2026-09-11
- CPython: Lib/asyncio/threads.py · repositorio del proyecto · CPython 3.14.7 · Comprobado el 2026-09-11
- Python Software Foundation: typing.Protocol and runtime_checkable · documentación del lenguaje · Python 3.14 · Comprobado el 2026-09-11
- OpenTelemetry: SDK environment variables: OTEL_PROPAGATORS · especificación · Specification 1.60.0 · Comprobado el 2026-09-11
- OpenTelemetry: Context propagation · documentación oficial · Comprobado el 2026-09-11
- OpenTelemetry: Baggage: security considerations · documentación oficial · Comprobado el 2026-09-11
- OpenTelemetry Python: opentelemetry-api: runtime context (source) · repositorio del proyecto · opentelemetry-api 1.44.0 · Comprobado el 2026-09-11
- W3C: Trace Context, the traceparent header · especificación · W3C Recommendation, Level 1 · Comprobado el 2026-09-11
- PostgreSQL Global Development Group: Privileges · documentación oficial · PostgreSQL 18 · Comprobado el 2026-09-11
- PostgreSQL Global Development Group: TRUNCATE · documentación oficial · PostgreSQL 18 · Comprobado el 2026-09-11
- PostgreSQL Global Development Group: CREATE TRIGGER · documentación oficial · PostgreSQL 18 · Comprobado el 2026-09-11
- PostgreSQL Global Development Group: Trigger functions in PL/pgSQL · documentación oficial · PostgreSQL 18 · Comprobado el 2026-09-11
- PostgreSQL Global Development Group: Role attributes · documentación oficial · PostgreSQL 18 · Comprobado el 2026-09-11
- PostgreSQL Global Development Group: ALTER TABLE: DISABLE TRIGGER · documentación oficial · PostgreSQL 18 · Comprobado el 2026-09-11
- PostgreSQL Global Development Group: INSERT: ON CONFLICT · documentación oficial · PostgreSQL 18 · Comprobado el 2026-09-11
- PostgreSQL Global Development Group: JSON types: jsonb indexing · documentación oficial · PostgreSQL 18 · Comprobado el 2026-09-11
- PostgreSQL Global Development Group: Date/time types · documentación oficial · PostgreSQL 18 · Comprobado el 2026-09-11
- Open Policy Agent: Open Policy Agent: introduction · documentación oficial · OPA 1.20 · Comprobado el 2026-09-11
- Open Policy Agent: Bundles: bundle file format · documentación oficial · OPA 1.20 · Comprobado el 2026-09-11
- Open Policy Agent: Policy testing · documentación oficial · OPA 1.20 · Comprobado el 2026-09-11
- Open Policy Agent: Upgrading to OPA 1.0 · documentación oficial · OPA 1.20 · Comprobado el 2026-09-11
- Open Policy Agent: Decision logs · documentación oficial · OPA 1.20 · Comprobado el 2026-09-11
- Open Policy Agent: REST API: get a document with input · documentación oficial · OPA 1.20 · Comprobado el 2026-09-11
