Decisiones técnicas (ADRs)
El registro de decisiones de arquitectura de Axel y su porqué, para que un dev nuevo entienda no solo qué se eligió sino la razón detrás.
El foundation está en marcha: F0 (bootstrap), F1 (auth + identidad) y F2
(tenancy + RLS) mergeadas (jul 2026). Las decisiones con fase asignada ya son
código; el registro fino por decisión vive en el wiki del repo platform
(wiki/architecture/decisions.md). Esta página guarda el porqué al nivel de
arquitectura, para que quien se sume no tenga que re-litigarlo.
Un Architecture Decision Record captura una decisión técnica y su razón, para que no haya que re-litigarla ni reconstruirla de memoria. Abajo, primero un resumen de las decisiones cerradas, luego el detalle de cada una, y al final las que siguen abiertas.
Tres estados en la columna: Cerrada (la decidimos y no se re-litiga), Confirmada (un supuesto externo que verificamos, no una decisión nuestra; p. ej. que la DGT acepta la firma) y Abierta (aún sin decidir; su dueño y disparador están más abajo).
Resumen
| # | Decisión | Elección | Estado |
|---|---|---|---|
| 0.1 | Stack | Laravel 13 + Inertia 3 + React 19 + Postgres 18 | Cerrada |
| 0.3 | Firma electrónica | UANATACA (PSCE acreditado), salida PAdES-T | Cerrada |
| 0.4 | Infraestructura | Laravel Cloud + VPS para el Optimizer | Cerrada |
| 0.5 | Auth del firmante | OTP del PSCE, sin cuenta | Cerrada |
| 0.6 | Tracker | GitHub Projects v2 | Cerrada |
| 0.7 | Arquitectura de código | Monorepo por dominio, motor en PHP puro | Cerrada |
| 0.8 | Tenancy | Shared schema + tenant_id + RLS | Cerrada |
| 0.9 | Rol del PoC | Especificación viva, no base a portar | Cerrada |
| 0.10 | Resolución de tenant en la URL | Dominio único, tenant por sesión | Cerrada |
| 0.11 | Login a la doc interna | Google (OAuth) sobre Cloudflare Access | Cerrada |
| 0.12 | Mecánica de tenancy (F2) | stancl lean + rol no-owner + contexto por ciclo de vida | Cerrada |
| DGT | Registro digital | La DGT/MTPS acepta firma certificada | Confirmada |
| 0.2 | Librería LLM | Interface firme, librería por decidir | Abierta |
| B19 | Retención de backups | Piso legal por validar | Abierta |
| PII | Cifrado en reposo | Anotado, no decidido | Abierta |
0.1 Stack: Laravel + Inertia + React + Postgres
CerradaLaravel es la mayor ventaja de velocidad del equipo. React viene del starter production-grade interno (AxelOps), así que no se parte de cero en frontend. Postgres 18 aporta RLS nativo, que refuerza el aislamiento multi-tenant. Inertia cubre el ciclo actual y una API futura pueden coexistir sin rehacer el frontend.
0.3 Firma electrónica: UANATACA
Cerrada (13-jul)
UANATACA es un Prestador de Servicios de Certificación Electrónica acreditado en
El Salvador. El Art. 24 de la Ley de Firma Electrónica exige un PSCE acreditado
para que la firma tenga pleno valor probatorio (mismos efectos que la
manuscrita). Se integra detrás de una interface SigningService agnóstica: al
Optimizer solo se le transmiten hashes, y la salida es PAdES nivel T.
0.4 Infraestructura: Laravel Cloud
CerradaSe eligió por el ahorro de horas del CTO (el recurso más escaso) y por una
superficie de ataque gestionada, apropiada para legal-tech con PII, no por
precio. El Optimizer de UANATACA corre en un VPS en la misma región que
us-east-1. El lock-in se asume conscientemente, con un trigger explícito de
reevaluación al llegar a 100+ tenants.
0.5 Auth del firmante: OTP del PSCE
CerradaEl empleado firma por un enlace firmado, sin crear cuenta. El OTP lo genera y valida UANATACA, no Axel: el segundo factor probatorio queda en manos del certificador acreditado, lo que refuerza el valor bajo el Art. 24.
0.6 Tracker: GitHub Projects v2
CerradaEl seguimiento vive en GitHub Projects v2: costo cero (bolsa de Actions propia de la org) e integrado directamente con el código y los PRs, sin herramienta externa que sincronizar.
0.7 Arquitectura de código
CerradaUn solo repo: producto y backoffice comparten el dominio. Organización por
vertical slicing (app/Domain/{Labor,Contracts,Signing}/), no por capa
técnica.
El backoffice usa el mismo stack de frontend que el producto (no Filament), para no sumar un tercer paradigma de UI que revisar y mantener.
La pieza donde un error significa daño legal (el motor de cálculo) vive en PHP
puro, sin dependencias de Laravel: es testeable con fixtures y sin base de
datos. Los componentes de UI se estandarizan como @axel/* sobre shadcn. El
principio guía: “diseñar para el futuro es barato, construir para el futuro es
sobre-ingeniería”.
0.8 Tenancy: shared schema + tenant_id + RLS
Cerrada (cambiada 4-jul, revirtió schema-per-tenant · implementada en F2, 17-jul)
El aislamiento por schema resultó percibido más que real, ningún régimen de compliance lo exige y el proveedor de Postgres lo desaconseja, además de tener un techo de escala bajo. La decisión es regret-minimizing: pasar de columna a schema es reversible y barato, al revés no. Ver Multi-tenancy y Seguridad.
0.9 Rol del PoC: spec, no base a portar
CerradaEl PoC es la especificación viva de UX y de dominio: se consulta, no se porta. Su código no entra a producción porque le falta authz, tenancy, validación server-side y tests, inaceptable con PII real. El motor de producción se construye de cero tomando el PoC como referencia funcional.
DGT/MTPS acepta firma certificada
Confirmada (jun 2026)
La Dirección General de Trabajo acepta firma PAdES/UANATACA en su registro electrónico. Esto despeja el ciclo completamente digital: no queda un paso de papel obligatorio entre la firma y el registro.
0.10 Resolución de tenant en la URL: dominio único
Cerrada (16-jul)
Axel usa dominio único: una sola URL de app para todos los tenants, y el
tenant se resuelve por sesión (current_tenant_id, ya en F1). Se descarta el
subdominio por tenant (acme.axel.legal). El slug de tenants pierde su único
propósito y sale del diseño; para referencias externas/API se usa un public_id
con prefijo (ten_..., patrón Stripe). Los subdominios se reservan para
región/país (data-residency), nunca para tenant.
Cinco razones, por peso: (1) Reversibilidad: pasar de único a subdominio
después es aditivo; al revés es destructivo (rompe URLs en la naturaleza:
bookmarks, callbacks SSO, links de documentos firmados, redirects para
siempre). Se elige la puerta que queda abierta. (2) Coherencia: se eligió
UUIDv7 para no revelar clientes; coca-cola.axel.legal respondiendo confirma que
es cliente, contradiciendo una decisión ya cerrada. (3) Cookies: el
subdominio obliga a cookie de sesión compartida en .axel.legal (un XSS en un
subdominio alcanza al padre); dominio único mantiene la cookie en un host. (4)
Enumerabilidad: en un mercado pequeño (SV) probar empresa-x.axel.legal confirma
al cliente. (5) Sunk cost ≈ 0: el slug no se usa en código; cambiarlo es
editar docs, no refactorizar.
Precedente legal-AI: los dos referentes de la categoría usan dominio único.
Harvey resuelve en app.harvey.ai por email/SSO (sus subdominios son por
región — eu.app, au.app — no por cliente); Legora en legorai.com/login
único, con 400+ bufetes. El pitch de “producto dedicado por URL” no es tabla en el
mercado. Confirmado con negocio: sin compromiso comercial de subdominios.
Consecuencias (ejecutadas en F2): slug y la tabla domains eliminados;
nada de wildcard DNS ni certificados *.axel.legal; public_id agregado
(ten_ + 24 base62, route key pública). RLS no cambió: el aislamiento
depende de tenant_id en las queries, no del subdominio. ADR completo en el wiki
del repo (architecture/tenant-url-resolution y architecture/public-identifiers).
0.11 Login a la doc interna: Google sobre Cloudflare Access
Cerrada (17-jul)
La documentación (handbook.axel.legal) está gateada por Cloudflare Zero Trust
Access. Se suma Google como identity provider para que el equipo entre con
un clic usando su cuenta @axel.legal, en vez del One-time PIN por correo (que
queda como fallback). El gate por dominio lo mantiene la política de Access
(email_domain: axel.legal), no el IdP.
Tres decisiones dentro de esta:
- Google, no “Google Workspace”. El IdP de Workspace intenta leer grupos vía la Admin SDK y falla sin ese permiso; no necesitamos políticas por grupo hoy. El IdP “Google” simple hace login por OAuth y el filtro por dominio ya lo da la política de Access.
- Google ahora, Microsoft después. El equipo vive en Google Workspace, así que Microsoft/Entra no aporta hoy. Si se concreta una migración a M365, sumar ese IdP es aditivo (~10 min, sin tocar lo existente): Cloudflare soporta múltiples IdPs a la vez.
- Costo cero. Zero Trust Free cubre hasta 50 usuarios (somos ~6); el OAuth client de Google no factura. Agregar IdPs no cambia de plan.
El OAuth client vive en el proyecto de Google Cloud Axel Access
(axel-access-502706), con la pantalla de consentimiento en modo Interno y el
redirect URI https://axel-legal.cloudflareaccess.com/cdn-cgi/access/callback. El
IdP quedó verificado end-to-end con el test de Cloudflare.
0.12 Mecánica de tenancy (F2): stancl lean, rol no-owner, contexto por ciclo de vida
Cerrada (17-jul, implementada en el PR #199)
Al implementar 0.8 se cerraron las decisiones de mecánica: stancl/tenancy en
configuración mínima (aporta el modelo de tenant y el global scope; sin
bootstrappers, sin rutas, sin resolución por dominio; se descartaron el modo
multi-DB y spatie/laravel-multitenancy), un rol runtime no-owner por una
conexión dedicada (pgsql_tenant) para que FORCE RLS sea real, y el contexto
de RLS movido por listeners del ciclo de vida de tenancy — no por el
middleware — para que scope y RLS no puedan divergir en requests, jobs o
Tenant::run().
Decisiones de seguridad dentro de esta: la conexión runtime nunca toma
credenciales de DB_URL (un DSN de owner apagaría RLS en silencio); bajo pooler
transaccional la variable de sesión es no-op y solo vale SET LOCAL (una GUC
de sesión quedaría pegada a un backend compartido, fail-open); y fuera de
local/testing el rol lo provisiona ops, no la migración, que solo verifica que exista
y que no sea SUPERUSER/BYPASSRLS. Detalle y alternativas en el wiki del repo:
architecture/tenancy-approach, architecture/tenant-isolation y el índice
architecture/decisions.
Decisiones abiertas
Cada una lleva quién la decide y qué la desbloquea, para que no quede en el aire sin dueño.
Abierta La interface agnóstica (IntentService) está firme; lo
abierto es qué librería queda detrás. Inclinación por la API nativa de
Anthropic frente a laravel/ai (que tiene un bug abierto en structured
output).
Decide: César (CTO). Disparador: el spike de la tarea #4 (capa LLM).
Abierta El research técnico apunta a un posible piso de 10 años, sin confirmar como plazo oficial.
Decide: el área legal (piso legal), César lo implementa. Disparador: la validación legal del plazo, antes de configurar la política de retención.
Abierta El cifrado en reposo de DUI, salario y dirección vía casts de Laravel está anotado, no decidido todavía.
Decide: César con input del área legal (qué campos son PII sensible bajo la ley). Disparador: antes de manejar datos reales de un cliente en producción.