Saltar al contenido principal

Salud · Localización por país + editor de plantillas

Plan de implementación. Escrito 2026-08-20. Continúa PROGRAMAMED-ANALISIS.md (que definió F1–F6) y no lo sustituye: aquí se abren dos ejes nuevos que lo cruzan.

Los dos pedidos

  1. Localización agregable. Hoy el módulo de salud es genérico y las exigencias de Ecuador (MSP, ACESS, LOPDP) están fuera. Hay que meterlas sin clavarlas: mañana entra Perú o Colombia y debe ser añadir un paquete, no editar el motor.
  2. Editor de plantillas de verdad. Hoy una plantilla es texto plano con tokens. El usuario quiere colores, logo, tipografía, tablas — y lo mismo para las recetas, cuyo diseño está hoy escrito a mano en un componente.

Son independientes: se pueden hacer en cualquier orden. Se planifican juntos porque comparten una pieza — el documento imprimible — y porque el paquete de Ecuador necesita plantillas rígidas, que solo tienen sentido cuando existe un editor flexible del que protegerse.

Alcance decidido (2026-08-20): hasta L6 completo. No es "un buen sistema clínico" sino "un sistema que pasa una inspección de la ACESS". Eso arrastra tres consecuencias que el resto del documento desarrolla: aparece una fase P0 de plataforma (autenticación, 2FA, retención) que antes era opcional; L5 y L6 dejan de ser un epígrafe y se detallan en §13.2 y §13.3. La firma electrónica queda fuera de alcance (§19, decidido 2026-08-21): el sistema genera el documento y el médico lo firma aparte con su certificado acreditado.

Parte IV (§18–§20) es de lectura obligatoria antes de empezar L1. Buena parte de lo que aquí se llama "módulo de salud" no es de salud: el motor de plantillas, las series numeradas y la firma son piezas de plataforma. Dónde se construyen se decide antes de construirlas, no después.


Parte I — Localización como agregable

1. Vocabulario: idioma ≠ jurisdicción​

En el proyecto ya se llama "localización" a dos cosas distintas y conviene separarlas antes de escribir una línea:

Qué varíaYa resuelto porEjemplo
Idioma (i18n)cómo se dicei18next, src/locales/, fallbackLng: 'es'"Prescription" / "Receta"
Jurisdicción (l10n)qué es legal y obligatorionada todavíala receta de psicotrópicos necesita folio de la ACESS

Este plan es de jurisdicción. Un tenant español y uno ecuatoriano ven la interfaz en el mismo español y aun así rellenan formularios distintos. El eje es el país de la organización (tenants.country, que ya existe), no el idioma del navegador.

2. Estado actual​

Lo que ya es localizable y sirve de precedente

  • frontend/src/lib/identity-documents.ts — catálogo de documentos de identidad por país, con _FALLBACK genérico y una lista corta a propósito ("crece cuando hay un cliente real en ese país"). Este es el patrón: registro en código, clave por país, degradación honesta. Todo lo demás se construye a su imagen.
  • tenants.country ya se edita en la configuración del workspace (actions/workspace.ts) y valida contra SUPPORTED_COUNTRIES.
  • La plataforma de módulos (manifiestos + tenant_modules + siembra al activar) es exactamente el mecanismo Odoo l10n_ec. Está construida y funciona.

Lo que está clavado y bloquea Ecuador (referencias al SRS adjunto)

SRSExigenciaHoy
§2.1prohibido UPDATE destructivo sobre lo firmado; corrección por adendahealth_save_encounter hace UPDATE public.clinical_encounters en sitio cuando recibe p_encounter_id
§2.3versión encadenada con previous_version_idsolo lo tienen las plantillas, no los registros clínicos
§3firma electrónica .p12, PAdES, 2FAno hay generación de PDF en servidor (se imprime desde el navegador) ni 2FA
§4CIE-10 obligatorio, DCI obligatoriadiagnóstico es texto libre; health_medications es catálogo auto-alimentado por texto
§5formularios MSP 001/002/005/024no existe motor de formularios estructurados
§6.2folios ACESS para psicotrópicosno hay series numeradas
§7días de reposo en letras y cifras, QR antifraudefield.reposo_* son texto libre; el QR es F3 pendiente
§8disparador EPI-1 por CIE-10no existe
§9.3recepción / médico / enfermería con accesos distintossolo hay health.records.read y health.records.manage

Dos observaciones honestas antes de seguir:

  • §9.3 no es un problema de Ecuador. Que recepción no lea el Form. 002 es buen diseño en cualquier país. El permiso granular va al módulo de salud, no al paquete EC.
  • §3 y §9.1 (2FA, cifrado en reposo) no son del módulo de salud. Son de plataforma y chocan con un hueco conocido: el backend NestJS no autentica salvo en scheduling. Como el alcance llega hasta L6, entran igualmente: son la fase P0 (§13.1). No se pueden dejar fuera y llamar a esto "cumple".

3. Arquitectura: el paquete de localización​

3.1 Tres capas, una regla​

CONTRATO shared/src/modules/health-l10n.ts solo tipos: qué ranuras existen
────────────────────────────────────────────────────────────────────
MOTOR health_* (SQL) + sections/health/… genérico, no sabe de países
────────────────────────────────────────────────────────────────────
PAQUETE lib/health/l10n/ec.ts + semilla SQL datos + validadores de un país

Regla invariante (la misma que ya tiene la plataforma de módulos): el motor nunca menciona un país. Ni una tabla ec_*, ni un if (country === 'EC') fuera de un paquete. Si al implementar hace falta un if de país en el motor, es que falta una ranura.

3.2 Las ranuras​

Un HealthLocalePack es un objeto plano. Cada ranura tiene un valor genérico que es lo que el sistema hace hoy, así que el paquete genérico no cambia nada de lo existente.

RanuraQué decideGenérico (hoy)EC
identitytipos de documento + validadordocumento / pasaporte, sin validarcédula (módulo 10), RUC (13 díg.), pasaporte
professionalcampos obligatorios del prescriptorregistro libreregistro ACESS y SENESCYT
diagnosisCodingde dónde sale un diagnósticotexto libreICD10, obligatorio, sin texto libre
drugNamingcómo se nombra un fármacolibreDCI obligatoria, aviso si está fuera del CNMB
immutabilityqué pasa al corregirupdateamend_only una vez firmado
documentTemplatesplantillas de sistemalas 6 actualesvariantes EC + certificado IESS rígido
clinicalFormsformularios estructuradosningunoMSP 001, 002, 005, 024
numberingseries controladasningunablock ACESS por médico para psicotrópicos
verificationQR público de validezopcionalobligatorio en certificados
signaturecómo se firmaimagen escaneadafirma fuera del sistema con certificado acreditado; el documento reserva su sitio y admite el PDF firmado de vuelta (§19)
epidemiologyavisos disparados por diagnósticoningunoEPI-1 sobre lista de CIE-10 notificables
retentionretención y anonimizaciónpor defectoLOPDP: reportes sin nombre ni cédula
formattingfecha, número en letrasISOdd/mm/aaaa, tres (3) días

formatting.numberToWords es del idioma, no del país — pero la obligación de escribirlo es del país. El paquete declara la obligación y delega en un helper por idioma; así Perú reutiliza el mismo helper sin copiarlo.

3.3 Resolución: de qué país es este tenant​

tenants.country ──┐
├─→ resolveHealthLocale(country, override) ──→ HealthLocalePack
override del ─┘ si no hay paquete: GENERIC (nunca null)
tenant (opcional)
  • Fuente primaria: tenants.country.
  • Override por tenant en la configuración de salud: una clínica privada en Ecuador que no factura al IESS puede querer el paquete genérico. Se guarda en tenant_modules.config del módulo health (ya es jsonb y ya hay caché de 60 s en modules.service.ts), no en una tabla nueva.
  • Nunca devuelve nulo. Un país sin paquete cae a GENERIC, igual que identityDocumentsFor().

resolveHealthLocale vive dos veces, una por lado (frontend y backend), sobre tipos compartidos — no hay runtime compartido: @xenpia/shared es solo tipos (la nota al pie de shared/src/modules/types.ts es explícita). Es el mismo compromiso que ya hace DOCUMENT_TOKENS: el tipo en shared, los valores en frontend/src/sections/health/documents/tokens.ts.

3.4 El manifiesto health_l10n_ec​

El paquete se activa como módulo, con el mecanismo que ya existe:

export const healthL10nEcManifest: ModuleManifest = {
code: 'health_l10n_ec',
name: 'Salud · Ecuador',
description: 'Formularios MSP, CIE-10, folios ACESS y reglas de la HCU.',
icon: 'solar:document-medicine-bold',
category: 'vertical',
version: '0.1.0',
dependsOn: ['health'],
permissions: ['health.forms.manage', 'health.series.manage'],
menus: [
{ code: 'health.forms', path: '/dashboard/health/forms', section: 'health', sort: 40, sectionSort: 50, permission: 'health.clinical.read' },
{ code: 'health.series', path: '/dashboard/health/series', section: 'health', sort: 50, sectionSort: 50, permission: 'health.series.manage' },
],
extensionPoints: ['contact.detail.tabs'],
sort: 31,
};

Qué gana esto frente a un simple if (country === 'EC'):

  • La activación siembra permisos, menús y datos del país en una operación idempotente, que es lo que ya hace modules.service.ts.
  • La pantalla de Aplicaciones lo muestra y lo explica: el cliente ve que tiene el paquete Ecuador activo, y puede desactivarlo.
  • dependsOn: ['health'] bloquea la incoherencia de activar el paquete sin la vertical, gratis.

