Ir al contenido

Pliegos: licitación como código

🟡 Parcial — El round-trip completo AAPP↔licitador está construido y demostrado sobre repos reales de GitHub.com. El expediente tiene estado (abierto / adjudicado) y adjudicatario formal, y adjudicar fusiona el PR de la entrega. Límites de hoy: GitHub.com y GitLab (gitlab.com o autohospedado; las entregas son merge requests y adjudicar los fusiona) — ni Forgejo ni Bitbucket listan entregas. Aún no hay plazo de presentación ni notificación formal al licitador no seleccionado. El detalle, en «Alcance honesto hoy».

Esta guía cubre las dos mitades del flujo: la AAPP compradora (publicar o registrar un pliego, leer las entregas, adjudicar) y el licitador (responder al pliego, producir la evidencia en local, autocomprobarse y entregar). Si vienes del Nivel 6 del curso, esto es la versión de producto del «pliego como código» que allí se nombra: el umbral lo impone el pliego de la compradora, no el proveedor.


Un pliego es un repositorio git que contiene dos ficheros:

pliego.oscal.yaml ← las cláusulas exigidas (formato OSCAL)
pliego.oscal.yaml.sig ← sobre DSSE/in-toto firmado por la AAPP (ECDSA-P256)

Cada cláusula del pliego referencia una cláusula del catálogo del motor (por ejemplo mdr.gspr-3-risk-management de eu/mdr@2017, o la estimación del riesgo de eu/pren-18228@2026) con una exigencia:

  • Bloqueante (block) — la entrega debe acreditarla Covered; si no, el veredicto es Rechazado.
  • Aviso (warn) — informativa; no cierra la puerta.

La firma es lo que hace el pliego vinculante: el licitador (y cualquier tercero) puede verificarlo con la clave pública de la AAPP, y su froga.yaml de respuesta queda anclado al digest exacto del pliego (responds_to_pliego = sha256 de los bytes del fichero). Responder a un pliego distinto —o a una versión debilitada— produce un digest que no casa, y la entrega sale Rechazada. El pliego no declara clase de riesgo: bajo el AI Act clasifica el licitador (proveedor), no la AAPP contratante.

