Saltar a contenido

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-principal o x-finaz-roles se 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

curl -sS http://127.0.0.1:18300/platform/v1/instruments?limit=2

{"code":"CONSTRAINT_VIOLATION","message":"falta token Bearer (Authorization: Bearer <token>)",
 "retryable":false,"trace_id":"…","details":{}}
401

curl -sS -H "x-finaz-principal: mallory" -H "x-finaz-roles: deployer" \
  http://127.0.0.1:18300/platform/v1/instruments?limit=2

{"code":"CONSTRAINT_VIOLATION","message":"cabecera prohibida en modo token: x-finaz-principal",
 "retryable":false,"trace_id":"…","details":{"cabecera":"x-finaz-principal"}}
401

curl -sS -H "Authorization: Bearer <TOKEN>" -H "x-finaz-roles: deployer" \
  http://127.0.0.1:18300/platform/v1/instruments?limit=2

{"code":"CONSTRAINT_VIOLATION","message":"cabecera prohibida en modo token: x-finaz-roles",
 "retryable":false,"trace_id":"…","details":{"cabecera":"x-finaz-roles"}}
401, aunque el token sea bueno.

curl -sS -H "Authorization: Bearer invalido" \
  http://127.0.0.1:18300/platform/v1/instruments?limit=2

{"code":"CONSTRAINT_VIOLATION","message":"token inválido","retryable":false,"trace_id":"…","details":{}}
401

curl -sS -H "Authorization: Bearer <TOKEN_VIEWER>" \
  http://127.0.0.1:18300/platform/v1/runs?limit=2

{"code":"CONSTRAINT_VIOLATION","message":"rol insuficiente para read:own-runs","retryable":false,
 "trace_id":"…","details":{"capacidad":"read:own-runs","finaz_gap":"ADR-AG005-errores-auth"}}
403

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.

curl -sS -H "Authorization: Bearer <TOKEN>" \
  "http://127.0.0.1:18300/platform/v1/flows/fixture.flow.01_A_feature:v1"
import json, urllib.request

req = urllib.request.Request(
    "http://127.0.0.1:18300/platform/v1/flows/fixture.flow.01_A_feature:v1",
    headers={"Authorization": "Bearer <TOKEN>"})
with urllib.request.urlopen(req, timeout=20) as r:
    print(json.loads(r.read()))

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 --> [*]
  • CANCELLED se aplica en la frontera segura (antes de ejecutar). Si pides cancelar un run RUNNING, 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:

{"code":"SCHEMA_INVALID","message":"cabecera Idempotency-Key obligatoria en creaciones", }    400

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); nunca OFFSET (que salta o repite filas si la tabla cambia).
  • limit por defecto 50, máximo 100 (provisional); la respuesta refleja limit_efectivo para que el recorte sea visible.
  • Cursor caducado (TTL provisional 15 min) o manipulado → 400, nunca saltos silenciosos.
  • GET /runs só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
cd ~/FinazTradingEngine
set -a; . ~/finaz-secrets-v2/api_tokens_live; set +a   # FINAZ_TOK_OP / _DEP / _VIEW
python3 deployment/v2/smoke/e2e_batch.py
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: POST202 RunRef; estados QUEUED → 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_efectivo explícito.
  • GET /results/{id} devuelve el ResultRef publicado; OpenAPI 3.1 en /openapi.json.

Para practicar

  1. En el servidor, pide GET /platform/v1/instruments?limit=2 con el token viewer y explica la respuesta {"items":[], …}. ¿Es un error?
  2. Modifica un carácter del next_cursor de un listado de runs y reenvíalo. ¿Qué código obtienes y por qué es mejor que devolver una página cualquiera?
  3. ¿Qué código devuelve GET /runs/{id} si el run existe pero es de otro principal? ¿Por qué no 403?
  4. Escribe la secuencia de peticiones (sin ejecutarla) para admitir un run y cancelarlo antes de que el worker lo coja. ¿Qué run_revision esperarías al final?
  5. Descarga /openapi.json y cuenta cuántas operaciones tienen método POST.