Trampa conocida: los menús solo se siembran al activar. En el tenant demo, que ya tiene health activo, cualquier menú nuevo del módulo de salud no aparece hasta re-activar el módulo. Cada fase que añada un menú debe decir "re-activar el módulo" en su comprobación.

3.5 Reglas de base de datos​

  1. Ninguna tabla con prefijo de país. El discriminador es una columna locale_code text (NULL = genérico), nunca el nombre de la tabla.
  2. Las tablas de catálogo no son PHI (CIE-10, CNMB, definiciones de formulario): pueden llevar política de SELECT para authenticated. Las de instancias (formularios rellenados, folios consumidos) sí son PHI y siguen el patrón obligatorio del módulo: RLS + REVOKE ALL, sin política de SELECT, acceso solo por funciones health_* SECURITY DEFINER que comprueban user_has_permission y auditan.
  3. Nunca llamar a health_audit en un camino que pueda correr con service-role. audit_logs.user_id es NOT NULL y revienta la función entera (ya pasó con health_resolve_delivery).
  4. Las migraciones se aplican a mano en el editor SQL. Cada archivo termina con su bloque de comprobación comentado, como los actuales.

Tablas nuevas previstas (todas neutrales):

health_code_systems (code, name, locale_code, version) -- ICD10, CNMB
health_code_entries (system_code, code, display, parent, flags) -- flags: {notifiable:true}
health_form_definitions (locale_code, code, version, title, schema jsonb, is_active)
health_form_instances (tenant, contact, encounter, definition_version, data jsonb,
status draft|signed, previous_version_id, signed_by, signed_at)
health_amendments (target_kind, target_id, tenant, body, reason, created_by)
health_number_series (tenant, locale_code, kind, owner_profile_id, prefix,
range_from, range_to, next_value, authority_ref, is_active)
health_number_assignments (series_id, value, target_kind, target_id, assigned_at)

Y columnas añadidas a lo existente:

health_document_templates + locale_code text -- NULL = genérica
health_document_template_versions + doc jsonb, schema_version int -- Parte II
health_documents + layout_snapshot jsonb -- Parte II

4. Añadir un país nuevo — la prueba de que es agregable​