sequenceDiagram
  participant A as AAPP (org desplegadora)
  participant G as GitHub.com
  participant L as Licitador (org proveedora)
  A->>A: firma pliego.oscal.yaml fuera de la nube (froga sign-pliego + KMS)
  A->>G: publica pliego.oscal.yaml + .sig
  L->>G: fork del repo del pliego (vía la plataforma)
  L->>L: froga run LOCAL → evidencia firmada .froga/*
  L->>G: PR de entrega (fork → repo del pliego)
  A->>G: la nube relee el PR y RE-DERIVA el veredicto
  A->>G: «Adjudicar» → fusiona el PR (squash) y cierra el expediente
  A->>A: la entrega se promueve a sistema gobernado

2. Lo que la nube comprueba (y por qué no se fía)

Sección titulada «2. Lo que la nube comprueba (y por qué no se fía)»

El expediente del pliego (/[org]/pliegos/[slug]) no muestra lo que el licitador dice: re-deriva el veredicto de cada entrega en el servidor, releyendo los artefactos del head del PR y verificando cada eslabón, fail-closed y en orden:

  1. La firma del pliegopliego.oscal.yaml + .sig contra la clave pública registrada con el pliego. Firma inválida → el pliego no es autoritativo.
  2. El cross-check de digest — el responds_to_pliego del froga.yaml del licitador debe ser sha256 de los bytes exactos del pliego (anti-forja).
  3. La identidad del licitador — su clave pública se lee de .froga/PUBKEY.txt en el head del PR; sin ella, nada verifica y la entrega sale Rechazada («licitador no enrolado»).
  4. La evidencia firmada — los informes conformance/*.json del licitador deben verificar contra esa clave y cumplir el esquema completo.
  5. El entitlement — si el pliego exige cláusulas bloqueantes de una norma de pago (prEN 18228, MDR, DORA…), el .froga/entitlement.json del licitador debe verificar contra el emisor de Venturalítica.
  6. La intersección de cláusulas — toda cláusula block del pliego debe estar Covered en la evidencia.

Cualquier fallo en la cadena produce Rechazado (con las brechas nombradas cláusula a cláusula) o No evaluable (error de lectura del SCM) — nunca un Aceptado por omisión. La puerta no falla en abierto. Y la indisponibilidad tampoco se disfraza de veredicto: si GitHub no respondió (límite de peticiones o timeout), la entrega se muestra «GitHub no disponible» — reintenta en unos minutos; no es un juicio sobre la entrega ni sobre la firma.

El Nivel 6 del curso lo destila en tres juicios; el flujo de pliegos añade un cuarto:

JuicioPregunta que respondeQuién / qué lo emite
Autenticidad¿Son los bytes exactamente lo que el firmante firmó?La verificación criptográfica (froga verify)
Aceptación¿Cumple la entrega las cláusulas bloqueantes del pliego?La puerta del pliego (re-derivada server-side)
Conformidad¿Cumple el producto la ley, cláusula a cláusula?Solo un organismo notificado acreditado — nadie en esta cadena
Adjudicación¿Incorporo esta entrega como sistema gobernado?Una persona de la AAPP, con el botón «Adjudicar»

Una entrega puede ser perfectamente auténtica y estar Rechazada. Puede estar Aceptada y no ser conforme (la brecha hacia la conformidad plena se documenta, no se oculta). Y puede estar Aceptada sin estar adjudicada: el veredicto es de la puerta; la adjudicación es una decisión humana. Ver organismo notificado o autodeclaración para la frontera del tercer juicio.


  • Rol AI Act de desplegador. La superficie de Pliegos solo existe en organizaciones desplegadoras: en Ajustes → Perfil, declara el rol AI Act de la organización como «Desplegador» (o «Ambos»). Sin él, las páginas de /pliegos muestran un aviso explicativo con el enlace directo a Ajustes → Perfil para declararlo (si eres owner; al resto se le indica pedírselo al propietario). El 404 queda solo para quien no es miembro de la organización.
  • Rol de miembro owner o maintainer — publicar, registrar y adjudicar son mutaciones.
  • Conexión GitHub de la organización (la GitHub App instalada en la organización de GitHub de la AAPP, con permiso de crear repositorios).

4.2 Publicar un pliego (firmado fuera de la nube)

Sección titulada «4.2 Publicar un pliego (firmado fuera de la nube)»

En /[org]/pliegos«Publicar pliego», tras firmar el pliego fuera de la nube:

  1. Compón pliego.oscal.yaml — normalmente con la misión «Inicializar pliego técnico» de tu sistema (clase de riesgo del Anexo III + normas técnicas + cláusulas exigidas, fila a fila), o escrito a mano en formato OSCAL.
  2. Fírmalo fuera de la nube, en tu máquina o en tu CI, con el mismo mecanismo con el que el motor firma la evidencia de las demos de desarrollo: froga sign-pliego pliego.oscal.yaml (backend KMS de Scaleway en producción — FROGA_SIGNING_BACKEND=scaleway-kms + SCW_KMS_BASE_URL) escribe el sobre DSSE pliego.oscal.yaml.sig; froga pubkey imprime tu clave pública SEC1. La clave privada nunca llega a la nube.
  3. En el formulario, rellena nombre del pliego y nombre del repositorio que la nube creará (GitHub o GitLab), y pega —o carga desde fichero— los tres artefactos: el YAML, el .sig y la clave pública (SEC1 hex, 04…, 130 caracteres). El formulario muestra la huella de la clave para tu trazabilidad.

El servidor verifica que los tres artefactos son auto-consistentes (yaml↔sig↔pubkey) y valida cada cláusula contra el catálogo antes de tocar nada (fail-closed: una firma que no verifica no deja rastro); solo entonces crea el repositorio y commitea exactamente los bytes recibidos (pliego.oscal.yaml + .sig). El pliego queda registrado con tu clave pública como ancla: es la que los licitadores usarán para verificar el pliego.

4.3 Registrar un pliego ya existente («Explorar pliego»)

Sección titulada «4.3 Registrar un pliego ya existente («Explorar pliego»)»

Si el pliego ya vive en un repositorio (por ejemplo, firmado con froga sign-pliego fuera de la plataforma), regístralo en /[org]/pliegos«Explorar pliego»: Nombre, Conexión SCM, Repositorio (aapp-org/pliego-retina), Referencia (rama o SHA, opcional) y la clave pública de la AAPP firmante (SEC1 hex sin comprimir: 130 caracteres, empieza por 04).

El registro es fail-closed: la nube lee pliego.oscal.yaml + .sig del repo y verifica la firma contra esa clave antes de crear nada. Un repo sin pliego o con firma inválida no se registra. El formulario valida el formato de la clave en línea (te avisa si no son 130 hex con prefijo 04) y, en cuanto pegas una clave bien formada, muestra su huella (los primeros 16 caracteres hex del SHA-256 de la clave): contrástala con la huella que la AAPP vio al firmar el pliego antes de registrarlo.

Cada pliego tiene su expediente en /[org]/pliegos/[slug]: la cabecera muestra el número de cláusulas, el estado de la firma y el estado del expediente«Expediente abierto», o «Adjudicado a licitador · fecha» tras adjudicar (el estado vive en la base de datos, no depende de GitHub; la lista de /pliegos lleva la misma columna Estado). La firma se re-verifica en el servidor en cada carga y tiene tres estados honestos:

  • «Pliego firmado ✓» — la firma verifica contra la clave registrada.
  • «Pliego sin verificar ✗» — la firma es inválida o falta (fallo real de veracidad).
  • «GitHub no disponible — reintenta ⟳» — GitHub no respondió (límite de peticiones o timeout). No es un fallo de firma: es indisponibilidad; vuelve a cargar en unos minutos.

Debajo, las entregas recibidas — los pull requests contra el repo del pliego, con su licitador, el enlace al PR y el veredicto re-derivado:

  • Aceptado — toda la cadena de la sección 2 verifica y todas las cláusulas bloqueantes están cubiertas.
  • Rechazado — con las brechas nombradas (qué cláusula, de qué norma, y por qué), o la razón estructural (digest que no casa, licitador sin PUBKEY.txt…).
  • No evaluable — no se pudo leer el PR o sus artefactos (error del SCM); nunca se convierte en un Aceptado.
  • GitHub no disponible — el proveedor no respondió al evaluar esa entrega (rate-limit/timeout): reintenta. Si GitHub no estaba disponible para el pliego entero, la tabla muestra el aviso honesto en lugar de un «sin entregas» engañoso — las entregas existen, solo que no se pudieron leer.

Los PRs cerrados sin fusionar (intentos retirados) se ignoran; los fusionados se conservan (la entrega adjudicada queda fusionada — es aceptación histórica, ver 4.5). El veredicto se recalcula al cargar la página: si el licitador empuja evidencia nueva al PR, el expediente lo refleja. Con el expediente ya adjudicado, las entregas de los demás licitadores llevan la etiqueta «No seleccionada» — es una marca visual del expediente; su veredicto re-derivado no cambia.

Con el expediente abierto, cada entrega Aceptada ofrece el formulario «Adjudicar»: eliges el nombre del sistema y confirmas. La adjudicación es transaccional: la nube re-verifica en el servidor que la entrega sigue aceptada (nunca acepta el veredicto del cliente) y, en un solo acto:

  • Promueve la entrega a un sistema gobernado de tu organización, anclado al commit exacto de la entrega (el head del PR): la evidencia adjudicada está congelada; nada posterior la altera. Registra el origen (recibido de = la organización del licitador) — la atribución de cadena de suministro se deriva del PR re-verificado, no del formulario. El sistema aparece en Sistemas y en la sección «Sistemas adjudicados» del expediente.
  • Cierra el expediente: el estado pasa a «Adjudicado a licitador · fecha», el formulario de adjudicar desaparece (la decisión está tomada) y las entregas de los demás licitadores quedan etiquetadas «No seleccionada».
  • Fusiona el PR de la entrega (merge squash) en el repositorio del pliego: la entrega adjudicada queda como un commit en la historia del expediente — auditabilidad git de la decisión. Si el merge falla (conflicto, permisos), la adjudicación no se revierte: la UI te avisa para que lo fusiones a mano en GitHub.

La adjudicación es además idempotente: un doble clic o una segunda visita no crean un sistema duplicado — se devuelve el sistema ya promovido. E intentar adjudicar la entrega de otro licitador sobre un expediente ya adjudicado se rechaza («el expediente ya está adjudicado»): la decisión humana registrada manda sobre cualquier reintento posterior.

Aceptado ≠ adjudicado: aceptado es el veredicto de la puerta; adjudicar es tu decisión de incorporarlo — el propio expediente lo recuerda bajo el título de entregas.


Responder a un pliego requiere hoy cuatro piezas:

  1. Cuenta y organización en la plataforma Venturalítica, con rol owner o maintainer.
  2. Organización propia en GitHub (no vale una cuenta personal) con la GitHub App instalada — el fork del pliego aterriza ahí.
  3. El CLI froga en tu máquina: la evidencia se produce y firma en local con froga run; la nube nunca ejecuta el motor ni firma por ti.
  4. Un entitlement de normas si el pliego exige cláusulas de normas de pago (prEN 18228, MDR, DORA…): froga entitlement sync escribe .froga/entitlement.json, firmado por el emisor de Venturalítica. ISO 23894 e ISO/IEC 42001 son libres y no lo requieren. Qué normas licencia cada plan (y qué pasa si el pliego exige una fuera del tuyo), en Planes y normas.

En Sistemas«Responder a un pliego» (/[org]/systems/respond), un asistente de dos pasos:

  • Paso 1 — Conectar al pliego. Pegas la URL del repositorio del pliego en GitHub. La plataforma forkea el repo del pliego a tu organización de GitHub — un fork nativo (metadatos del lado de GitHub): la nube no empuja contenido entre organizaciones, y la autoría de los ficheros del pliego sigue siendo de la AAPP.
  • Paso 2 — Declarar propósito y clasificación. Nombre del sistema, finalidad prevista, clasificación (Anexo III) y clase de riesgo declarada — bajo el AI Act clasificas tú, no la AAPP. La nube genera en tu fork un froga.yaml inicial (un andamio, sin firmar) con responds_to_pliego = el digest exacto del pliego y las normas que el pliego exige, y registra el fork como sistema gobernado en tu organización.

La nube nunca escribe .froga/* ni firma nada en tu nombre. En tu fork, en local:

Ventana de terminal
git clone https://github.com/tu-org/pliego-retina.git && cd pliego-retina
# completa froga.yaml (riesgos, controles, apetito) y tu pipeline de evidencia
froga run # produce y FIRMA la evidencia .froga/*
froga pubkey > .froga/PUBKEY.txt # publica tu clave pública (la nube la lee del PR)
# si el pliego exige normas de pago:
froga entitlement sync --system <id> --url <nube> --token <token>
git add froga.yaml .froga && git commit -m "evidencia firmada" && git push

.froga/PUBKEY.txt en el head de la entrega es cómo la AAPP te identifica: sin ella, tu entrega sale «Rechazado · licitador no enrolado». El detalle del esquema de firma, en firmar y verificar la evidencia.

5.4 Autocomprobarse ANTES de entregar (la compuerta local)

Sección titulada «5.4 Autocomprobarse ANTES de entregar (la compuerta local)»

No entregues a ciegas: la misma puerta que la AAPP re-deriva en la nube corre en tu máquina.

Ventana de terminal
froga conformance --against-pliego pliego.oscal.yaml \
--aapp-pubkey 04… # la clave pública de la AAPP que firmó el pliego

Verifica la firma del pliego, intersecta sus cláusulas bloqueantes con tu evidencia de conformidad firmada, y sale con exit 0 si tu entrega sería Aceptada y exit ≠ 0 si sería Rechazada, nombrando la cláusula que falla. Tu clave pública se resuelve de --pubkey, o de .froga/PUBKEY.txt. Es la referencia completa de froga conformance; métela en tu CI para no descubrir la brecha en el expediente de la AAPP.

En el detalle de tu sistema aparece el panel «Entrega» (solo en sistemas que responden a un pliego): muestra el pliego origen, la guía de desarrollo local, y el botón «Entregar», que abre el pull request de entrega desde tu fork hacia el repo del pliego de la AAPP — con tu propia credencial, nunca la de la AAPP. El PR lleva los metadatos de trazabilidad del traspaso (receivedFrom, deliveryOnly); el contenido ya vive en tu fork. A partir de ahí, tu entrega aparece en el expediente de la AAPP con el veredicto re-derivado de la sección 2.


Lo anterior está construido, demostrado sobre repositorios reales y verificado — y tiene límites que conviene conocer antes de planificar una licitación sobre ello:

  • GitHub.com y GitLab. El expediente funciona sobre GitHub.com (entregas = pull requests) y sobre GitLab — gitlab.com o autohospedado con URL base propia (entregas = merge requests; el fork del licitador, la lectura de la evidencia del head del MR y el merge al adjudicar están implementados; hay un GitLab local de prueba en demo/gitlab/). Publicar un pliego nuevo (crear el repositorio y commitear el YAML + la firma) funciona igual en GitHub y en GitLab — la firma se produce siempre fuera de la nube con froga sign-pliego; la nube solo transporta y verifica. Forgejo y Bitbucket siguen sin listar entregas: la app ya no ofrece esas conexiones al registrar un pliego, precisamente para que ningún expediente aparezca vacío sin aviso.
  • El expediente tiene estado, pero no es (aún) un expediente administrativo completo. El pliego ya tiene estado (abierto / adjudicado), adjudicatario y fecha de adjudicación formales; la adjudicación es transaccional (el doble clic no duplica), cierra el expediente y fusiona el PR de la entrega. Lo que sigue faltando: el plazo de presentación, la notificación formal al licitador no seleccionado (la etiqueta «No seleccionada» es una marca del expediente, no una notificación), y el cierre de un expediente desierto (el estado «Cerrado» existe en el modelo, pero no hay todavía acción para cerrar sin adjudicar).
  • 1 pliego = 1 repositorio. Cada expediente es un repo de GitHub de la AAPP y las entregas son PRs contra él. Eso da auditabilidad git de serie, y también significa que una cartera de decenas de licitaciones/año es una cartera de repos que tu organización de GitHub debe gobernar.
  • El onboarding del licitador no está empaquetado. Cada licitador necesita las cuatro piezas de la sección 5.1 (cuenta en la plataforma, organización GitHub con la App, el CLI en local y el entitlement si hay normas de pago). No existe todavía invitación del licitador desde el pliego ni un flujo «responder sin cuenta».
  • El veredicto de la puerta no es una evaluación de conformidad. «Aceptado» significa que la entrega cumple el criterio del pliego — no que el producto sea conforme cláusula a cláusula con la ley. Ese juicio, para las clases de alto riesgo que lo exigen, solo lo emite un organismo notificado, y no hay ninguno en esta cadena. Es la lección central del Nivel 6.