T0 — Contratos de integración¶
Estado: cerrado · Fecha: 2026-08-11 · Dueño: @quanpg03 · Revisión: 2
Fija cómo se hablan entre sí las partes del sistema. No es código: es la hoja de decisiones que permite que Migue, Nat y yo trabajemos en paralelo sin pisarnos.
Regla: si algo aquí cambia, se cambia aquí primero y se avisa. Nadie "arregla" un contrato en su propio código.
Contrato 1 — El objeto Principal¶
Lo que devuelve la autenticación y lo que consume todo endpoint. Cognito valida la identidad; los permisos salen de nuestra base de datos, nunca del token.
class Scope(BaseModel):
aliado_id: UUID | None = None # si el usuario es un aliado
cliente_id: UUID | None = None # si el usuario es un cliente
class Principal(BaseModel):
user_id: UUID
email: str
nombre: str
capabilities: frozenset[str] # ya expandidas, ver Contrato 2
scope: Scope
roles: list[str] # SOLO para mostrar y depurar
session_id: UUID
def can(self, capability: str) -> bool:
return capability in self.capabilities
Reglas¶
rolesnunca autoriza. Está ahí para pintar "Gerencia" en el perfil y para depurar. Cualquierif principal.roles == ...en el código es un error de revisión. Se autoriza siempre concan().- Un usuario puede tener varios roles. Las capacidades se suman: si alguien es vendedor y gerencia, tiene la unión. Nunca la intersección.
scopepuede tener ambos campos llenos. Un aliado que además contrató a NHEO es aliado y cliente a la vez. Nada puede asumir que son excluyentes.- El
Principalse resuelve en cada request, leyendouser_roles. No se cachea entre requests. Es lo que hace que revocarle un rol a alguien tenga efecto inmediato en vez de esperar 8 horas a que expire su token. session_idexiste para revocar sesiones individuales (SEG-14).
Rutas de autoservicio (sin capacidad)¶
Algunas rutas no requieren capacidad, solo estar autenticado, y siempre operan sobre uno mismo:
GET/PATCH /auth/perfil GET /auth/sesiones DELETE /auth/sesiones/{id}
GET /notificaciones POST /notificaciones/{id}/leer
GET/PATCH /notificaciones/preferencias
Están enumeradas aquí a propósito. Cualquier ruta que no esté en esta lista lleva
requires(...) obligatorio, y el test de T6 recorre el router verificando que no haya
ninguna sin proteger. Las encuestas que responde el cliente (CSAT y NPS) se contestan por
enlace con token de un solo uso, fuera de la sesión: tampoco llevan capacidad.
Contrato 2 — Catálogo de capacidades¶
67 capacidades, formato recurso:accion. Base: las 6 matrices de permisos del documento
técnico §3, más las desviaciones registradas al final de esta sección.
Vocabulario de acciones¶
| Acción | Significa |
|---|---|
read |
Ver, limitado a lo mío (mi cartera, mis asignados, mis referidos) |
read_all |
Ver todo, sin restricción de pertenencia |
create / edit |
Crear / modificar |
export |
Descargar en CSV o Excel |
approve |
Aprobar en un flujo (comisiones, gastos, viajes) |
adjust |
Emitir una corrección contable |
execute / manage / reconcile / pay |
Acciones con efecto externo sobre dinero |
admin |
Administración completa del recurso |
Tres reglas de resolución¶
read_allreemplaza aread. Al construir elPrincipal, si el usuario tieneX:read_allse le añadeX:readautomáticamente. En la matriz de abajo solo se marcaread_all— marcar ambas sería redundante y hace ambiguo cualquier conteo.- El alcance de escritura es igual al de lectura. Con
clientes:edity soloclientes:read, editas tu cartera. Conread_all, editas todo. No existenedit_allnicreate_all: sería un tercer eje que nadie mantiene bien. - En
create, el alcance se valida conWITH CHECK, no conUSING. No hay fila previa contra la cual evaluar pertenencia, así que la política RLS debe verificar la fila nueva: un vendedor no puede crear un cliente convendedor_idde otro, y un cliente no puede abrir un ticket sobre unproyecto_idajeno. SinWITH CHECKel aislamiento se puede burlar escribiendo en vez de leyendo.
El catálogo¶
# Usuarios y auditoría (3)
usuarios:read usuarios:admin auditoria:read
# Clientes (5)
clientes:read clientes:read_all clientes:create
clientes:edit clientes:export
# Proyectos (5)
proyectos:read proyectos:read_all proyectos:create
proyectos:edit proyectos:read_internal
# Tareas y bloqueos (4)
tareas:read tareas:create tareas:edit
bloqueos:create
# Comunicación con el cliente (4)
actualizaciones:read actualizaciones:publish
conversacion:read conversacion:write
# Tickets (5)
tickets:read tickets:read_all tickets:create
tickets:respond tickets:assign
# Facturas (5)
facturas:read facturas:read_all facturas:create
facturas:edit facturas:export
# Pagos y suscripciones (5)
pagos:read pagos:read_all pagos:pay
pagos:manage pagos:reconcile
# Comisiones (5)
comisiones:read comisiones:read_all comisiones:approve
comisiones:adjust comisiones:export
# Aliados (6)
aliados:read aliados:create aliados:edit
aliados:assign_client pagos_aliados:read pagos_aliados:execute
# Gastos, actividades y viajes (9)
gastos:read gastos:create gastos:edit
gastos:approve actividades:read actividades:create
viajes:read viajes:create viajes:approve
# Encuestas (2)
encuestas:read encuestas:manage
# Reportes y finanzas (7)
kpis:read kpis:read_all reportes:read
reportes:configure reportes:export finanzas:read
finanzas:export
# Sistema (2)
config:manage jobs:admin
Seis capacidades que merecen explicación¶
proyectos:read_internal — separa las notas internas del resto del proyecto. Sin ella no
ves la pestaña de notas internas aunque puedas ver el proyecto. Es lo que impide que el aliado
o el cliente vean "parche feo para la API legacy del cliente".
actualizaciones:publish vs conversacion:write — son cosas distintas y por eso son dos
capacidades. actualizaciones es la bitácora curada que el equipo redacta en un sentido;
conversacion es el hilo de ida y vuelta con el cliente. El cliente puede escribir en el
segundo, nunca en el primero.
comisiones:adjust — el documento §3-A le da a Gerencia "Editar" sobre comisiones. Con el
ledger append-only eso no existe: editar es emitir un evento de corrección. Esta capacidad
es la traducción, y es la más sensible del sistema: quien la tiene puede mover dinero que ya se
calculó. Va separada de approve a propósito, para que aprobar un lote y corregir un monto no
sean el mismo permiso.
pagos:manage — pausar y cancelar suscripciones, y emitir reembolsos. Efecto irreversible
sobre dinero de un cliente real.
bloqueos:create — separada de proyectos:edit porque §5.5 dice que cualquier miembro del
equipo puede registrar un bloqueo, incluido un vendedor que no debería poder editar el
proyecto.
config:manage — SLA por plan, umbral de alerta de comisión, pesos de las fases. Cambiar un
peso cambia el % de avance que ve el cliente: no es una pantalla inocente.
Matriz rol → capacidades¶
Recuerda la regla 1: donde aparece read_all no se marca read, se deriva.
| Capacidad | Gerencia | Ventas | Desarrollo | Cliente | Admin | Aliado |
|---|---|---|---|---|---|---|
| usuarios:read | ● | |||||
| usuarios:admin | ● | |||||
| auditoria:read | ● | |||||
| clientes:read | ● | ● | ● | |||
| clientes:read_all | ● | ● | ||||
| clientes:create | ● | ● | ||||
| clientes:edit | ● | ● | ● | |||
| clientes:export | ● | ● | ||||
| proyectos:read | ● | ● | ● | ● | ||
| proyectos:read_all | ● | |||||
| proyectos:create | ● | |||||
| proyectos:edit | ● | |||||
| proyectos:read_internal | ● | ● | ||||
| tareas:read | ● | ● | ||||
| tareas:create | ● | |||||
| tareas:edit | ● | |||||
| bloqueos:create | ● | ● | ● | |||
| actualizaciones:read | ● | ● | ● | |||
| actualizaciones:publish | ● | |||||
| conversacion:read | ● | ● | ● | |||
| conversacion:write | ● | ● | ||||
| tickets:read | ● | ● | ● | |||
| tickets:read_all | ● | |||||
| tickets:create | ● | ● | ||||
| tickets:respond | ● | ● | ||||
| tickets:assign | ● | |||||
| facturas:read | ● | |||||
| facturas:read_all | ● | ● | ||||
| facturas:create | ● | |||||
| facturas:edit | ● | |||||
| facturas:export | ● | ● | ||||
| pagos:read | ● | ● | ● | ● | ||
| pagos:read_all | ● | ● | ||||
| pagos:pay | ● | |||||
| pagos:manage | ● | |||||
| pagos:reconcile | ● | |||||
| comisiones:read | ● | ● | ● | |||
| comisiones:read_all | ● | ● | ||||
| comisiones:approve | ● | ● | ||||
| comisiones:adjust | ● | |||||
| comisiones:export | ● | ● | ● | |||
| aliados:read | ● | ● | ||||
| aliados:create | ● | |||||
| aliados:edit | ● | |||||
| aliados:assign_client | ● | |||||
| pagos_aliados:read | ● | ● | ● | |||
| pagos_aliados:execute | ● | |||||
| gastos:read | ● | ● | ||||
| gastos:create | ● | |||||
| gastos:edit | ● | ● | ||||
| gastos:approve | ● | |||||
| actividades:read | ● | ● | ● | |||
| actividades:create | ● | ● | ||||
| viajes:read | ● | ● | ● | |||
| viajes:create | ● | ● | ||||
| viajes:approve | ● | |||||
| encuestas:read | ● | ● | ||||
| encuestas:manage | ● | |||||
| kpis:read | ● | ● | ● | |||
| kpis:read_all | ● | |||||
| reportes:read | ● | ● | ||||
| reportes:configure | ● | |||||
| reportes:export | ● | ● | ||||
| finanzas:read | ● | ● | ||||
| finanzas:export | ● | ● | ||||
| config:manage | ● | |||||
| jobs:admin | ● |
Totales (verificados contando la tabla): Gerencia 37 · Ventas 14 · Desarrollo 23 · Cliente 10 · Admin 30 · Aliado 6. Suma de marcas: 120 sobre 402 celdas posibles.
Las 6 capacidades del Aliado¶
clientes:read · proyectos:read · pagos:read · comisiones:read · comisiones:export ·
pagos_aliados:read
Todas de lectura, ninguna de escritura. Cualquier PR que agregue una capacidad a esta columna pasa por mí sin excepción. El test de aislamiento de T6 debe verificar exactamente estas 6 y fallar si aparece una séptima.
Fíjate en lo que no tiene: actualizaciones:read ni conversacion:read. El documento
§5.6 dice literalmente que el aliado no accede a comunicaciones internas, y su vista de
proyectos es "resumen: fase + % avance". Darle la bitácora del cliente sería filtrar la
relación comercial de NHEO con ese cliente. pagos:read sí lo tiene porque §3-F y §6.6 le
prometen ver si el cliente está "pagando/atrasado" y su "último pago".
Desviaciones registradas respecto al documento §3¶
El §3 no cubre todo el sistema, así que estas capacidades no salen de sus matrices. Se listan para que nadie las tome por error de transcripción.
| Capacidad y rol | De dónde sale | Por qué |
|---|---|---|
| Las 27 de Admin sobre clientes, aliados, comisiones y encuestas | §6.1, §6.2, §6.4, §4.12 | §3-E solo enumera facturas, gastos, suscripciones, caja, reportes, conciliación y pagos a aliados, pero §6 le asigna a Admin todo el ciclo del aliado |
tickets:read_all a Gerencia |
§9.1 | Necesita el KPI "Tickets Abiertos" |
tickets:create, tickets:assign a Desarrollo |
§4.7, SOP-03 | El equipo abre tickets a nombre del cliente; la asignación es automática pero alguien debe poder reasignar |
proyectos:create a Ventas |
§8 Flujo 1 | "Admin/Ventas crea el cliente… se crea el proyecto inicial" |
bloqueos:create a Ventas y Gerencia |
§5.5 | "Cualquier miembro del equipo puede registrar un bloqueo" |
kpis:read a Ventas y Desarrollo |
§3-C, §3-B | "Métricas de ventas propias" y el dashboard propio del dev |
comisiones:adjust a Gerencia |
§3-A "Editar" | Traducción de "editar" al modelo append-only |
viajes:read/create a Ventas y Desarrollo |
§4.10 | §3 solo menciona la aprobación; alguien tiene que solicitarlos |
tareas:read NEGADO al Cliente |
decisión propia | §3-D y §5.6 se lo dan, pero decidimos sustituirlo por la bitácora curada (DAT-07). Es una desviación deliberada del documento original |
facturas:read y actualizaciones:read NEGADOS a Ventas |
decisión propia | §3-C le da "estado de pagos", que resolvemos con pagos:read. Si en la práctica el vendedor necesita ver la factura, se agrega — pero que sea una decisión, no un descuido |
Contrato 2b — Cómo llega el alcance a RLS¶
Al abrir cada transacción, antes de cualquier consulta:
# set_config, NUNCA "SET LOCAL app.x = '<valor>'" con interpolación de cadenas
await session.execute(
text("SELECT set_config('app.current_user_id', :uid, true),"
" set_config('app.current_aliado_id', :ali, true),"
" set_config('app.current_cliente_id', :cli, true),"
" set_config('app.scope_clientes', :sc, true),"
" set_config('app.scope_proyectos', :sp, true),"
" set_config('app.scope_facturas', :sf, true),"
" set_config('app.scope_pagos', :spa, true),"
" set_config('app.scope_tickets', :st, true),"
" set_config('app.scope_comisiones', :sco, true)"),
{...}
)
Por qué hay un alcance por recurso y no uno global¶
Esto era un bug grave en la revisión 1 y vale la pena entender por qué.
La primera versión fijaba un solo booleano, app.ve_todo = true si tiene alguna capacidad
:read_all. Admin tiene clientes:read_all, facturas:read_all, pagos:read_all y
comisiones:read_all — así que ve_todo habría sido true, y las políticas RLS de
proyectos, tareas y tickets le habrían devuelto todas las filas. Justo lo contrario de lo
que dice §3-E, que le niega a Admin el acceso a proyectos de desarrollo.
Con Gerencia el error habría pasado desapercibido, porque su ve_todo = true coincide con lo
deseado. Se habría descubierto en producción, con un administrativo viendo notas de proyectos.
Regla: app.scope_<recurso> vale 'all' u 'own', y se deriva de la capacidad
<recurso>:read_all de ese recurso concreto. Nunca de la presencia de cualquier otra.
tareas y actualizaciones heredan app.scope_proyectos; no tienen GUC propio.
Detalles que rompen si se ignoran¶
- Los GUC son texto. Un
aliado_idnulo llega como cadena vacía, y''::uuidlanzainvalid input syntax. En las políticas:NULLIF(current_setting('app.current_aliado_id', true), '')::uuid. - Siempre
current_setting(..., true)(la forma de dos argumentos). Sin eltrue, una variable no fijada lanza error42704en vez de devolverNULL. set_config(..., true)es local a la transacción, que es lo que queremos con pooling.- Migue: verifica esto explícitamente en T6. Es el punto exacto donde RLS y el pool de conexiones se pelean, y donde vive el plan B documentado en SEG-10.
El worker no tiene Principal¶
comision.calcular, stripe.reconciliar_dia y email.enviar corren en el servicio worker,
sin request y sin usuario. Si intentan operar bajo estas políticas, todas las consultas
devuelven cero filas.
Decisión: el worker se conecta con un rol de base de datos distinto, nheo_worker, con
BYPASSRLS, y su propio pool de conexiones. La API nunca usa ese rol.
A cambio de esa potencia, dos reglas obligatorias:
1. Todo query del worker filtra explícitamente por tenant en el código. RLS ya no lo protege.
2. Ningún endpoint HTTP puede terminar ejecutándose bajo nheo_worker. Que sean pools
separados no es cosmético: es lo que hace que un error de importación no le dé a un request
de un aliado acceso total.
Alternativa considerada y rechazada: un GUC app.is_system = 'true' con una cláusula OR en
cada política. Más simple de montar, pero deja el bypass total a una cadena de distancia dentro
del mismo pool que atiende a los aliados. El rol separado hace el error mucho menos probable.
Contrato 3 — Respuestas HTTP¶
Éxito: cuerpo desnudo¶
GET /api/v1/clientes/018f2a...
200 OK
{ "id": "018f2a...", "nombre_empresa": "Distribuidora El Roble SAS",
"estado": "ACTIVO", "mrr": "2400000.00", "created_at": "2026-03-15T14:22:00Z" }
Listas, siempre igual:
GET /api/v1/clientes?limit=50&cursor=eyJjIjoi...
200 OK
{ "items": [ {...}, {...} ], "next_cursor": "eyJjIjoi...", "prev_cursor": null }
- Orden por defecto:
created_at DESC, id DESC. Elides el desempate y es obligatorio: sin él, dos filas con el mismo timestamp hacen que el cursor salte o repita registros. - El cursor es opaco: base64 de
{"created_at": ..., "id": ...}. El frontend no lo interpreta. limitpor defecto 50, máximo 200. Un?limit=100000se recorta a 200, no da error.- Hay
prev_cursorporque el diseño usa botones anterior/siguiente.nullen los extremos. - No hay conteo total. Contar con filtros es caro y el diseño ya decidió no mostrar números de página.
Creación devuelve 201 con el objeto. Acciones sin cuerpo, 204.
Operaciones por lote¶
POST /api/v1/comisiones/aprobar-lote no puede devolver 204: hay que saber qué se aprobó y qué
no. Devuelve 200 con:
{
"procesados": 47,
"fallidos": [
{ "id": "018f...", "code": "COMISION_YA_APROBADA",
"message": "Esta comisión ya fue aprobada." }
]
}
Error: siempre envuelto¶
Conflicto de estado (409) — sin details:
{ "error": {
"code": "COMISION_YA_APROBADA",
"message": "Esta comisión ya fue aprobada y no se puede modificar.",
"request_id": "01J8XQ2M4K7P9R" } }
Validación (422) — con details:
{ "error": {
"code": "VALIDACION",
"message": "Revisa los campos marcados.",
"details": [ { "field": "porcentaje", "message": "Debe estar entre 0 y 100" } ],
"request_id": "01J8XQ2M4K7P9R" } }
| Campo | Para qué |
|---|---|
code |
Estable, en mayúsculas, para que el frontend decida. Nunca cambia una vez publicado |
message |
En español, listo para mostrar. Sin jerga técnica |
details |
Solo en 422. Es lo que pinta los errores bajo cada campo del formulario |
request_id |
Igual al header X-Request-Id. Es lo que el usuario reporta y lo que buscamos en Sentry |
X-Request-Id: la API lo acepta si viene, y si no lo genera. Va en la respuesta de todo
request, con éxito o con error, y en todas las líneas de log (OPS-02).
Códigos de estado¶
| Código | Cuándo | Qué hace el frontend |
|---|---|---|
| 400 | Petición mal formada | Mostrar message |
| 401 | Token vencido | Refrescar y reintentar |
401 + code: SESION_REVOCADA |
Sesión cerrada desde otro lado o rol revocado | No refrescar. Ir directo a login |
| 403 | Le falta la capacidad | Pantalla "sin permiso" |
| 404 | No existe o no lo puede ver | Pantalla "no encontrado" |
| 409 | Conflicto de estado | Mostrar message y recargar |
| 422 | Validación de campos | Pintar details bajo cada campo |
429 + header Retry-After |
Demasiadas peticiones | Esperar los segundos que indique |
| 500 | Error nuestro | Pantalla de error con el request_id |
El 401 con SESION_REVOCADA no es un detalle. Sin él: revocamos la sesión en nuestra base,
pero el refresh token sigue siendo válido en Cognito, así que el frontend refresca con éxito,
vuelve a recibir 401, refresca otra vez — bucle infinito contra Cognito.
La regla del 404¶
Si RLS filtra una fila, la API devuelve 404, no 403. Para un aliado que pide el cliente de otro aliado, ese cliente sencillamente no existe.
Es deliberado: un 403 confirmaría que el recurso existe, y eso ya es una fuga — un aliado podría enumerar IDs para saber cuántos clientes tiene NHEO. El 403 se reserva para cuando le falta la capacidad, no el alcance.
Dos casos derivados, para que dos devs no los resuelvan distinto:
- Un PATCH cuyo UPDATE afecta 0 filas por RLS → 404. No 403, no 200 silencioso.
- Un POST cuyo cuerpo referencia un id fuera de alcance (crear un ticket sobre un proyecto
ajeno) → 422 con details sobre ese campo, no 404. Es un error de formulario, no de ruta.
Nota de implementación¶
FastAPI devuelve por defecto {"detail": [...]}, que no es este contrato. Hay que registrar
exception_handler para RequestValidationError y HTTPException que los normalice. Media
hora en T1, y evita que el frontend maneje dos formatos.
Contrato 4 — Encolado de trabajos¶
def enqueue(
job_type: str,
payload: dict,
*,
run_at: datetime | None = None, # None = lo antes posible
idempotency_key: str | None = None,
max_attempts: int = 5,
) -> UUID: # id del job, nuevo o el existente
...
Cinco reglas¶
1. enqueue() va dentro de la misma transacción que la escritura de dominio.
Es la ventaja de tener la cola en Postgres y no en Redis: si la transacción se revierte, el
trabajo también. Nunca queda encolado un correo de "comisión generada" para una comisión que no
se guardó.
2. La deduplicación usa ON CONFLICT DO NOTHING, nunca una excepción.
INSERT INTO jobs (id, job_type, payload, run_at, idempotency_key, max_attempts)
VALUES (...)
ON CONFLICT (job_type, idempotency_key) DO NOTHING
RETURNING id;
-- si no devolvió fila: SELECT id FROM jobs WHERE job_type = ... AND idempotency_key = ...
Esto era un bug en la revisión 1. Decía "restricción UNIQUE" a secas: un segundo INSERT
con la misma llave habría lanzado IntegrityError, y en Postgres eso aborta la transacción
completa, revirtiendo la escritura de dominio que la envolvía. El procesador de eventos de
Stripe habría perdido escrituras la primera semana. enqueue() nunca lanza por duplicado:
devuelve el id del trabajo existente.
3. La llave de idempotencia protege el encolado, no la ejecución. Con reintentos la entrega es al menos una vez: si el worker muere después de insertar la comisión y antes de marcar el trabajo, el reintento la inserta otra vez.
La que realmente salva es la restricción
UNIQUEde dominio sobrecommission_events, no la dejobs. Y todo manejador debe ser idempotente: comprobar si su efecto ya ocurrió antes de aplicarlo. Nat: esto es requisito de aceptación de T8 y del motor de comisiones.
4. idempotency_key es obligatoria en los trabajos de dinero.
La lista, explícita y verificada en tiempo de ejecución por enqueue():
comision.calcular · comision.corregir · pago_aliado.registrar · stripe.procesar_evento.
Llamar a uno de estos sin llave lanza ValueError en desarrollo y en producción. No es una
recomendación en prosa: es una comprobación.
5. El payload guarda identificadores, no objetos.
{"comision_id": "018f..."}, nunca la comisión serializada. Cuando el trabajo se ejecute —quizá
20 minutos después por un reintento— los datos pueden haber cambiado, y el worker debe leer el
estado actual.
Reproceso: por qué la llave lleva run_id¶
COM-08 exige poder recalcular todo el histórico desde stripe_events cuando cambien las reglas
de comisión. Con una llave eterna, reencolar los mismos eventos no encolaría nada, en
silencio, porque todas las llaves ya existirían.
Por eso la llave de los trabajos de recálculo lleva un discriminador de corrida:
recalc:{run_id}:{stripe_event_id}. Cada reproceso usa un run_id nuevo. Las llaves de la
operación normal no lo llevan.
Además, un job de mantenimiento purga las filas de jobs completadas con más de 90 días.
Semántica de ejecución¶
| Aspecto | Decisión |
|---|---|
| Toma de trabajos | SELECT ... FOR UPDATE SKIP LOCKED, lotes de 10 |
| Sondeo | Cada 2 segundos. HU-031 exige comisión calculada en ≤2 minutos con dos saltos asíncronos; con sondeo de 30s no se cumple |
| Arrendamiento | locked_at + locked_by. Un trabajo tomado hace más de 5 minutos se considera huérfano y vuelve a la cola. Sin esto, un worker que muere deja el trabajo tomado para siempre |
| Reintentos | Espera exponencial: 10s, 40s, 3min, 12min, 50min |
Al agotar max_attempts |
Pasa a AGOTADO, sale de la cola, y dispara alerta si el job_type es de dinero |
| Reproceso manual | Desde la pantalla 117, que reencola con run_id nuevo |
Nombres de trabajos¶
dominio.accion, en minúscula: stripe.procesar_evento · stripe.reconciliar_dia ·
comision.calcular · comision.corregir · pago_aliado.registrar · email.enviar ·
notificacion.despachar · proyecto.notificar_hito.
Contrato 5 — Dueño de las migraciones¶
Dueña única: Nat. Ya tiene T4 (migraciones), T7 (cola) y T8 (Stripe): todo el carril de datos. Quien escribe el esquema es quien lo conoce.
- Nadie más crea archivos en
backend/alembic/versions/. Ni Migue ni yo. - Si necesitas una columna o una tabla, abres un ticket con el campo, el tipo y para qué es. Nat la escribe. En la práctica es la misma tarde.
CODEOWNERS:/backend/alembic/ @nat.- Toda migración tiene
downgrade()que funciona, probado antes del merge. - Las migraciones se aplican como paso previo al despliegue, nunca al arrancar la aplicación: dos contenedores arrancando a la vez correrían la misma migración dos veces.
Por qué una sola persona: Alembic mantiene una cadena lineal de revisiones. Si dos personas crean una migración desde la misma cabeza, aparecen dos cabezas y el despliegue falla hasta que alguien las fusiona a mano. Con tres personas en paralelo eso pasa la primera semana.
Anexo — Convenciones¶
No estaba en el ticket de T0, pero si no se fija ahora se decide tres veces distinto.
Identificadores. UUID como llave primaria en todas las tablas, generado en la aplicación.
Usar UUIDv7 (ordenado por tiempo) en vez de v4: los índices se fragmentan mucho menos porque
las inserciones van al final del árbol. Nat: verifica si la versión de Postgres que quedó en
RDS trae uuidv7() nativo; si no, se genera desde Python con una librería.
Tiempo. Todo en UTC, en columnas TIMESTAMPTZ. La conversión a hora de Bogotá se hace solo
al presentar. Ninguna consulta filtra por hora local.
Dinero. NUMERIC(14,2), nunca float. La moneda va en su propia columna CHAR(3) aunque
hoy solo exista COP: agregarla después obliga a migrar datos financieros. Los porcentajes
también NUMERIC, guardados como 10.00 para 10%.
Nombres. Tablas en plural y en español (clientes, comisiones), como en el documento
técnico. Columnas en snake_case. Llaves foráneas <tabla_singular>_id. Toda tabla lleva
created_at y updated_at.
Rutas. /api/v1/<recurso> en plural. Los verbos que no son CRUD van como subrecurso:
POST /api/v1/comisiones/aprobar-lote.
Definición de terminado¶
-
Principaldefinido, con la regla de que los roles no autorizan - 67 capacidades, matriz de los 6 roles y desviaciones registradas
- Alcance por recurso hacia RLS, y contexto del worker resuelto
- Formato de éxito, de error, códigos de estado y las reglas del 404
-
enqueue()con deduplicación que no aborta la transacción - Nat como dueña única de las migraciones
- Publicado en el README del repo (bloqueado por T1)
Los tres carriles ya pueden mockear lo que no existe y seguir trabajando.