En la entrega anterior conté por qué la adopción de la Brújula Comercial —el reporte de facturación de Diveco— no se resolvió mejorando la herramienta, sino envolviéndola en una escuela de negocios interna con curso, examen, certificado y notificaciones. Esa historia se puede leer como narrativa de producto. Esta entrega es distinta: es la que describe cómo se construyó, y la tesis es incómoda para cualquiera que crea que la persuasión vive en el copy.
Cuando quieres que un comportamiento ocurra de forma confiable, no lo pides mejor. Lo mueves del cliente —opcional, evadible— al servidor —forzado.
Cada mecanismo de la Diveco Business School que voy a describir tiene esa misma forma: algo que en un producto ingenuo viviría en el navegador, como estado mutable que el usuario controla, y que aquí vive en DynamoDB detrás de una condición atómica. La diferencia entre “sugerir” y “forzar” no es de tono. Es de dónde corre el código.
Escasez ejecutable
El patrón ingenuo para limitar intentos de examen es un contador en el cliente: una variable de estado, quizás persistida en localStorage. Se gamea con un F5 o abriendo DevTools. No es una vulnerabilidad exótica, es el primer experimento que hace cualquier usuario medianamente curioso.
El quiz de la Business School usa dos fases, reserve y finalize. reserve es la que importa para la escasez: quema un intento atómicamente contra DynamoDB, vía un UpdateItem con condición. Si la condición falla —ya no quedan intentos— la respuesta es un 403 attempts_exhausted, y el cliente ni siquiera llega a ver las preguntas. No hay un “primero verifico si puedes, luego resto uno”: es una sola operación atómica que resta y verifica en el mismo paso, así que dos pestañas abiertas a la vez no pueden ambas colarse por la misma ranura.
Un detalle que delata la intención: los intentos abandonados cuentan. Si reservas un intento y cierras la pestaña sin terminar el examen, ese intento ya se gastó. La escasez no es “intentos completados”, es “intentos iniciados” —porque de lo contrario el límite sería trivialmente evadible cerrando y reabriendo hasta acertar por agotamiento.
finalize es la otra mitad: califica del lado del servidor, sobre las respuestas que llegan, no sobre un puntaje que el cliente reporta. Entre reserve y finalize no hay ningún punto donde el navegador tenga autoridad sobre cuántos intentos quedan o qué nota se obtuvo.
Deadline como estado del servidor
El mismo principio aplica a la urgencia. Un banner de “quedan 2 días” es copy: información que el cliente decide mostrar, y que no impide nada si el usuario decide ignorarlo. La ventana de examen de la Business School es distinta: availableFrom y availableUntil son campos que el servidor evalúa para derivar examWindowStatus, con tres valores posibles —abierto, programado, cerrado—. Fuera de la ventana, el quiz no se muestra degradado ni deshabilitado: se oculta. No hay ruta por la que un usuario con la URL correcta y las herramientas de red abiertas pueda golpear el endpoint de examen fuera de horario y obtener algo distinto de un rechazo.
La urgencia, aquí, no es una técnica de copywriting. Es una autorización que el servidor otorga o niega según el reloj. El “quedan 2 días” en el correo (ver más abajo) es honesto porque describe un estado que el backend ya hace cumplir, no una presión inventada para el mensaje.
Recompensa compartible sin fuga de datos
El certificado es la pieza que sale del sistema y vive en el mundo: un PDF que la persona sube a LinkedIn. Eso le impone una restricción que las otras piezas no tienen —tiene que ser correcto una sola vez, sin duplicarse, y tiene que poder verificarse en público sin filtrar datos personales de quien lo obtuvo.
La idempotencia se resuelve por clave: el certificado es único por (courseId, userId). Se emite dentro de finalize, en el mismo flujo que califica el intento que cruza el umbral, y la emisión corre en un try/catch aislado del resto de la operación —un fallo en el envío por SES nunca rompe la nota ni el estado del intento. emailSentAt se estampa solo tras el éxito de SES; si falla, queda sin estampar y se reintenta en el siguiente finalize aprobado sobre el mismo curso. Es un detalle pequeño con una implicación grande: el sistema de correo puede caerse sin que el usuario pierda su aprobación ni sin que el certificado quede en un estado ambiguo.
El PDF en sí se genera del lado del cliente con jsPDF —A4 horizontal, logo, firma, un código QR—. Que el render sea client-side no es una concesión a la escasez ejecutable de más arriba: el PDF no es la fuente de verdad, es una proyección. La fuente de verdad es el registro en el servidor, y por eso el QR no apunta a un endpoint que devuelva el modelo completo del certificado. Apunta a una verificación con op:'validate', que proyecta exactamente tres campos —nombre, curso, fecha— y nunca el userId, que en este sistema es el correo de la persona. Cualquiera que escanee el QR de un certificado ajeno confirma que es legítimo sin aprender el correo del dueño. Es la misma disciplina de las secciones anteriores aplicada al lado inverso del problema: no solo forzar del lado del servidor lo que debe forzarse, también decidir del lado del servidor exactamente qué se expone cuando algo se hace público.
Empujón quirúrgico
Las notificaciones son la única pieza de esta pila que sí toca al usuario directamente, y por eso es la que más fácil se vuelve ruido si se hace mal. La implementación evita el error obvio —notificar a todo el mundo por igual— tratándolas como un delta job: enviar_brujula.py escanea DynamoDB, calcula enrolled − passed para obtener a quién recordarle, y a passed para saber a quién felicitar. Nadie recibe un recordatorio de un examen que ya aprobó, y nadie recibe una felicitación por algo que no hizo.
El script corre en modo dry-run por defecto: hay que pasar --send explícitamente para que dispare correos de verdad. Es una decisión de diseño tan simple que es fácil pasarla por alto, pero es la que evita que un typo en un cron job le mande “te quedan 2 intentos” a la base completa de usuarios un domingo a las 3am. El costo de un envío accidental en este tipo de sistema —persuasión activa, con escasez y deadline reales— es más alto que en una notificación transaccional cualquiera, porque el mensaje sí presiona.
Y el contenido del correo, igual que el banner de la ventana de examen, describe presión real: escasez (“máximo 2 intentos”), deadline en el asunto, recompensa (el certificado, el enlace a LinkedIn). No es manipulación disfrazada de honestidad. Es honestidad, porque cada afirmación del correo corresponde a una regla que el servidor ya hace cumplir en otro lugar del sistema.
La deuda honesta
Ningún sistema real queda sin costuras, y esta serie se ha propuesto no esconderlas. El Lambda quiz-attempt-gate que corre reserve/finalize no puede importar código de app/ —son dos entornos de ejecución distintos dentro del mismo repo Amplify, y esa frontera no se cruza—. La consecuencia es que lib.ts, dentro del propio Lambda, reimplementa gradeQuiz y la constante CERT_THRESHOLD que usa el resto de la aplicación. Junto a esa reimplementación hay un comentario que dice, literalmente, que hay que mantenerla en sync con el original.
Es deuda técnica real, del tipo que un linter no atrapa: dos copias de la misma lógica de calificación, separadas por una frontera de despliegue, sostenidas por una convención humana en vez de un import del compilador. La alternativa —extraer un paquete compartido, publicarlo, versionarlo— habría sido la solución correcta a mediano plazo y una sobreingeniería evidente para un piloto acotado y reversible. Nombrar el tradeoff en vez de esconderlo es, de las decisiones de este post, la más fácil de tomar y la más fácil de saltarse.
Cierre
Cada mecanismo de este artículo resuelve el mismo problema desde un ángulo distinto: intentos, tiempo, reputación pública, atención selectiva. Y en los cinco casos la solución tiene la misma forma —mover la decisión del lado que el usuario controla al lado que no—. Esa es la lección que sí es portable fuera de este sistema: diseñar adopción no es escribir mejor copy ni insistir con más correos. Es ingeniería de sistemas aplicada a un problema que normalmente se trata como si fuera de marketing.
Esta disciplina —forzar del lado del servidor lo que el cliente no puede hacer cumplir por sí solo— no es nueva en este blog. Es la misma que exploré, desde otro ángulo, en property testing con eris: declarar la ley en el lugar donde nadie puede evadirla, y dejar que el sistema —no la buena fe de quien lo usa— la haga cumplir.