API de plataforma (/platform/v1)¶
Qué vas a aprender¶
- Cómo se autentica uno contra la API de plataforma: token Bearer y tres roles (viewer, operator,
deployer); y por qué cualquier cabecera
x-finaz-*se rechaza. - Qué recursos expone
/platform/v1: flows, runs, results y los de catálogo, con paginación por cursor firmado. - Los estados de un run (
QUEUED → RUNNING → SUCCEEDED / FAILED / CANCELLED). - Cómo funciona la idempotencia (
Idempotency-Key) y cuándo responde 409. - A leer respuestas reales tomadas del servidor y el contrato OpenAPI 3.1.
Fuentes y ejemplos
Código: packages/finaz_api/ (router.py, auth.py, corridas.py, flujos.py,
paginacion.py, openapi.py) y packages/finaz_security/authz.py. Smoke de referencia:
deployment/v2/smoke/e2e_batch.py. Todas las respuestas de este capítulo se obtuvieron
del servidor finaz-new el 2026-09-22 (API en 127.0.0.1:18300). Los tokens se sustituyen por
<TOKEN>; los trace_id y cursores largos se recortan con ….
La API sólo escucha en loopback
127.0.0.1:18300 no es accesible desde fuera del servidor. Los ejemplos se ejecutan en el
servidor (por ejemplo, ssh finaz-new-user y luego curl). La API de plataforma no es pública.
1. Autenticación: quién eres lo dice el token, no tú¶
1.1 El modelo¶
La frontera de autenticación (finaz_api/auth.py, capítulo 12 del pack) funciona así:
- El cliente envía
Authorization: Bearer <token>. - El servidor calcula el SHA-256 del token y lo busca en un fichero JSON montado read-only
(
FINAZ_API_TOKENS_FILE, por defecto/run/secrets/api_tokens.json). En ese fichero sólo hay hashes, no tokens. - El principal y los roles salen del registro, nunca de la petición.
- Cualquier petición que traiga
x-finaz-principalox-finaz-rolesse rechaza con 401, incluso si además trae un token válido.
Analogía: la pulsera del festival
En la entrada te ponen una pulsera (token) y la organización sabe qué zonas abre (roles). Si llegas
a la zona VIP con un cartel que dice «soy VIP» (cabecera x-finaz-roles: deployer), no sólo no
entras: te paran por intentarlo, aunque lleves pulsera.
¿Por qué existían esas cabeceras?
La primera versión de la API aceptaba x-finaz-principal y x-finaz-roles tal cual: cualquiera
podía declararse deployer. La auditoría del 2026-09-19 (docs/v2/GAP_REPORT.md, hallazgo 3) lo
marcó como cierto y crítico. Desde M4, la auth es por token verificado y esas cabeceras sólo se
admiten en modo desarrollo explícito (FINAZ_API_ALLOW_HEADERS=1 y sin fichero de tokens),
nunca en el servidor.
1.2 Roles y capacidades¶
La autorización (finaz_security.authz) no pregunta por roles directamente, sino por
capacidades:
| Capacidad | viewer | operator | deployer | Ejemplos de operación |
|---|---|---|---|---|
read:catalog |
✅ | ✅ | ✅ | instrumentos, datasets, GET /flows/{id}, capacidades |
read:own-runs |
— | ✅ | ✅ | GET /runs, GET /runs/{id}, GET /results/{id} |
write:runs |
— | ✅ | ✅ | POST /runs, POST /plans, cancelar |
write:flows |
— | ✅ | ✅ | POST /flows |
admin:models |
— | — | ✅ | administración de modelos |
admin:datasets |
— | — | ✅ | administración de datasets |
Y tres respuestas distintas según el fallo:
| Código | Cuándo |
|---|---|
| 401 | Sin identidad válida: falta token, token inválido o cabecera x-finaz-* |
| 403 | Identidad válida, rol insuficiente para la capacidad |
| 404 | El recurso es de otro dueño: se responde como si no existiera (no revela existencia ajena) |
1.3 Los rechazos, en vivo¶
El sobre de error y un gap declarado
Todos los errores comparten sobre: code, message, retryable, trace_id, details. Fíjate en
que un 401/403 lleva code: CONSTRAINT_VIOLATION: el catálogo de códigos del contrato (cap. 04 del
pack) todavía no tiene códigos específicos de auth. En vez de inventarlos, la API lo declara como
gap (finaz_gap: ADR-AG005-errores-auth) y el código HTTP es el que distingue.
2. Salud (sin token) y capacidades (con token)¶
curl -sS http://127.0.0.1:18300/healthz
# {"status":"ok"} → 200
curl -sS http://127.0.0.1:18300/health/ready
# {"estado":"listo","rol":"api","dependencias":{"tienda":"lista"}} → 200
/healthz y /health/live dicen «el proceso vive»; /health/ready comprueba sus dependencias (la
tienda PG). Es lo que usa el healthcheck de Compose.
GET /platform/v1/capabilities exige token (sin él responde 401; comprobado el 2026-09-22) y devuelve qué operaciones están realmente disponibles
(disponibilidad: OPERATIVA o no, con motivo). Extracto real:
{"rol":"api","generacion_runtime":2,"capacidades":[
{"operacion":"salud_live","metodo":"GET","ruta":"/health/live","disponibilidad":"OPERATIVA", …},
{"operacion":"crear_flujo","metodo":"POST","ruta":"/platform/v1/flows","disponibilidad":"OPERATIVA", …},
…]}
3. Mapa de recursos¶
La lista de rutas que publica el OpenAPI vivo (GET /openapi.json, versión 3.1.0, título
«FINAZ Platform API» 1.0.0):
| Método | Ruta | Para qué |
|---|---|---|
| GET | /healthz, /health/live, /health/ready |
Salud |
| GET | /platform/v1/capabilities |
Capacidades reales |
| GET | /platform/v1/instruments, /instruments/{id} |
Catálogo de instrumentos |
| GET | /platform/v1/universes/{id} |
Universo versionado |
| GET | /platform/v1/datasets, /datasets/{id} |
Datasets |
| POST/GET | /platform/v1/snapshots, /snapshots/{id} |
Snapshots |
| POST | /platform/v1/flows |
Crear versión inmutable de un flow |
| GET | /platform/v1/flows/{id} |
Leer una versión exacta ({flow_id}:v{n}) |
| POST | /platform/v1/plans |
Compilar un plan (exige el FlowSpec completo; ver capítulo 2) |
| POST/GET | /platform/v1/runs |
Admitir un run / listar los propios |
| GET | /platform/v1/runs/{id} |
Estado de un run |
| POST | /platform/v1/runs/{id}/cancel |
Pedir cancelación |
| GET | /platform/v1/results/{id} |
ResultRef publicado |
| POST/GET | /platform/v1/subscriptions, /subscriptions/{id} |
Suscripciones |
| WS | /platform/v1/events |
Eventos (WebSocket) |
No hay GET /flows (listado)
Un GET /platform/v1/flows responde 405 con "code":"SCHEMA_INVALID", "message":"método no permitido para esta ruta" y "details":{"permitidos":["POST"]}. Los flows se leen por
versión exacta. Curiosidad: el 405 llega incluso sin token, porque el enrutado (¿existe este
método?) se resuelve antes que la autenticación.
Sin FastAPI en el núcleo
El router y el generador OpenAPI son propios (router.py, openapi.py); FastAPI/uvicorn sólo es
el adaptador HTTP (adaptador_fastapi.py). Por eso las rutas /{ruta_completa} que ves al final
del OpenAPI son el catch-all que entrega cada petición al router propio.
4. Flows: versiones inmutables¶
POST /platform/v1/flows valida el FlowSpec, busca claves prohibidas y crea la siguiente versión
del flow_id. El ID de recurso es {flow_id}:v{versión}. No existe «latest» implícito.
Respuesta real (200):
{"flow_id":"fixture.flow.01_A_feature","flow_version":1,
"canonical_hash":"84c4dcd89812f838a1c68eb4b3758c8f28ff2f93786dfa72aaed408706006ebc",
"nodos":4,"aristas":3,"requested_modes":["BATCH","REPLAY","STREAM"],
"solicitado_por":"s4.deployer"}
Sin versión (…/flows/fixture.flow.01_A_feature) la respuesta es 404
REFERENCE_NOT_FOUND «flujo no visible o inexistente».
5. Runs: admisión, estados e idempotencia¶
5.1 Admitir un run¶
POST /platform/v1/runs
Authorization: Bearer <TOKEN_OPERATOR>
Idempotency-Key: mi-clave-001
Content-Type: application/json
{"flow_id": "fixture.flow.01_A_feature", "flow_version": 1,
"mode": "research", "snapshot_ref": {"snapshot_id": "fixture.snapshot.01"}, "seed": 7}
La API resuelve el flow exacto (404 si no existe), valida el snapshot (hoy el fixture G1 autorizado
u otro snapshot admitido; otro → 422), compila en BATCH, construye el RunSpec con spec_hash
canónico y run_id determinista, y guarda run + evento de outbox en una transacción. Responde
202 con el RunRef: aceptado, no terminado.
5.2 Estados de un run¶
stateDiagram-v2
[*] --> QUEUED: POST /runs (202)
QUEUED --> RUNNING: worker reclama (CAS)
QUEUED --> CANCELLED: cancel antes de ejecutar
RUNNING --> SUCCEEDED: resultado publicado
RUNNING --> FAILED: error visible en «errores»
RUNNING --> QUEUED: worker muerto, lease vencido (recuperación)
SUCCEEDED --> [*]
FAILED --> [*]
CANCELLED --> [*]
CANCELLEDse aplica en la frontera segura (antes de ejecutar). Si pides cancelar un runRUNNING, la solicitud queda registrada y el worker la respeta en su siguiente frontera; si ya es terminal, la cancelación es idempotente y sin efecto.- Cada transición incrementa
run_revision.
5.3 Leer un run real¶
curl -sS -H "Authorization: Bearer <TOKEN_OPERATOR>" \
http://127.0.0.1:18300/platform/v1/runs/run_fe323e2efb67c020438fa9514a6a9dec5342d037b4fe4356d9597fa11298e1d4
{"run_id":"run_fe323e2e…98e1d4","run_revision":2,"runtime_generation":2,
"estado":"SUCCEEDED","mode":"research",
"flow":{"flow_id":"fixture.flow.01_A_feature","flow_version":7},
"snapshot_ref":{"snapshot_id":"fixture.snapshot.01"},"seed":7,"plan_ref":null,
"result_refs":["result.batch.ada4aca5076b4557"],"errores":[],
"solicitado_por":"s4.operator"}
run_revision: 2 cuenta las dos transiciones (QUEUED→RUNNING→SUCCEEDED). Un run inexistente (o de
otro dueño) da 404 "corrida no visible o inexistente".
5.4 Idempotencia en vivo¶
Toda creación exige Idempotency-Key. Sin ella:
Repetir la misma clave con el mismo cuerpo devuelve la respuesta original sin crear nada. Real,
reenviando el flow del smoke con su clave (s4e2e-flow1):
{"nodos":4,"aristas":3,"flow_id":"fixture.flow.01_A_feature","flow_version":1,
"canonical_hash":"84c4dcd8…6ebc","solicitado_por":"s4.deployer",
"requested_modes":["BATCH","REPLAY","STREAM"]} → 201
Es la v1 original, no una v8: la respuesta se ha reproducido, no recalculado.
Misma clave con otro cuerpo (seed 8 en vez de 7, clave s4e2e-run1):
{"code":"CONSTRAINT_VIOLATION","message":"clave de idempotencia ya usada con otra solicitud",
"retryable":false,"trace_id":"…","details":{"conflicto":"idempotencia"}} → 409
Analogía: el número de pedido
La Idempotency-Key es el número de pedido que tú eliges. Si llamas dos veces con el mismo número y
el mismo pedido, la tienda te dice «ya lo tengo». Si usas el mismo número para un pedido distinto,
te para: no sabe cuál de los dos querías (409).
5.5 Listar con paginación¶
curl -sS -H "Authorization: Bearer <TOKEN_OPERATOR>" \
"http://127.0.0.1:18300/platform/v1/runs?limit=2"
{"items":[{"run_id":"run_fe323e2e…","estado":"SUCCEEDED", …},
{"run_id":"run_b9af3891…","estado":"SUCCEEDED", …}],
"next_cursor":"eyJleHAiOjE3OTAxMDk0MDYu…7_YkKOYY55N0FswRhtHbmHKpn0q18fpWzDJ_r3hcy0g",
"limit_solicitado":2,"limit_efectivo":2}
La paginación (paginacion.py) es keyset con cursor opaco y firmado:
- El cursor es base64url de
{f, o, k, exp}(filtros, orden, última clave, caducidad) + firma HMAC-SHA256. - Orden estable
(seq, id); nuncaOFFSET(que salta o repite filas si la tabla cambia). limitpor defecto 50, máximo 100 (provisional); la respuesta reflejalimit_efectivopara que el recorte sea visible.- Cursor caducado (TTL provisional 15 min) o manipulado → 400, nunca saltos silenciosos.
GET /runssólo lista tus runs.
6. Results: el ResultRef publicado¶
curl -sS -H "Authorization: Bearer <TOKEN_OPERATOR>" \
http://127.0.0.1:18300/platform/v1/results/result.batch.ada4aca5076b4557
{"schema_version":"1.0.0","kind":"ResultRef",
"result_id":"result.batch.ada4aca5076b4557",
"manifest_hash":"sha256:7a567ce41eace8adfb11d9428b2cdbcde93bfdb26e398535e7fd368cb5be08a9",
"snapshot_manifest_hash":"sha256:257112e2503d21b924e94c39e9ff4a85cbeb8e1e521da2aac8b128d382058e85",
"run_id":"run_fe323e2e…98e1d4","epoch":0,
"published_at":"2026-09-19T12:17:20.387910+00:00"}
Un ResultRef no trae los valores: es la referencia publicada e inmutable, con el hash del
manifiesto del resultado y del snapshot de entrada. Se lee de PG (la única puerta de visibilidad):
si no está publicado, no existe para la API.
7. El flujo completo en un script¶
deployment/v2/smoke/e2e_batch.py recorre el vertical con 10 comprobaciones (G1/S4 PASS):
sequenceDiagram
autonumber
participant S as smoke
participant API as api
S->>API: GET /healthz (sin auth) → 200
S->>API: GET /instruments sin token → 401
S->>API: GET /instruments con x-finaz-* → 401
S->>API: token inválido + cabecera → 401
S->>API: POST /flows (deployer) → 201
S->>API: POST /runs (operator) → 202
S->>API: mismo POST, misma clave → mismo run_id
S->>API: misma clave, otro cuerpo → 409
loop hasta 24 × 5 s
S->>API: GET /runs/{id}
end
Note over S,API: estado SUCCEEDED
S->>API: GET /results/{result_refs[0]} → 200
import json, os, time, urllib.request
BASE = "http://127.0.0.1:18300"
TOK = os.environ["FINAZ_TOK_OP"] # nunca lo imprimas
def llamar(metodo, ruta, cuerpo=None, clave=None):
cab = {"Content-Type": "application/json", "Authorization": "Bearer " + TOK}
if clave:
cab["Idempotency-Key"] = clave
datos = json.dumps(cuerpo).encode() if cuerpo is not None else None
req = urllib.request.Request(BASE + ruta, data=datos, method=metodo, headers=cab)
with urllib.request.urlopen(req, timeout=20) as r:
return r.status, json.loads(r.read() or b"{}")
st, run = llamar("POST", "/platform/v1/runs",
{"flow_id": "fixture.flow.01_A_feature", "flow_version": 1,
"mode": "research",
"snapshot_ref": {"snapshot_id": "fixture.snapshot.01"},
"seed": 7},
clave="mi-clave-unica")
while True:
_, r = llamar("GET", f"/platform/v1/runs/{run['run_id']}")
if r["estado"] in ("SUCCEEDED", "FAILED", "CANCELLED"):
break
time.sleep(5)
Los tokens, fuera del manual y de los logs
Los tokens vivos están en ~/finaz-secrets-v2/api_tokens_live (fuera del repo). Cárgalos con
set -a; . fichero; set +a y no los imprimas, ni en consola ni en informes.
8. OpenAPI 3.1¶
GET /openapi.json (sin token) devuelve el contrato de la API en OpenAPI 3.1.0. Úsalo para:
- generar clientes,
- comprobar qué rutas existen (y cuáles no, como el listado de flows),
- ver los esquemas de cuerpo de cada operación.
curl -sS http://127.0.0.1:18300/openapi.json | python3 -c \
'import json,sys; d=json.load(sys.stdin); print(d["openapi"], d["info"]["title"])'
# 3.1.0 FINAZ Platform API
Resumen¶
- Auth Bearer verificada por hash contra un fichero read-only; roles viewer/operator/deployer
mapeados a capacidades;
x-finaz-*→ 401 siempre. - 401 sin identidad, 403 sin rol, 404 para recursos ajenos o inexistentes.
- Flows inmutables por versión exacta (
flow_id:vN); no hay listado ni «latest». - Runs:
POST→ 202RunRef; estadosQUEUED → RUNNING → SUCCEEDED / FAILED / CANCELLED. - Idempotencia: misma clave + mismo cuerpo = misma respuesta; otro cuerpo = 409; sin clave = 400.
- Paginación keyset con cursor firmado HMAC y
limit_efectivoexplícito. GET /results/{id}devuelve elResultRefpublicado; OpenAPI 3.1 en/openapi.json.
Para practicar¶
- En el servidor, pide
GET /platform/v1/instruments?limit=2con el token viewer y explica la respuesta{"items":[], …}. ¿Es un error? - Modifica un carácter del
next_cursorde un listado de runs y reenvíalo. ¿Qué código obtienes y por qué es mejor que devolver una página cualquiera? - ¿Qué código devuelve
GET /runs/{id}si el run existe pero es de otro principal? ¿Por qué no 403? - Escribe la secuencia de peticiones (sin ejecutarla) para admitir un run y cancelarlo antes de que el
worker lo coja. ¿Qué
run_revisionesperarías al final? - Descarga
/openapi.jsony cuenta cuántas operaciones tienen métodoPOST.