Meter Perú, cuando llegue el cliente, debe ser exactamente esto:

  1. shared/src/modules/health-l10n.ts — nada. El contrato ya está.
  2. frontend/src/lib/health/l10n/pe.ts (+ gemelo en backend) — el objeto HealthLocalePack de Perú. Si necesita una ranura que no existe, ahí se descubre; se añade al contrato con valor genérico por defecto, para no tocar EC.
  3. supabase/migrations/2026XXXX_l10n_pe_seed.sql — plantillas de sistema con locale_code = 'PE', definiciones de formulario, catálogos.
  4. backend/src/modules/manifests.ts — un manifiesto más en ALL_MODULE_MANIFESTS + su fila en public.app_modules.
  5. src/locales/** — las cadenas nuevas en es y en.

Cero cambios en el motor. Si algún paso obliga a tocar un health_* genérico, el diseño falló y hay que abrir la ranura que faltaba en vez de parchear.

5. El paquete Ecuador: SRS → ranura → fase​

SRSRanuraFase
§2.1 no borrado / adendasimmutabilityL4
§2.2 audit trail (VIEW, PRINT, EXPORT, IP, user-agent)motor (no del país)L4
§2.3 versionado encadenadoimmutabilityL4
§3 firma .p12 / PAdESsignaturefuera de alcance (§19): la aplica el médico con su herramienta
§3 2FAplataforma (no del país)P0
§4 CIE-10diagnosisCodingL3
§4 DCI / CNMBdrugNamingL3
§5.1 MSP 001 admisiónclinicalForms + identityL5
§5.2 MSP 002 consulta externaclinicalFormsL5
§5.3 MSP 005 evolución SOAPclinicalFormsL5
§5.4 MSP 024 consentimientoclinicalForms + signatureL5
§6.1 receta común (membrete, QR)documentTemplatesL1 + L2
§6.2 folio ACESS psicotrópicosnumberingL6
§7 certificados (letras+cifras, QR)formatting, verificationL2 + L6
§8 EPI-1epidemiologyL6
§9.2 anonimización de reportesretentionP0 (no hay reportería aún: nace anonimizada, que es lo barato)
§9.3 RBAC recepción/médico/enfermeríamotor (no del país)L4

Parte II — Editor de plantillas

6. Por qué hoy es texto plano, y qué sí se puede cambiar​

La decisión actual está documentada en la cabecera de 20260826_health_documents.sql y era correcta para lo que había: el cuerpo es texto con tokens, no HTML, porque el proyecto no tiene sanitizador y volcar HTML de usuario en una vista que muestra PHI es un XSS almacenado.

El error sería concluir "entonces no se puede enriquecer". Lo que no se puede es guardar una cadena de marcado que alguien interprete. Sí se puede guardar un árbol de datos cuyos nodos el renderizador conoce uno a uno.

La garantía de seguridad se aplica al renderizar, no al guardar. El renderizador tiene una tabla tipo de nodo → componente. Un nodo que no está en la tabla se descarta. Un color que no case ^#[0-9a-f]{6}$ se ignora. Una tipografía fuera de la lista blanca cae a la del tema. Así, aunque alguien llame a la RPC de guardado a mano y meta basura en el jsonb, no hay nada que interpretar. En ningún punto del sistema aparece dangerouslySetInnerHTML ni generateHTML. Esa línea es la invariante de toda la Parte II.

7. Modelo de documento por bloques (v2)​

health_document_template_versions gana doc jsonb y schema_version int. body se queda: las versiones v1 existentes se siguen renderizando con DocumentBody tal cual, y un documento emitido en 2026 se reimprime idéntico en 2030. Nada se migra a la fuerza.

Nodos de bloque

NodoPara qué
paragraph, headingtexto, con align y lineHeight
bulletList / orderedListlistas
tablela tabla que pide el usuario: filas, columnas, cabecera, anchos, bordes, relleno
imagelogo o imagen, por id de activo, nunca por URL libre
divider, spacer, pageBreakmaquetación
signatureBlockfirma: línea, nombre, credenciales, imagen de firma
qrQR de verificación (contenido calculado, no tecleado)
prescriptionItemstabla de medicamentos, alimentada por los datos estructurados de la receta
columnsdos o tres columnas (membretes tipo "paciente / fecha")

Marcas de texto: bold, italic, underline, color, fontSize, fontFamily, highlight. Y un nodo inline atómico token, que es la mejora más útil del día a día: hoy {{patient.nombre}} es texto y se rompe borrando una llave; como átomo se inserta y se borra entero, y se pinta como chip.

Límites duros (se comprueban al renderizar, no solo al guardar): profundidad máxima de anidamiento, número máximo de nodos, número máximo de celdas de tabla. Una plantilla no es un lienzo infinito y un jsonb de 40 MB no debe poder tumbar la vista de impresión de una receta.

8. Tema, membrete y activos​

Colores, tipografía y logo no se meten nodo a nodo. Van en una capa declarativa que se hereda, porque el pedido real no es "quiero pintar este párrafo de azul" sino "quiero que mis documentos se vean míos".

health_brand_profiles
theme: { fontFamily, baseSize, lineHeight, colors: { text, muted, accent, rule } }
page: { size: A4|Letter, orientation, margins }
header: [ bloques ] ← logo, nombre, dirección, RUC, teléfonos
footer: [ bloques ] ← pie legal, web, paginación
watermark: { text | assetId, opacity }
  • Un perfil por tenant, con override opcional por profesional (owner_profile_id): el médico que tiene su propio membrete y su firma escaneada. Es la misma jerarquía professional → tenant → sistema que ya usan las plantillas; no se inventa un concepto nuevo.
  • page_config de la versión de plantilla ya existe y hoy está a '{}': ahí van los ajustes propios de esa plantilla, que pisan al perfil del tenant.
  • Tipografías: lista blanca. Las cinco ya instaladas por @fontsource (DM Sans, Inter, Nunito Sans, Public Sans, Barlow) más las que se añadan a propósito. Nada de font-family libre: una fuente que no está en el bundle no se imprime igual en el equipo del médico que en el del auditor.
  • Activos (health_template_assets): logo, firma escaneada, sello. Bucket privado de Supabase, filas inmutables (subir de nuevo = fila nueva, nunca sobrescribir), tipo MIME y tamaño validados, kind explícito. La firma escaneada de un médico es un dato sensible: no va a un bucket público.

9. El editor​

Recomendación: TipTap (ProseMirror) con esquema restringido. Ya estaba anticipado en la nota de la migración F2 ("si algún día se quiere WYSIWYG con chips atómicos, añadir TipTap"). Encaja porque:

  • Su getJSON() es el árbol de bloques descrito arriba; no hay conversión ni parser de HTML de por medio.
  • El esquema es una lista blanca por construcción: lo que no se registra como extensión, no existe en el documento.
  • Los tokens como nodos atómicos son un caso de manual (atom: true).

Extensiones: StarterKit (recortado), Table, TextStyle + Color, FontFamily, TextAlign, Highlight, más tres propias — Token, AssetImage, PageBreak — y dos de bloque de dominio — SignatureBlock, PrescriptionItems.

Validación al guardar con zod, que ya es dependencia del frontend, dentro del server action saveTemplateVersion ('use server', corre en servidor). Es defensa en profundidad, no la garantía: la garantía sigue siendo el renderizador.

Alternativa si TipTap se considera demasiado peso: un editor de bloques propio sobre MUI (cada bloque un formulario, sin edición enriquecida en línea). Más barato de auditar y notablemente peor para redactar prosa médica. Mi recomendación es TipTap; la alternativa queda escrita por si la respuesta es no.

Lo que se conserva de la pantalla actual: el panel lateral de tokens agrupado por origen, la vista previa en vivo con SAMPLE_CONTEXT, el clonado de plantilla de sistema y el guardado como versión nueva. Todo eso ya funciona y no se toca; cambia la caja donde se escribe.

10. Plantillas rígidas — donde se cruzan las dos partes​

El SRS §7 es tajante: "el sistema debe proveer una plantilla rígida", porque un certificado alterado produce glosas del IESS. Un editor libre y una plantilla rígida no se contradicen si la rigidez la declara el paquete del país:

{
code: 'certificado_medico_iess',
localeCode: 'EC',
layout: 'flexible', // membrete, colores y tipografía SÍ se tocan
requiredNodes: [
{ token: 'field.cie10' }, // sin CIE-10 no es homologable en el IESS
{ type: 'signatureBlock' },
{ type: 'qr' },
],
lockedNodes: ['legal_notice'],
}

Al guardar una versión se comprueba que los nodos obligatorios siguen ahí; si no, error con código (missing_required_node) que la interfaz traduce, como todo el resto del proyecto. El usuario personaliza cuanto quiera alrededor de lo que la ley fija.

11. Recetas por el mismo motor​

Hoy prescription-print-view.tsx son 267 líneas de maquetación escrita a mano: el médico no puede cambiarle ni el logo. Se convierte en una plantilla más, de tipo receta, cuyo cuerpo contiene un nodo prescriptionItems que recibe los ítems estructurados que ya guarda health_prescription_items (dosis, cantidad, frecuencia, duración, instrucciones).

Con eso, "colores, logo y tipografía" cae también sobre las recetas sin escribir un segundo sistema — que es literalmente lo que pide el encargo. Y el paquete EC puede sembrar la variante receta con DCI obligatoria y el bloque de folio.

12. Congelado y compatibilidad​

Un documento emitido hoy debe reimprimirse idéntico dentro de diez años. Hoy se congela body_snapshot + context_snapshot; con tema, logo y tipografía por medio eso ya no basta: cambiar el logo del tenant cambiaría certificados antiguos.

Por eso health_documents gana layout_snapshot jsonb con el tema, el page_config y los ids de activo resueltos en el momento de emitir. Como los activos son filas inmutables, no hace falta empotrar la imagen en base64 en cada documento: basta con no borrar nunca un activo referenciado (borrado lógico).

En la ruta pública /d/[token], que corre sin sesión con createAdminSupabase, el activo se resuelve en el servidor a URL firmada de vida corta antes de renderizar. Nunca se expone el bucket.

Compatibilidad: el renderizador despacha por schema_version (1 = texto con DocumentBody, 2 = bloques). Nada se migra de golpe; una plantilla v1 se convierte a v2 la primera vez que alguien la abre en el editor nuevo y guarda — es decir, cuando ya iba a crear una versión nueva de todos modos.


Parte III — Fases, riesgos y decisiones

13. Fases​

Nomenclatura L para no chocar con las F1–F6 de PROGRAMAMED-ANALISIS.md. F3 (folio + QR) y F5 (formularios MSP) quedan absorbidas por L6 y L5 respectivamente, ahora con un motor genérico debajo.

FaseAlcanceCierraDepende deTamaño
P0Autenticación real en todo el backend, 2FA para roles con PHI, retención y consentimiento LOPDP, copias verificadas§3 (2FA), §9.1, §10 del marco legal—M
L0Contrato HealthLocalePack, registro + GENERIC + esqueleto EC, resolveHealthLocale, override en tenant_modules.config, manifiesto health_l10n_ecLa arquitectura. Sin cambio visible—S
L1Editor v2: doc jsonb, renderizador de bloques, TipTap, tema + membrete + activos, receta por plantilla. Se construye como módulo documents, no dentro de salud (§18)El segundo pedido, entero—L
L2locale_code en plantillas, orden de resolución, nodos obligatorios y bloqueados, semilla de plantillas ECPlantillas rígidas del §6/§7L0, L1S — ✅ hecho; migraciones 20260841 y 20260842 SIN APLICAR
L3health_code_systems/entries, selector de diagnóstico, DCI/CNMB en receta, credenciales del profesional§4, §6.1L0M — ✅ motor hecho (20260843 y 20260844 sin aplicar). Los catálogos siguen SIN CARGAR: falta la fuente oficial (§16)
L4aInmutabilidad + adendas: draft/signed, firmar bloquea, fe de erratas, políticas por jurisdicción§2.1L0✅ hecho; 20260846 sin aplicar
L4bAuditoría con IP, agente y acción de impresión§2.2L4a✅ hecho; 20260847 sin aplicar
L4cPermisos granulares (recepción / médico / enfermería) con compatibilidad§9.3L4apendiente
L5Motor de formularios estructurados + definiciones MSP 001/002/005/024, consentimiento con captura de trazo u OTP§5L0, L3, L4XL
L6Series y folios ACESS (→ sequences), QR de verificación pública, PDF en servidor, retorno del PDF firmado por el médico (§19 variante B), disparador EPI-1§6.2, §7, §8todasM — la firma propia salió de alcance (§19)

Estado — ✅ L0 hecho (2026-08-21). Contrato HealthLocalePack en shared/src/modules/health-l10n.ts (13 ranuras), paquetes GENERIC y EC en frontend/src/lib/health/l10n/, resolveHealthLocale, lectura del override en actions/health-locale.ts, manifiesto healthL10nEcManifest y migración 20260835_health_l10n_ec_module.sql (✅ aplicada, verificada contra la base). identity-documents.ts gana validadores de cédula y RUC ecuatorianos y pasa a ser la fuente de la ranura identity. Sin cambio visible, como estaba previsto.

Certificado contra la base el 2026-08-21: migración aplicada, fila del catálogo coincidente con el manifiesto, RLS comprobada (la clave anónima ve 0 filas), cierre de dependencias health_l10n_ec → health → scheduling, tsc y eslint limpios, y 45 comprobaciones de lógica en verde. Y una sorpresa útil: F1–F3 también están aplicadas y en uso (119 fichas, 104 encuentros, 42 entregas), lo que cierra el riesgo nº1 de §14 y estrecha la ventana de §18 — ver la nota allí.

Estado — ✅ L1 completo y aplicado. Se entregó por trozos, para poder revisar cada uno:

AlcanceEstado
L1.1doc jsonb + schema_version, congelado del aspecto, doc_assets, doc_brand_profiles, bucket privado✅ hecho y aplicado
L1.2Renderizador de bloques: lista blanca, saneo, límites, aplanado a texto✅ hecho y verificado
L1.3Editor TipTap con esquema restringido y tokens atómicos✅ hecho
L1.4aEditor montado en la pantalla de plantillas, RPC v2, migración v1→v2 al guardar✅ hecho y aplicado (20260837)
L1.4bMarca: tema, página, logo y activos, con pestaña propia en la configuración de salud✅ hecho
L1.5aEl árbol de punta a punta: emisión, congelado del aspecto e impresión del médico✅ hecho y aplicado (20260838)
L1.5bEl enlace del paciente con activos, y la receta como una plantilla más✅ hecho y aplicado (20260839, 20260840)

Validación de L0–L3 (2026-08-23). 130 comprobaciones contra la base real y sobre la lógica pura, todas en verde, más tsc, eslint y next build. Salió un fallo de verdad, del tipo que solo aparece mirando el conjunto: las plantillas ecuatorianas declaraban bloques signatureBlock y qr que ninguna vista de impresión rellenaba. El nodo estaba en el árbol, el renderizador reservaba el hueco y nadie lo llenaba, así que el certificado salía sin firma. Se corrigió rellenando la ranura de firma (signature-block.tsx) y se retiró la exigencia de qr hasta L6, que es quien la hace real — el mismo criterio que ya se había aplicado al CIE-10. Migración correctora: 20260845. Detalle en §21.

Corrección al montar el editor (2026-08-21). Al instalar TipTap se comprobó que los nombres reales no coincidían con los que había supuesto §7, y el esquema se plegó a los suyos: horizontalRule en vez de divider, textAlign en vez de align, y —la importante— color, tamaño y tipografía no son marcas propias: TipTap las guarda como atributos de una sola marca textStyle. Mantener nombres propios habría obligado a convertir el árbol en cada guardado y en cada carga, que es justo la fragilidad que se evitaba eligiendo TipTap. Se corrigió antes de que existiera ningún documento v2, así que no hubo datos que migrar.

Decisión tomada al empezar L1: la extracción del módulo documents se parte. Las piezas nuevas nacen con nombre neutral (doc_*, components/document/), y el renombrado de las dos tablas heredadas queda como paso propio y aislado — reescribir RPC que hoy sirven datos reales no debe mezclarse con una funcionalidad nueva.

Tamaños relativos, no fechas: S ≈ días, M ≈ una a dos semanas, L ≈ tres a cuatro, XL ≈ más de un mes — para una persona que ya conoce el repositorio, con las migraciones aplicándose a mano. L5 y L6 son XL cada una y juntas pesan más que todo lo demás sumado; ahí está el grueso del compromiso de llegar hasta L6.

Orden recomendado: P0 → L0 → L1 → L2 → L3 → L4 → L5 → L6. L1 va pronto porque es el pedido explícito y no depende de nada. L4 es el más delicado (cambia el comportamiento de guardar una evolución, que ya está en uso en la demo) y por eso va después de que el editor esté estable.

Detalle de L4 — permisos granulares. Los actuales health.records.read y health.records.manage no distinguen recepción de médico. Se añaden al manifiesto de salud: health.admission.manage (Form. 001), health.clinical.read, health.clinical.write, health.vitals.write, health.prescriptions.manage, health.documents.emit, health.templates.manage. Para no romper los tenants vivos, la migración concede los nuevos a quien ya tenga los viejos, y durante una versión las funciones health_* aceptan cualquiera de los dos (OR). Los antiguos se retiran cuando ya no queden llamantes.

13.1 P0 — prerrequisitos de plataforma​

Antes eran "riesgos"; con L6 como meta son alcance. Ninguno es del módulo de salud, y por eso es fácil que se caigan de la lista hasta que un auditor pregunta.

PuntoQué hay que hacerNota
Autenticación del backendExtender PermissionGuard a bots, contacts, channels, data-sources y a todo endpoint nuevo de salud (scheduling, kiosk, invoicing y modules ya lo llevan)/api/webhooks/* y /api/bots/message son públicos a propósito (widget de webchat) y deben seguir siéndolo. CORS no protege nada: cualquier curl lo ignora
2FA (§3)Supabase Auth trae MFA TOTP con niveles aal1/aal2 en el JWT. Exigir aal2 para roles con acceso a PHI, en el guard del backend y en el middleware del frontendEs la vía barata: no hay que construir 2FA, solo obligarlo. Cuidado con el alta: un médico que pierde el TOTP no puede quedar fuera de la historia clínica de sus pacientes → política de recuperación por administrador, auditada
Cifrado en reposo (§9.1)El SRS pide TDE. Postgres no tiene TDE nativo y el cifrado por columna de Supabase (pgsodium/TCE) está en desusoLa respuesta defendible hoy es: cifrado de disco gestionado + control de acceso por RLS + auditoría, documentado. Si el auditor exige cifrado por columna, se acota a diagnóstico y se paga en consultabilidad. Verificar con Supabase antes de prometer nada
Consentimiento LOPDPRegistro de consentimiento explícito del paciente para el tratamiento de datos de salud: quién, cuándo, qué versión del texto, por qué medioTabla propia; se reutiliza la misma mecánica de no-repudio del MSP 024 (§13.2)
Retención y anonimización (§9.2)Política de retención declarada + reportes que omitan nombre y documentoNo hay reportería todavía, así que es barato hacerlo bien: nace anonimizada
Gestión de secretos (KMS)Ya no es bloqueante: era el prerrequisito para custodiar el .p12, y la firma salió de alcance (§19). Sigue siendo buena práctica para otros secretosVuelve a ser obligatorio solo si se retoma §19.z
Copias y continuidadCopias verificadas y un procedimiento de restauración probadoUna inspección lo pregunta, y "está en Supabase" no es una respuesta

13.2 L5 en detalle — motor de formularios + serie MSP​

Es la fase más grande. La tentación es escribir cuatro pantallas React (una por formulario) y acabar; sería un error: el MSP cambia sus formatos por resolución ministerial y quedaríamos atados a un despliegue de código por cada cambio.

a) La definición es un dato. health_form_definitions.schema es un jsonb con secciones → campos → tipo, obligatoriedad, validación y dependencias (mostrar si otro campo vale X). Versionada: publicar una corrección del MSP es insertar la versión 2, no editar la 1 — misma disciplina que las plantillas.

b) Un renderizador genérico que recorre el esquema y pinta MUI, con su validador derivado del mismo esquema (zod). Un formulario nuevo = una fila nueva.

c) Instancias con estado. draft → signed. Al firmar, se congela; corregir crea una instancia nueva con previous_version_id (el mecanismo ya lo trae L4) y la interfaz muestra la original marcada como corregida con la adenda debajo, que es literalmente lo que pide §2.1 del SRS.

d) MSP 001 — Admisión. Aquí hay una decisión que evita un desastre de datos: el 001 no duplica la ficha del contacto. Nombres, documento, celular, correo y dirección ya viven en contacts; el formulario los referencia y solo aporta lo que hoy no existe (autoidentificación étnica, nivel de instrucción, ocupación, nacionalidad, seguro RPIS/RPC, familiar de contacto). Si se copian, a la semana hay dos verdades y ninguna es la buena. Es además el formulario de recepción, el que justifica el permiso health.admission.manage de L4.

e) MSP 002 — Consulta externa. Dos piezas que no pueden ser jsonb suelto porque se consultan:

  • Signos vitales → tabla propia, con IMC calculado en la base (columna generada), no en el cliente. Los usa enfermería (§9.3) y alimentan gráficas.
  • Diagnósticos → health_encounter_diagnoses (encuentro, código CIE-10, tipo presuntivo/definitivo, orden). Estructurados porque de ahí salen el disparador EPI-1 (§8), el certificado homologable al IESS (§7) y cualquier estadística.

El resto (motivo, enfermedad actual, antecedentes, examen físico por segmentos) sí cabe como instancia de formulario.

f) MSP 005 — Evolución SOAP. Encaja sobre clinical_encounters, que ya existe: se le añade la estructura S/O/A/P y firma independiente por entrada (la de las 08:00 y la de las 14:00 son documentos distintos). Es el punto donde más se nota L4: hoy una evolución se re-escribe encima.

g) MSP 024 — Consentimiento informado. Es el único con requisitos de no-repudio:

  • Registrar que el paciente leyó: versión del texto, hash del documento mostrado, marca de tiempo del servidor.
  • Aceptación por trazo (canvas → PNG a bucket privado, nunca público) u OTP al celular — y aquí hay una reutilización valiosa: el envío por WhatsApp ya está montado para los recordatorios de cita (outbox + canales). El OTP sale casi gratis.
  • Guardar método, IP y user-agent junto a la aceptación.

h) Impresión. Los formularios se imprimen con el motor de plantillas de L1; no se escribe una segunda vista de impresión.

13.3 L6 en detalle — folios, QR, firma y EPI-1​

a) Series y folios ACESS (§6.2). La ACESS vende blocks físicos numerados a un médico concreto; el software registra el rango y va descontando. El mecanismo no es de salud — es el ir.sequence de Odoo — y vive como servicio de plataforma (§18); lo de Ecuador es solo la configuración del block.

  • health_number_series por profesional (no por tenant), con rango, valor siguiente y referencia de la autorización.
  • El consumo es transaccional (UPDATE ... RETURNING sobre la fila de la serie, que serializa) + UNIQUE (series_id, value) en health_number_assignments. Dos recetas simultáneas no pueden llevar el mismo folio ni saltárselo.
  • Anular una receta no libera el folio: queda consumido y marcado como anulado. Un folio reutilizado es exactamente lo que la norma quiere evitar.
  • Aviso cuando queden pocos folios, y bloqueo cuando se agoten: el médico tiene que comprar otro block, y el sistema no puede inventarse números.

b) QR de verificación pública (§7). Ruta /verify/[folio] que devuelve solo: folio, tipo, fecha de emisión, profesional, estado (vigente/anulado) y coincidencia de hash. Nunca datos clínicos ni el nombre completo del paciente — el empleador comprueba que el certificado es real, no qué tiene su empleado. Se copia el patrón de /d/[token] (cliente admin en servidor + RPC), con la trampa ya conocida: esa ruta corre sin sesión, así que la función no puede llamar a health_audit (audit_logs.user_id es NOT NULL y revienta); se traza en la propia fila.

c) PDF en servidor. Sigue haciendo falta aunque la firma salga de alcance: el QR, el folio y el envío al paciente necesitan un archivo, no una ventana de impresión.

ruta /print/... (la que ya existe) ──Playwright/Chromium──▶ PDF

Un solo renderizador — el de React que ya se está construyendo en L1 — y la fidelidad de lo que el médico ve en pantalla, gratis. El coste es operativo: Chromium en la imagen del backend (~300 MB) y un servicio más que vigilar. pdf-lib dibujando desde el árbol de bloques sigue siendo la alternativa si ese coste se considera inaceptable, pero es un segundo renderizador que habrá que mantener sincronizado con el primero para siempre. Recomiendo Chromium.

d) Firma electrónica (§3). Fuera de alcance (§19). El sistema genera el PDF; el médico lo firma con su herramienta y su certificado acreditado, y lo sube de vuelta para que el expediente quede completo. Lo único que hay que construir aquí es ese retorno: un adjunto marcado como "versión firmada".

e) EPI-1 (§8). Es la fase más barata de las cuatro porque llega la última: necesita el catálogo CIE-10 con flags.notifiable (L3), los diagnósticos estructurados (L5) y el motor de formularios (L5). Lo propio es el disparador — al guardar un diagnóstico notificable, un aviso modal — y la ficha EPI-1 preconsolidada, dejando libres solo fecha de inicio de síntomas, lugar probable de contagio y estado de laboratorio. Punto a verificar antes de estimarla: si el SIVE acepta envío electrónico o si la notificación es en papel/portal. Cambia entre "generar un PDF" e "integrar con un tercero", que no se parecen en nada.

13.4 Camino crítico y qué puede ir en paralelo​

P0 ──┬──────────────────────────────────────────────▶ L4 ──┬──▶ L5 ──▶ L6
│ │
L0 ──┼──▶ L1 ──▶ L2 ────────────────────────────────▶──────┤
│ │
└──▶ L3 ─────────────────────────────────────────────▶┘
  • L1 (editor) y L3 (catálogos) no se estorban: tocan zonas distintas. Dos personas pueden ir a la vez desde el día siguiente a L0.
  • P0 y L0/L1 tampoco: P0 es backend y auth, L1 es frontend y SQL de salud.
  • L4 es la barrera. Cambia cómo se guarda lo clínico y toca permisos vivos; conviene que no haya otra cosa a medias cuando entre.
  • L5 → L6 es estrictamente secuencial: sin diagnósticos estructurados no hay EPI-1 ni certificado homologable.
  • El camino crítico real es P0 → L4 → L5 → L6. L1 y L2, que son lo que el cliente ve, están fuera de él: se pueden entregar pronto y sin culpa.

Archivos previstos​

shared/src/modules/health-l10n.ts contrato (solo tipos)
shared/src/modules/health.ts + tipos de doc por bloques
frontend/src/lib/health/l10n/{index,generic,ec}.ts registro + paquetes
frontend/src/lib/identity-documents.ts absorbido por el paquete (re-export)
frontend/src/sections/health/documents/render/ node-*.tsx + tabla de despacho
frontend/src/sections/health/documents/editor/ TipTap + extensiones propias
frontend/src/sections/health/branding/ perfil de marca + activos
backend/src/modules/health/l10n/ gemelo del registro
backend/src/modules/manifests.ts + healthL10nEcManifest
supabase/migrations/2026XXXX_health_template_blocks.sql
supabase/migrations/2026XXXX_health_branding_assets.sql
supabase/migrations/2026XXXX_health_locale_core.sql
supabase/migrations/2026XXXX_l10n_ec_seed.sql

— L4 en adelante —
frontend/src/sections/health/forms/ renderizador genérico + instancias
frontend/src/sections/health/consent/ trazo / OTP del MSP 024
frontend/src/sections/health/series/ blocks ACESS del profesional
frontend/src/app/verify/[folio]/page.tsx verificación pública (sin PHI)
backend/src/modules/health/pdf/ render (Chromium) + firma (@signpdf)
backend/src/modules/health/forms/ definiciones e instancias
backend/src/modules/permission.guard.ts extendido al resto de controladores
supabase/migrations/2026XXXX_health_immutability.sql adendas + versionado + permisos
supabase/migrations/2026XXXX_health_forms.sql
supabase/migrations/2026XXXX_health_vitals_diagnoses.sql
supabase/migrations/2026XXXX_health_number_series.sql

14. Prerrequisitos y riesgos​

  1. Migraciones sin aplicar. Cerrado el 2026-08-21: F1, F2 y F3 están aplicadas y en uso, verificado contra la base. Se descubrió de rebote que la plataforma de módulos (20260813_module_platform.sql) tampoco lo estaba, y fallaba en silencio — getEnabledModules se traga el error y devuelve lista vacía. Lección que sí queda viva: una migración pendiente aquí no rompe, degrada, así que hay que comprobarla contra la base y no fiarse de que la pantalla se vea bien.
  2. PDF en servidor. Resuelto en §13.3.c: Chromium renderiza la ruta /print/... que ya existe, y no hace falta un segundo renderizador. El coste es operativo (Chromium en la imagen del backend), no de diseño. Se necesita igualmente aunque la firma haya salido de alcance.
  3. Backend sin autenticación. Ya no es un riesgo aparcable: es la fase P0 (§13.1). Con L6 como meta, un backend que acepta el tenant_id que le manden no pasa una inspección, y todo lo que se construya encima hereda el agujero.
  4. Riesgo legal. Los formatos del MSP cambian por resolución ministerial. Por eso las definiciones de formulario son datos versionados (health_form_definitions.version), no componentes React: actualizar un formulario debe ser una migración de semilla, no un despliegue de código. Y una advertencia honesta: este plan interpreta un SRS, no es asesoría legal; antes de anunciar "cumple MSP" hace falta que lo valide alguien que responda por ello.
  5. Rendimiento y abuso del editor. Ver los límites duros del §7. Se comprueban al renderizar.
  6. i18n obligatoria. Toda cadena nueva en es y en como mínimo; el resto cae al fallback español, como ya hace todo el módulo de salud.

15. Decisiones​

Resueltas

  • ¿Hasta dónde llega Ecuador? → hasta L6 completo (2026-08-20). De ahí salen P0, §13.2 y §13.3.
  • ¿Chromium o pdf-lib? → Chromium para generar, @signpdf para firmar; no eran alternativas (§13.3.c).
  • ¿Custodia de la clave de firma? ¿Qué TSA? ¿Token o archivo? → todas cerradas de golpe: la firma sale de alcance (§19, 2026-08-21). El sistema genera el documento y el médico lo firma fuera. Queda solo el retorno del PDF firmado como adjunto (variante B). El análisis descartado está en §19.z.

Abiertas, por orden de urgencia

  1. ¿Se extrae documents ahora? (§18). Es la decisión más cara de aplazar: hacerlo al empezar L1 no cuesta casi nada; hacerlo después de que salud, CRM y agenda hayan escrito encima, muchísimo. Recomiendo extraer.
  2. ¿TipTap? Añade una dependencia grande. La alternativa (editor de bloques propio sobre MUI) es más pobre para redactar. Recomiendo TipTap. Bloquea L1, que es lo primero que se ve.
  3. ¿Quién valida legalmente? Este plan interpreta un SRS; no es asesoría legal. Antes de decirle a un cliente "cumple MSP" hace falta alguien que responda por esa frase. Conviene saber quién es ahora, no al final de L6.
  4. ¿Qué país entra segundo? Si ya se sabe, conviene bosquejar su paquete en L0 aunque quede vacío: dos paquetes reales validan el contrato, uno solo lo confirma por casualidad.

16. Qué hay que averiguar fuera del código​

Cuatro cosas que este plan asume y no puede comprobar solo. Cada una puede mover una estimación de forma importante, así que conviene resolverlas pronto:

QuéPor qué importaCuándo hace falta
Todo lo relativo a certificadores, TSA y sellosCerrado: la firma salió de alcance (§19). Vuelve a abrirse solo si el validador legal dice que el §3 del SRS exige que firme el sistema—
Si el SIVE acepta notificación electrónica o es papel/portalEs la diferencia entre "generar un PDF" e "integrar con un tercero"Antes de estimar L6.e
La fuente oficial y actualizada del CIE-10 del MSP y del CNMBLa semilla de L3 sale de ahí; una lista sacada de cualquier sitio no es defendible ante un auditorAntes de L3
Cómo son de verdad los blocks de la ACESS: por médico, rango, vigencia, qué pasa al agotarseEl modelo de health_number_series está diseñado sobre el SRS, no sobre un block real en la manoAntes de L6.a

17. Comprobación de cumplimiento​

Lista para seguir el avance con el cliente y, llegado el caso, para preparar la inspección. Cada fila apunta a la fase que la cierra.

ExigenciaFaseCómo se demuestra
La historia clínica no se puede editar ni borrar tras firmarL4Intentar corregir una evolución firmada: solo ofrece adenda
Toda lectura, impresión y exportación queda registradaL4audit_logs con usuario, IP y user-agent
Recepción no puede leer el Form. 002L4Entrar con un usuario de recepción
Diagnóstico por CIE-10, no texto libreL3El campo no acepta texto suelto
Prescripción por DCIL3El buscador prioriza principio activo
Formularios MSP 001/002/005/024 completosL5Emitirlos y compararlos con el formato oficial
Consentimiento con no-repudioL5Trazo u OTP + hash + marca de tiempo del servidor
Receta de psicotrópicos con folio ACESS descontadoL6Emitir dos y ver que los folios son consecutivos e irrepetibles
Certificado con QR verificable sin ver la historiaL6Abrir el QR en una sesión anónima
El documento firmado por el médico está en el expedienteL6Abrir la ficha y ver el PDF firmado adjunto a su documento (§19 variante B)
2FA obligatorio para médicos y administradoresP0Iniciar sesión sin segundo factor

Parte IV — Qué sale de salud y se vuelve módulo

18. La regla de extracción​

Odoo no mete la firma dentro de la app médica: tiene sign aparte, tiene documents aparte y tiene ir.sequence en base. La intuición es correcta, y va más lejos que la firma: buena parte de lo que este plan llama "salud" no es de salud.

La plataforma ya tiene el mecanismo entero — manifiestos, dependsOn, activación por tenant, siembra de permisos y menús. No hay que inventar nada: hay que decidir dónde se escribe cada cosa antes de escribirla.

Regla: se extrae cuando hay un segundo consumidor real, o cuando extraerlo más tarde costaría datos. No antes — extraer sobre un solo consumidor es adivinar, y se acierta poco.

La ventana barata se estrechó, pero sigue abierta (medido el 2026-08-21). F1–F3 ya están aplicadas y en uso, así que renombrar dejó de ser un buscar-y-reemplazar. Aun así el coste real es bajo, y conviene ver por qué:

TablaFilas¿Se mueve a documents?
health_document_templates8Sí → doc_templates
health_document_template_versions8Sí → doc_template_versions
health_documents4No: es PHI, se queda en salud
health_document_deliveries42No: apunta a documentos emitidos

Solo se mueven las dos de plantillas, con 8 filas cada una, y ALTER TABLE … RENAME TO conserva los datos. El trabajo está en reescribir las RPC que las nombran y las acciones del frontend — un rato, no una semana. Cada mes que pase con más tenants encima, más caro.

Pieza¿Hay un segundo consumidor?Decisión
Motor de plantillas, editor de bloques, marca y activos (L1)Sí, evidente: presupuestos y contratos del CRM, confirmaciones de agenda, facturasMódulo documents, desde el día uno
Entrega por enlace firmado /d/[token] (F3)Sí: cualquier módulo que mande un documento a un clienteDentro de documents
Series numeradas y folios (L6.a)Sí: facturas, presupuestos, cualquier correlativo legalServicio de plataforma (core), no módulo: es una tabla y una función, y la necesitan todos
Firma criptográfica—No se construye (§19). Si algún día se retoma, nace como módulo sign sobre documents
Motor de formularios (L5)No, todavíaDentro de salud, con esquema neutral y cero columnas de salud, listo para salir cuando aparezca el segundo
Paquetes de localizaciónSí, el CRM tendrá su l10n fiscalEl contrato es por módulo: se copia el patrón, no se comparte el objeto

Mapa​

core
└─ sequences servicio: series numeradas y folios

documents plantillas · bloques · marca · activos · emisión · entrega
├─ health dependsOn ['scheduling', 'documents']
└─ crm (futuro) dependsOn ['documents']
(`sign` iría aquí si se retomara §19.z)

health_l10n_ec dependsOn ['health']

La ranura signature del paquete EC declara que Ecuador exige firma y la pantalla lo recuerda al emitir —"este documento debe firmarse antes de entregarse"— aunque el sistema no la aplique (§19). Regla general para el futuro: dependencia dura (dependsOn) solo cuando sin ella el módulo no funciona; si funciona pero incompleto, se declara en la ranura y se avisa.

La frontera que hay que cuidar: PHI​

documents aporta el motor; el documento emitido se queda en salud. Parece un detalle y es lo que separa esta extracción de una ingenua:

  • Una plantilla, una marca y un logo no son datos de paciente: pueden vivir en tablas con política de SELECT normal.
  • Un certificado emitido sí es PHI y tiene que seguir bajo el régimen del módulo de salud: RLS + REVOKE ALL, sin política de SELECT, acceso solo por funciones health_* auditadas.

Por eso health_documents no se mueve, y cuando el CRM emita presupuestos tendrá su crm_documents. La alternativa —una tabla común de documentos emitidos con una columna module— obligaría a meter tres regímenes de seguridad distintos dentro de una sola tabla, que es exactamente como se filtran las historias clínicas.

19. La firma: fuera de alcance​

Decidido 2026-08-21: el sistema no produce firmas criptográficas. Genera el documento; el médico lo firma aparte, con su herramienta y su certificado acreditado. Se descartan N2s y N2c, y con ellos la custodia del .p12, la TSA, el perfil B-LT, el almacén de raíces y el módulo sign entero. El análisis que llevó hasta ahí queda archivado en §19.z, sin borrar: si algún día se retoma, no hay que volver a derivarlo.

Lo que sí se queda: N0, el QR de verificación. Pasa a ser el único mecanismo antifraude que aporta el software, así que no se toca — sigue en L6 tal cual.

Ahora bien, hay un hueco que la decisión abre y que conviene tapar, porque es barato:

"Que firme en otro software" no falla al firmar; falla al volver. El médico exporta el PDF, lo abre en FirmaEC o en la herramienta de su certificador, lo firma, lo guarda… y el documento legalmente válido termina en el escritorio de su computadora. En el sistema queda la copia sin firmar. La historia clínica deja de ser el expediente y pasa a ser un borrador de lo que está en otra parte — justo lo que un auditor no quiere encontrarse. Y a cuarenta recetas al día, nadie repite ese baile.

Hay tres formas de vivir con eso, y la diferencia de coste entre las dos primeras es casi nula:

Qué hacemosCosteQué queda en el expediente
A · solo exportarGeneramos el PDF y ahí acaba nuestra parte~0La copia sin firmar. El documento válido vive fuera
B · exportar + volver a subirEl médico sube el PDF ya firmado y lo adjuntamos al documento como la versión válida. No validamos la firma: la guardamosmuy bajo — es un adjunto, y F4 ya traía health_attachmentsEl expediente completo
C · B + validaciónAdemás comprobamos que la firma es buena y de quién dice sermedio: obliga al almacén de raíces y a una librería de validaciónLo mismo que B, pero demostrable

Recomendación: B. Cuesta casi lo mismo que A y evita que la historia clínica se convierta en un borrador de lo que está en el escritorio del médico. No hay criptografía, ni KMS, ni TSA, ni raíces que mantener: es subir un archivo y marcarlo como la versión firmada. C se puede añadir después, el día que un auditor pregunte "¿y cómo saben que ese PDF está firmado de verdad?".

19.1 Qué desaparece del plan con esta decisión​

PiezaEstado
Módulo signfuera. Los módulos documents y sequences de §18 siguen: no dependían de la firma
Custodia del .p12 y sus obligaciones (§19.b)fuera
KMS en P0deja de ser bloqueante. Sigue siendo buena práctica para otros secretos, pero ya no frena nada
TSA, RFC 3161, PAdES B-LT (§19.d)fuera
Almacén de raíces de las ECIfuera, salvo que se haga C
Preguntas de §16 sobre certificadores y selloscerradas: ya no aplican
Tamaño de L6de L a M: quedan folios, QR, PDF en servidor y EPI-1

19.2 Lo que hay que decir con honestidad sobre el cumplimiento​

El §3 del SRS pide que el sistema selle el PDF con PAdES. Con esta decisión, el sistema no lo hace: lo hace el médico con su herramienta. La firma sigue siendo legalmente válida —es un certificado acreditado— pero la frase que se le puede decir a un cliente cambia, y conviene tenerla escrita antes de una inspección:

El sistema produce los documentos y lleva su trazabilidad; la firma electrónica la aplica el profesional con su certificado acreditado, fuera del sistema.

Si eso basta o no para la ACESS es exactamente la pregunta de §15.3 — quien valide legalmente. Es la primera cosa que hay que preguntarle, porque si la respuesta es que no basta, hay que volver a §19.z y el coste vuelve entero.

Para generar el PDF en servidor sigue haciendo falta Chromium (§13.3.c): eso no era de la firma, era de producir el archivo.


19.z Archivado — el diseño de firma propia​

Nada de lo que sigue está en alcance desde el 2026-08-21. Se conserva porque el análisis costó y porque la decisión puede volver a abrirse si el validador legal dice que el §3 del SRS exige que firme el sistema. Leer solo en ese caso.

Y "construirla entera" no es la barbaridad que escribí en la primera versión de esta sección. Ahí sostuve que custodiar el .p12 era indefendible. Comprobado en Odoo, el cuadro es otro y conviene tenerlo exacto:

  • Odoo Sign (la app genérica) no custodia el certificado de nadie: el firmante dibuja, teclea o sube su rúbrica, y lo que da validez es el rastro —identidad por correo, IP, sello de tiempo, hash del contenido— más el certificado de auditoría. Es una firma electrónica simple bajo eIDAS y ESIGN; la propia documentación de Odoo dice que para escenarios estrictos (firma cualificada) hacen falta pasos adicionales de verificación de identidad.
  • Odoo l10n_ec_edi (la localización ecuatoriana) sí guarda el .p12 y su contraseña, en Contabilidad → Certificados SRI, y con eso firma el XML de cada factura y la manda al webservice del SRI.

O sea: la custodia existe en Odoo, pero vive en la localización, es de la empresa y es para documentos que emite una máquina — no en la app genérica de firma. Y en Ecuador es la norma del mercado: cualquier producto de facturación electrónica guarda el .p12 del contribuyente. Negarse por principio sería estar fuera de lo que el cliente espera.

Queda una diferencia real que no se puede borrar: en la factura el certificado es de la empresa, y la empresa lo guarda en su propio sistema. En una receta el certificado es de una persona —el médico— y firma en su nombre profesional. Eso no lo hace imposible; cambia lo que hay que exigir alrededor (§19.b).

Decisión: se construye completa, con los tres modos como configuración del módulo sign. El límite no lo pone la arquitectura —los tres comparten motor— lo pone el volumen de cada tipo de documento.

NivelQué haceCustodia de claveCuándo
N0 · QR de verificaciónEl documento lleva un QR contra una URL pública que confirma folio, tipo, fecha, profesional y estado. Sin criptografíaningunaL6, ya planificado
N1 · firma externa con retornoEl sistema exporta el PDF; el médico lo firma con su herramienta; lo vuelve a subir. El módulo valida la firma (cadena, integridad, que el firmante sea ese profesional) y la guarda como la versión buenaningunaL6, recomendado
N2s · firma en el servidor (modelo l10n_ec_edi)El médico deposita su .p12 una vez; el servidor firma al emitir. Es lo que hace Odoo con las facturas del SRIsí, del .p12 del médicoL6 · modo principal, elegido 2026-08-21 — el único viable para volumen alto
N2c · firma diferida en el clienteEl servidor genera el PDF con hueco de firma y devuelve solo el digest; el navegador o un ayudante local lo firma con el token; el servidor incrusta la firmaningunafuera de alcance (2026-08-21: solo certificado en archivo). Queda escrito por si algún día entra un token en hardware

Una inversión que conviene ver antes de estimar: N2s es el más fácil de programar de los tres (Chromium ya produce el PDF, @signpdf lo firma con el .p12 y ya está) y el más difícil de operar (§19.b). N2c era exactamente al revés: no obliga a custodiar nada, pero es criptografía en el navegador más un ayudante de escritorio — y es justo lo que se evita dejándolo fuera. Lo caro de N2s no es el código.

19.a Por qué N0 y N1 siguen existiendo si se hace N2​

Hacer N2 no vuelve inútiles los otros dos: los tres cubren huecos distintos, y un tenant puede tenerlos activos a la vez según el documento.

  1. El QR cubre el día a día. Lo que se quiere evitar con la firma en una receta común o un certificado de reposo es la falsificación, y un QR contra una URL del sistema la resuelve sin criptografía ninguna.
  2. N1 arregla el fallo del camino "otro software": el retorno. Es la diferencia entre un archivo perdido y un expediente completo, y cuesta poco — validar una firma PAdES es mucho más barato que producirla, y no se custodia nada.
  3. El volumen alto y el peso legal no caen en los mismos documentos. Recetas comunes y evoluciones son decenas al día y no se van a firmar a mano; la historia clínica exportada para un juzgado, el certificado que un empleador va a discutir o el consentimiento de una cirugía son unos pocos al mes, y ahí firmar fuera es perfectamente tolerable.

Orden de construcción dentro de L6: N0 → N2s → N1. (N2c queda fuera: ver la nota de "solo archivo" al final de §19.b.)

Ayer escribí que N1 tenía que ir antes que N2s "porque para firmar hay que saber validar". Es verdad a medias y conviene precisarlo: lo que N2s necesita es la biblioteca de validación —para comprobar en pruebas que lo que produce está bien sellado— no el flujo de N1, que es una pantalla donde el médico sube un PDF que firmó por fuera. Así que se puede ir directo a N2s: se escribe la validación como pieza interna y N1 sale después casi gratis, porque solo le falta la pantalla.

N1 no se descarta, y ahora importa más: con N2c fuera, es la única salida para el médico que no quiera depositar su certificado. Cuesta poco una vez hecho N2s —solo le falta la pantalla— y es lo que hace que "solo archivo" sea una decisión segura en vez de un callejón.

19.b Lo que obliga la custodia​

Guardar el .p12 de un médico no es una columna más: es un compromiso operativo. Si se hace N2s, se hace entero. Nada de esto es opcional:

RequisitoPor qué
La clave que cifra el .p12 no vive en la misma base (variable de entorno o KMS)Un volcado de la base robado no debe bastar para firmar
La contraseña del certificado nunca en logs, ni en trazas de error, ni en la respuesta de una APIEs el segundo factor del archivo
Delegación explícita y revocable del médico: qué tipos de documento autoriza a firmar automáticamente, desde cuándo, y un botón para retirarlaEl certificado es personal y firma en su nombre; el consentimiento tiene que ser suyo y poder deshacerse
Cada uso, auditado: qué documento, cuándo, desde qué sesiónSin esto no se puede responder "¿quién firmó esto?"
Aviso de caducidad con antelación (los certificados ecuatorianos duran uno o dos años)Un certificado vencido rompe la emisión el peor día
Baja inmediata al revocar, y borrado real del materialLa revocación tiene que significar algo
Plan de brecha escrito antes de guardar el primer certificadoSi se filtra, hay que avisar a cada médico y a la autoridad. Improvisarlo es peor

Supuesto declarado: solo certificado en archivo (decidido 2026-08-21). El sistema soporta .p12 / .pfx y no soporta token en hardware, que es lo que habría obligado a construir N2c. Es lo que abarata L6.

Como todo supuesto, hay que escribir qué pasa cuando se rompa: si mañana llega un médico con la firma en token, no puede depositarla, y la salida es N1 — firma con su herramienta y sube el PDF. Es manual, pero funciona desde el primer día y no obliga a abrir un proyecto nuevo. Por eso N1 no es opcional.

Nota de alcance: esto engorda P0, no L6 — la gestión de secretos (KMS o equivalente) es infraestructura de plataforma, y hasta que exista no se debe guardar el primer .p12.

19.c Diseño de N2s​

El almacén. Tabla sign_certificates: tenant, owner_profile_id, alias, el .p12 cifrado, la contraseña cifrada, referencia a la clave del KMS, y —en claro, porque hacen falta para avisar y auditar— sujeto, emisor, número de serie y valid_from / valid_to. Estado active | revoked | expired. El material descifrado existe solo en memoria durante la firma; no se escribe en disco, ni en caché, ni en un log.

La regla que más reduce el daño no es criptográfica. Se firma únicamente dentro de una petición autenticada del propio médico, con sesión aal2: nunca desde una cola, un cron, un webhook ni una herramienta de bot. Es la diferencia entre "el servidor puede firmar cuando el médico lo pide" y "el servidor puede firmar". Cuesta una comprobación y quita de la mesa el escenario feo — que alguien que entre al backend emita recetas de estupefacientes de madrugada.

Ojo con la consecuencia: eso excluye firmar desde el bot de WhatsApp. Es deliberado, y es el mismo muro que ya bloquea el doctor-assist por canal: un número de teléfono no prueba que quien escribe sea el médico.

19.d Sellado de tiempo — resuelto, con una corrección​

Confirmado (2026-08-21): las ECI acreditadas en Ecuador prestan el servicio de Autoridad de Sellado de Tiempo (TSA) sobre TSP, RFC 3161. Es el estándar internacional del IETF, está bien soportado y deja de ser una incógnita.

Corrección a lo que escribí antes. Dije "PAdES-T" y me quedé corto para este caso. PAdES-T (baseline B-T) prueba cuándo se firmó, pero no que el certificado estuviera vigente en ese momento: eso se demuestra con los datos de revocación (OCSP o CRL) del instante de la firma. Y esos datos hay que incrustarlos al firmar, porque dentro de diez años el respondedor OCSP de un certificado muerto hace años no va a contestar. Con retención a diez años el objetivo correcto es PAdES B-LT — firma + sello + material de validación dentro del propio PDF (diccionario DSS). Añadirlo después es imposible sin tocar el documento, que es justo lo que L4 prohíbe: nace B-LT o no nace.

Parámetros que quedan fijados, y que no conviene dejar al criterio del que implemente:

PuntoValor
ProtocoloTSP · RFC 3161 sobre HTTP
Hash del message imprintSHA-256. SHA-1 no
certReqtrue — que la TSA devuelva su certificado, para que el token se pueda verificar solo, sin llamar a nadie
Nonceuno distinto por petición, y comprobar que vuelve igual
Dónde va el tokenatributo no firmado id-aa-signatureTimeStampToken del CMS
Perfil objetivoPAdES B-LT: sello + OCSP/CRL embebidos en el DSS
Verificación al recibirque el imprint coincide, que el nonce coincide, y que el certificado de la TSA encadena a una raíz de confianza

La TSA es una dependencia de red dentro del camino de firma, y eso obliga a decidir el modo de fallo. La respuesta correcta es la estricta: firma y sello son un solo paso atómico. Si la TSA no responde, el documento no se emite firmado — se queda en borrador y se le dice al médico. Nunca un documento marcado como firmado sin su sello, porque "lo sellamos luego" es exactamente el UPDATE sobre lo firmado que L4 prohíbe.

Cada sello se paga. Las TSA cobran por estampilla o por bolsa, así que hay una decisión de producto detrás de una técnica: qué tipos de documento llevan firma criptográfica. Es la misma separación por volumen de §19, ahora con un coste unitario real — recetas comunes a decenas por día contra unos pocos certificados al mes. Conviene que el tipo de documento declare si exige firma, y que el paquete EC ponga los valores por defecto.

La configuración es del país, no del módulo. URL de la TSA, OID de política, algoritmo y credenciales van en la ranura signature del paquete EC y en la configuración del tenant — cada clínica puede contratar una ECI distinta. El módulo sign se queda genérico: habla RFC 3161 y no sabe en qué país está. Las credenciales de la TSA son otro secreto para el KMS de P0.

19.e Quién contrata qué (y quién paga)​

Una ECI vende cosas distintas, y conviene no confundirlas porque cambian de dueño:

PiezaQuién la contrataCosteNuestro papel
Certificado de firma (.p12)El médico, a su nombreanual, por personaNinguno. Lo recibimos ya emitido y lo custodiamos (§19.b)
Sellos de tiempo (TSA)Decisión abierta: el tenant o la plataformapor estampilla o por bolsaConsumirlos al firmar
Raíces e intermedios de las ECI acreditadasNadie: son públicos0Nuestro: mantener el almacén de confianza al día

El certificado no es negocio nuestro y no debe serlo: es personal del médico y firma en su identidad profesional. Los sellos sí hay que decidirlos, porque los consume el servidor en el momento de firmar y alguien tiene que tener la cuenta:

  • El tenant trae su TSA (recomendado por defecto). La clínica ya tiene trato comercial con una ECI —ahí compran los certificados sus médicos— y sus credenciales van a su configuración, como cualquier otra integración. La plataforma no adelanta un coste variable ni acaba revendiendo un servicio regulado.
  • La plataforma pone una TSA común. Quita fricción de alta —pedirle a una clínica pequeña que contrate una TSA antes de emitir su primera receta firmada es una barrera real— a cambio de un coste por documento que hay que recuperar en el precio.

Recomendación: construirlo como configuración del tenant (soporta los dos), y dejar la TSA común como decisión comercial, no técnica, para cuando se sepa el precio por estampilla. Y comprobar una cosa antes: algunos paquetes de certificado de las ECI ya incluyen una cuota de sellos, en cuyo caso el médico o la clínica ya los tienen pagados y solo hay que pedir las credenciales.

Almacén de raíces: es gratis y es nuestro sí o sí. Sin los certificados raíz e intermedios de las ECI acreditadas, la validación no puede distinguir un certificado bueno de uno autofirmado — ni el .p12 que deposita el médico, ni el PDF que sube en N1, ni el token que devuelve la TSA.

De dónde salen, en dos pasos que no se pueden juntar:

  1. Quién está acreditado → el registro de ARCOTEL (arcotel.gob.ec/entidades-de-certificacion-firma-electronica/) y firmadigital.gob.ec. Esta es la lista con valor oficial; ninguna otra.
  2. El archivo del certificado → el sitio de cada ECI, en su centro de descargas. El del Banco Central está en eci.bce.ec, con la AC raíz y la AC subordinada separadas. Las demás publican lo suyo en su propio dominio.

Cuidado con los revendedores. Hay "terceros vinculados" registrados en ARCOTEL (Eclipsoft, Datilmedia, Argosdata y otros) que comercializan certificados respaldados por una ECI acreditada. Un médico puede haber comprado a cualquiera de ellos: lo que hay que meter en el almacén es la raíz de la ECI que emite, no la marca a la que le pagó.

Disciplina de mantenimiento, porque un almacén de confianza mal llevado es peor que no tenerlo:

  • Descarga por HTTPS desde el dominio de la propia ECI, nunca desde un enlace de un tercero ni desde el certificado que se está validando (AIA/caIssuers sirve para construir la cadena, jamás para decidir en quién confiar: es circular).
  • Verificar la huella SHA-256 contra una fuente independiente antes de aceptarla, y dejarla escrita en un manifiesto junto al archivo.
  • Los certificados se versionan en el repositorio y cambiarlos es un evento de seguridad: PR con revisión humana, nunca actualización automática desde la red.
  • Vigilar caducidades. Las raíces duran diez o veinte años; las subordinadas rotan bastante más.

Atajo útil para arrancar: un .p12 real suele traer dentro su cadena de intermedios, así que el primer certificado que deposite un médico sirve para sembrar el almacén — pero la raíz se ancla siempre desde la fuente oficial, no desde el archivo que llegó.

Nota de implementación: verificar si la versión de @signpdf en uso trae soporte de TSA; si no, la petición y la respuesta TSP se construyen con PKI.js (tiene TimeStampReq / TimeStampResp) y el token se incrusta como atributo no firmado. La parte B-LT —recoger OCSP/CRL y escribir el DSS— es trabajo aparte y hay que contarlo en la estimación de L6.

La delegación es un documento, no una casilla. El médico autoriza —en el propio sistema, firmando con N0/N1— qué tipos de documento pueden firmarse con su certificado y desde cuándo. Revocable con un botón; revocar no invalida lo ya firmado, solo impide firmar más.

Antes de la primera firma real, cinco cosas, en este orden:

  1. KMS operativo (P0). Sin esto no se guarda ningún .p12.
  2. Plan de brecha escrito: a quién se avisa, en cuánto tiempo, con qué texto.
  3. El texto de la delegación, revisado por quien responda legalmente (§15.3).
  4. Un certificado real de prueba de un certificador ecuatoriano — no uno autofirmado, que no prueba nada de la cadena.
  5. La prueba de manipulación: alterar un byte del PDF firmado y comprobar que la validación falla. Es la fila de §17 que un auditor va a querer ver en vivo.

20. Qué cambia en las fases​

Nada del alcance; sí el sitio donde se escribe cada cosa, y el tamaño de L6.

  • L1 se construye como módulo documents. Mismo trabajo, distinto paquete y un manifiesto más. El sobrecoste hoy son horas; dentro de seis meses, con salud, CRM y agenda escribiendo encima, son semanas.
  • L6.a (folios) pasa a sequences, servicio de core. Lo de Ecuador se reduce a la pantalla donde el médico registra el rango de su block.
  • El módulo sign no se construye (§19). De L6.c/d queda solo el QR y el retorno del PDF que el médico firmó fuera, que es un adjunto. L6 baja a M. Chromium sigue haciendo falta para generar el PDF: eso no era de la firma.
  • L5 se queda dentro de salud — un solo consumidor — pero con el esquema de formularios neutral, sin una sola columna que diga "paciente", para que salir sea un renombrado el día que el CRM quiera formularios de calificación.
  • El orden no cambia: P0 → L0 → L1 → L2 → L3 → L4 → L5 → L6.

Glosario​

SiglaQué es
ACESSAgencia de Aseguramiento de la Calidad de los Servicios de Salud. Audita expedientes, da permisos de funcionamiento y controla los recetarios de psicotrópicos
ARCOTELAgencia de Regulación y Control de las Telecomunicaciones. Acredita a las ECI
CIE-10Clasificación Internacional de Enfermedades, 10.ª revisión. El catálogo de diagnósticos
CNMBCuadro Nacional de Medicamentos Básicos
DCIDenominación Común Internacional: el principio activo (paracetamol), no la marca
ECIEntidad de Certificación de Información. El nombre ecuatoriano de una Autoridad de Certificación (CA): emite certificados de firma con validez legal y presta servicios como el sellado de tiempo. BCE, Security Data, ANF, UANATACA, Consejo de la Judicatura
EPI-1Ficha de notificación epidemiológica obligatoria, del sistema SIVE
HCUHistoria Clínica Única. La serie de formularios del MSP
IESSInstituto Ecuatoriano de Seguridad Social. Homologa los certificados de reposo
KMSServicio de gestión de claves. Guarda la clave que cifra los secretos, fuera de la base de datos
LOPDPLey Orgánica de Protección de Datos Personales. Clasifica los datos de salud como sensibles
MSPMinisterio de Salud Pública
PAdESPerfil de firma electrónica para PDF. B-T añade sello de tiempo; B-LT añade además los datos de revocación, para que la firma se sostenga a largo plazo
PHIProtected Health Information: cualquier dato clínico identificable
RPIS / RPCRed Pública Integral de Salud (MSP, IESS, ISSFA, ISSPOL) / Red Privada Complementaria
SRIServicio de Rentas Internas. Aparece aquí solo como referencia: es a quien firma facturas el l10n_ec_edi de Odoo
TSA / TSPAutoridad de Sellado de Tiempo y su protocolo, RFC 3161
aal2Nivel de garantía de autenticación 2: la sesión pasó por segundo factor
.p12 / .pfxEl archivo que contiene el certificado y su clave privada, protegido por contraseña

21. Validación de L0–L3 (2026-08-23)​

130 comprobaciones: 71 contra la base real (solo lectura y funciones puras, sin escribir una fila de datos del cliente) y 59 sobre la lógica pura del frontend. 0 fallos. Más tsc de los dos paquetes, eslint y next build completo.

BloqueQué se comprobóResultado
MigracionesLas 11 aplicadas, con sus tablas, columnas y funciones8/8
Contratos RPC ↔ tiposQue cada RPC devuelve las claves que el frontend declara5/5
Reglas de negocioDeduplicado de activos, bloques obligatorios (positivos y negativos, incluido anidado hondo), semillas EC12/12
JurisdicciónLos 24 tenants resuelven la plantilla que les toca24/24
Datos intactos8 versiones en v1 y 3 en v2, ninguna sin texto, coherencia v1⇄v25/5
SeguridadLa clave anónima bloqueada en 6 tablas con PHI o configuración6/6
Catálogos (L3)Registrados, vacíos, búsqueda degradando, importación cerrada13/13
Lógica puraCédula/RUC, resolución de paquete, saneo, límites, ida y vuelta v1⇄v2, tokens59/59

21.1 El fallo que encontró​

Las plantillas ecuatorianas declaraban signatureBlock y qr, el renderizador reservaba el hueco correctamente… y ninguna vista de impresión pasaba esas ranuras: solo prescriptionItems. Resultado: el certificado ecuatoriano se habría impreso sin espacio de firma y sin QR, cumpliendo formalmente sus required_nodes y siendo inservible en la práctica.

Es un fallo de contrato entre dos capas que por separado funcionaban, y no lo habría cazado ningún test unitario de ninguna de las dos.

Se arregló en tres frentes:

  1. La ranura de firma se rellena (sections/health/documents/signature-block.tsx), en las dos vistas de impresión. Es la línea, el nombre y las credenciales; la firma electrónica sigue siendo externa (§19).
  2. El qr deja de ser obligatorio hasta L6. Exigir un bloque que el sistema no puede pintar es el mismo error que se evitó a conciencia con el CIE-10: el usuario no lo puede quitar y no hace nada. El nodo se queda marcando el sitio.
  3. Las plantillas EC pasan a professional.credenciales, que imprime ACESS y SENESCYT; con professional.registro solo salía la licencia antigua. Y la receta EC, que no tenía ningún token de profesional, ya lleva su nombre y sus registros bajo la firma.

21.2 Lo que NO se pudo validar, y por qué​

Honestidad sobre el alcance: verde aquí no quiere decir probado en producción.

  • El camino feliz con sesión. Casi todas las funciones health_* comprueban user_has_permission, que resuelve con auth.uid(); con la clave de servicio eso es NULL y la función rechaza. Se comprobó que rechaza —lo cual es en sí una prueba de seguridad— pero emitir un documento de principio a fin exige un navegador con sesión.
  • El disparador de DCI. Comprobarlo obligaría a insertar una receta en datos reales del cliente. Se sabe que la migración aplicó; su comportamiento se verá con la primera receta que se emita.
  • Nada se ha visto en pantalla. Compila y las rutas se generan, pero no se ha pulsado un botón. El editor, la marca y las credenciales están sin probar en navegador.
  • vitest sigue sin arrancar con Node 20.11.1 (necesita ≥20.12). Los tests están escritos; la misma lógica se verificó compilando y ejecutando con Node.

21.3 Pendiente antes de que Ecuador sirva de algo​

  • Ningún tenant tiene país configurado, así que hoy todos resuelven la plantilla genérica. Manta Hospital Center necesita country = 'EC' en la configuración del workspace para que aparezcan las ecuatorianas.
  • Los catálogos CIE-10 y CNMB están vacíos — bloqueado por §16.
  • Nadie tiene credenciales cargadas: hasta que se rellenen, los documentos imprimen la licencia antigua del recurso.