⌘J
En esta página 14

Engineering / Decisiones técnicas (ADRs)

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ónElecciónEstado
0.1StackLaravel 13 + Inertia 3 + React 19 + Postgres 18Cerrada
0.3Firma electrónicaUANATACA (PSCE acreditado), salida PAdES-TCerrada
0.4InfraestructuraLaravel Cloud + VPS para el OptimizerCerrada
0.5Auth del firmanteOTP del PSCE, sin cuentaCerrada
0.6TrackerGitHub Projects v2Cerrada
0.7Arquitectura de códigoMonorepo por dominio, motor en PHP puroCerrada
0.8TenancyShared schema + tenant_id + RLSCerrada
0.9Rol del PoCEspecificación viva, no base a portarCerrada
0.10Resolución de tenant en la URLDominio único, tenant por sesiónCerrada
0.11Login a la doc internaGoogle (OAuth) sobre Cloudflare AccessCerrada
0.12Mecánica de tenancy (F2)stancl lean + rol no-owner + contexto por ciclo de vidaCerrada
DGTRegistro digitalLa DGT/MTPS acepta firma certificadaConfirmada
0.2Librería LLMInterface firme, librería por decidirAbierta
B19Retención de backupsPiso legal por validarAbierta
PIICifrado en reposoAnotado, no decididoAbierta

0.1 Stack: Laravel + Inertia + React + Postgres

Cerrada
Laravel 13 · Inertia 3 · React 19 · Postgres 18

Laravel 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)

PSCE acreditado, salida PAdES nivel T

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

Cerrada
Plataforma gestionada, con trigger de reevaluación

Se 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

Cerrada
Firma por enlace firmado, sin cuenta

El 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

Cerrada
Integrado con el código, costo cero

El 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

Cerrada
Monorepo por dominio

Un solo repo: producto y backoffice comparten el dominio. Organización por vertical slicing (app/Domain/{Labor,Contracts,Signing}/), no por capa técnica.

Backoffice en Inertia + React

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.

Motor del Código de Trabajo en PHP puro

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)

Aislamiento en la columna, con RLS de backstop

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

Cerrada
Especificación viva, read-only

El 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)

El ciclo 100% digital está despejado

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)

Un solo host (app.axel.legal), tenant por sesión

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óneu.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)

Google (OAuth) como login method en Cloudflare Access

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)

El paquete es ergonomía; el músculo es RLS + código propio

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.

0.2 Librería del LLM

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).

Retención legal de backups (B19)

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.

Cifrado en reposo de PII

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.