<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Franchesco Romero</title>
    <description>The latest articles on DEV Community by Franchesco Romero (@elchesco_).</description>
    <link>https://dev.to/elchesco_</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1636392%2Fc4a5090b-c32c-48a0-884c-1d8299fa6fb7.JPG</url>
      <title>DEV Community: Franchesco Romero</title>
      <link>https://dev.to/elchesco_</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/elchesco_"/>
    <language>en</language>
    <item>
      <title>Tres capas de contexto para un agente de código, ordenadas por garantía</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Sun, 30 Aug 2026 18:09:58 +0000</pubDate>
      <link>https://dev.to/aws-builders/tres-capas-de-contexto-para-un-agente-de-codigo-ordenadas-por-garantia-2joe</link>
      <guid>https://dev.to/aws-builders/tres-capas-de-contexto-para-un-agente-de-codigo-ordenadas-por-garantia-2joe</guid>
      <description>&lt;h1&gt;
  
  
  Tres capas de contexto para un agente de código, ordenadas por garantía
&lt;/h1&gt;

&lt;p&gt;Una migración tiró una columna. Las pruebas estaban en verde, la revisión estaba hecha, el deploy era de rutina. Setenta y ocho segundos después el panel de admin estaba regresando 500s. El agente que revisó esa migración no tenía forma de saber que el deploy rota dos tasks una a la vez, porque nada en el repo lo dice.&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://speakerdeck.com/elchesco/como-evite-que-claude-repitiera-mis-errores" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Ffiles.speakerdeck.com%2Fpresentations%2F2ef4bdd2c2234c729b73efbd0ef3ef6f%2Fslide_0.jpg%3F40414533" height="1080" class="m-0" width="1920"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://speakerdeck.com/elchesco/como-evite-que-claude-repitiera-mis-errores" rel="noopener noreferrer" class="c-link"&gt;
            Cómo evité que Claude repitiera mis errores. - Speaker Deck
          &lt;/a&gt;
        &lt;/h2&gt;
          
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fd1eu30co0ohy4w.cloudfront.net%2Fassets%2Ffavicon-bdd5839d46040a50edf189174e6f7aacc8abb3aaecd56a4711cf00d820883f47.png" width="512" height="512"&gt;
          speakerdeck.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capa&lt;/th&gt;
&lt;th&gt;Qué va en ella&lt;/th&gt;
&lt;th&gt;Presente cuando&lt;/th&gt;
&lt;th&gt;Obedecida&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Lo que el modelo no puede deducir leyendo el repo&lt;/td&gt;
&lt;td&gt;Cada sesión, siempre&lt;/td&gt;
&lt;td&gt;Casi siempre&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Archivos de memoria&lt;/td&gt;
&lt;td&gt;Un archivo por error cometido, con la razón&lt;/td&gt;
&lt;td&gt;Cuando la recuperación lo juzga relevante&lt;/td&gt;
&lt;td&gt;Casi siempre&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hooks de git y de herramientas&lt;/td&gt;
&lt;td&gt;Reglas que no te puedes dar el lujo de que se salten&lt;/td&gt;
&lt;td&gt;Cada corrida&lt;/td&gt;
&lt;td&gt;No negociable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;El orden es el punto. Cada capa cuesta más de configurar y da una garantía más fuerte. La mayoría de la gente trata de resolver todo en la capa uno, que es por lo que su &lt;code&gt;CLAUDE.md&lt;/code&gt; tiene 600 líneas y va empeorando.&lt;/p&gt;

&lt;p&gt;Dos números de los cinco meses, los dos medidos, los dos en:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;CLAUDE.md&lt;/code&gt; fue de 180 líneas, llegó a su pico en 249, se podó a 129, y se queda en 145.&lt;/li&gt;
&lt;li&gt;Los archivos de memoria fueron 12, 26, 36, 76, 91.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Los dos medidos el 21 de agosto de 2026. Corre los scripts en tu propio repo hoy y el conteo de memoria será más alto que el mío, porque esa capa nunca deja de crecer. Ese es el punto de ella.&lt;/p&gt;

&lt;p&gt;La capa que escribo a mano se encogió. La capa que sale de los errores creció. Si las dos tuyas están creciendo, estás poniendo todo en la capa uno.&lt;/p&gt;

&lt;h2&gt;
  
  
  El incidente
&lt;/h2&gt;

&lt;p&gt;21 de julio, 21:14. CloudWatch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;UndefinedColumnError: column listings.public_visibility does not exist
GET /api/v1/admin/marketplace
500 · 500 · 500 · 500 · 500 ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La migración era correcta. El timing no. El backend corre dos tasks de Fargate y el rollout las reemplaza una a la vez, mientras que la migración corre antes de que las tasks nuevas estén sanas. Por setenta y ocho segundos el código viejo seleccionaba una columna que ya no existía.&lt;/p&gt;

&lt;p&gt;El arreglo es el expand y contract de manual: deja de referenciar la columna en un deploy, tírala en uno posterior. Esa no es la parte interesante. La parte interesante es que pedí una revisión de esa migración y nunca le dije a nadie, humano o de otro tipo, cómo se despliega este proyecto. La topología de deploy no está en el código. No hay ningún archivo que diga "esto rota dos a la vez, con traslape".&lt;/p&gt;

&lt;p&gt;El reflejo después de un incidente así es escribir un mejor prompt la próxima vez. Eso falla por una razón aburrida: un prompt se muere con la sesión.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capa 1: lo que el modelo no puede deducir
&lt;/h2&gt;

&lt;p&gt;La prueba de si una línea pertenece a &lt;code&gt;CLAUDE.md&lt;/code&gt; es una oración.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Si el modelo lo puede deducir leyendo el repo, córtalo.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude lee tu manifiesto. No necesita que le expliques que usas React. El &lt;code&gt;CLAUDE.md&lt;/code&gt; típico que encuentras en línea es en su mayoría una descripción del repo, que el agente deriva en dos tool calls y deriva mejor de lo que tú la escribiste, porque el repo es la verdad y tu descripción tiene cuatro meses de vieja.&lt;/p&gt;

&lt;p&gt;Lo que sobrevive la prueba cae en cuatro formas, y todas son o una prohibición o una excepción:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Base de datos&lt;/span&gt;

Nunca edites el schema a mano, siempre a través de migraciones.

Las migraciones que crean enums deben usar &lt;span class="sb"&gt;`op.execute()`&lt;/span&gt; crudo, no
&lt;span class="sb"&gt;`op.create_table`&lt;/span&gt; con un Enum nombrado: el paso de autogenerate se rompe.

&lt;span class="sb"&gt;`score_total`&lt;/span&gt; es una columna generada de Postgres. Nunca le asignes.

Las pruebas necesitan un Postgres real y el password no es el que está en el
README, que está viejo. Jálalo del secret manager del equipo.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nota la última línea. Carga el puntero, no el secreto. Un &lt;code&gt;CLAUDE.md&lt;/code&gt; se commitea, así que una credencial nunca va dentro de uno. Nombrar dónde vive el valor es todo el valor de la línea, y no cuesta nada.&lt;/p&gt;

&lt;h3&gt;
  
  
  Podar es una mejora medible
&lt;/h3&gt;

&lt;p&gt;La parte contraintuitiva: un &lt;code&gt;CLAUDE.md&lt;/code&gt; de 600 líneas rinde peor que uno de 100 líneas. Estos no son hechos que se acumulan, son instrucciones compitiendo por la atención. Cada línea irrelevante diluye las que importan.&lt;/p&gt;

&lt;p&gt;El mío creció a 249 líneas en once días, que es lo que pasa cuando tratas el archivo como documentación de onboarding. El 5 de abril lo corté a 129. A lo largo de los cuatro meses siguientes se fue de vuelta a 145 y nada se rompió.&lt;/p&gt;

&lt;p&gt;El truco práctico es hacer que el agente audite su propio contexto. Abre el archivo y pregunta:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;¿Qué en este archivo puedes deducir leyendo el repo?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Luego borra eso. Vas a borrar más de lo que esperas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capa 2: un error, un archivo, con la razón
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt; es lo que sabías de antemano. La memoria es lo que aprendiste crasheando. Si tu &lt;code&gt;CLAUDE.md&lt;/code&gt; crece cada vez que algo se rompe, estás usando la capa equivocada.&lt;/p&gt;

&lt;p&gt;Aquí está el archivo que salió de los 78 segundos, textual excepto por el&lt;br&gt;
frontmatter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;Una migración que hace DROP o RENAME de una columna sale en el mismo deploy que el código que deja de usarla, pero el backend corre 2 tasks de Fargate y las rota una a la vez, y la migración corre antes de que las tasks nuevas estén sanas.

&lt;span class="gs"&gt;**Por qué:**&lt;/span&gt; el rolling deploy significa que el código viejo y el nuevo sirven simultáneamente. Cualquier cambio de schema que no sea compatible hacia atrás con la imagen anterior rompe ese traslape. Pegó el 2026-07-21: &lt;span class="sb"&gt;`/admin/marketplace`&lt;/span&gt; tronó por ~78 segundos.

&lt;span class="gs"&gt;**Cómo aplicar:**&lt;/span&gt; los cambios aditivos (ADD COLUMN con un default, CREATE INDEX) son seguros de un jalón. Los destructivos necesitan expand/contract: el deploy A deja de referenciar la columna, el deploy B la tira. Un RENAME es un DROP más un ADD.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tres partes, todas obligatorias: qué pasa, por qué pasa, qué hacer. Sin la tercera es una queja, no una regla.&lt;/p&gt;

&lt;h3&gt;
  
  
  La razón no es opcional
&lt;/h3&gt;

&lt;p&gt;Esta es la parte que la gente se salta, y saltársela es lo que hace que estos archivos se vuelvan superstición.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"Nunca hagas DROP COLUMN en una migración."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Esa regla nunca tira una columna. Ni en un proyecto de una sola instancia, ni en desarrollo. No tiene idea de dónde aplica, así que aplica en todos lados y se te atraviesa.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"Nunca hagas DROP COLUMN en un solo deploy, porque el rolling
 deploy tiene dos versiones del código sirviendo a la vez."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Esa sabe que un job de una sola instancia está bien, y se da cuenta por su cuenta de que un RENAME es un DROP disfrazado. La razón convierte una prohibición en un modelo mental, y un modelo mental generaliza a casos que nunca escribiste.&lt;/p&gt;

&lt;p&gt;La otra cosa que vale la pena decir: yo no escribí ese archivo. Dije "esto acaba de pasar, guárdalo para que no se repita" y el agente lo escribió. Escribir la memoria es trabajo del modelo. Decidir que algo merece una es tuyo, y la prueba es si te va a morder otra vez. Un typo no. Una propiedad del sistema que no está escrita en ningún lado, sí.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capa 3: cuando casi siempre no es suficiente
&lt;/h2&gt;

&lt;p&gt;Las capas uno y dos funcionan bien. Pero son instrucciones a un modelo, y un modelo tiene una tasa de olvido distinta de cero. Para la mayoría de las reglas esa tasa es irrelevante. Para unas cuantas no lo es.&lt;/p&gt;

&lt;p&gt;Tenía una regla de que cada funcionalidad visible al usuario se documenta en una guía que lee el lado no técnico del equipo. Vivió en &lt;code&gt;CLAUDE.md&lt;/code&gt; por seis meses y se seguía casi siempre. Casi siempre resultó significar que la guía iba tres funcionalidades atrasada y nadie lo notó hasta que alguien la necesitó.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;USER_VISIBLE_PATHS&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;
    &lt;span class="s1"&gt;'^backend/app/api/v1/admin/'&lt;/span&gt;
    &lt;span class="s1"&gt;'^backend/app/services/billing/'&lt;/span&gt;
    &lt;span class="s1"&gt;'^frontend/src/pages/admin/'&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# ¿tocó algo visible pero no la guía?&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STAGED&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qx&lt;/span&gt; &lt;span class="s1"&gt;'docs/team-guide.md'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"✗ commit bloqueado"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="c"&gt;# e imprime la salida de emergencia:&lt;/span&gt;
    &lt;span class="c"&gt;#   git commit --no-verify&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El bash no es la parte interesante. Dos decisiones de diseño lo son.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;El mensaje de error documenta la salida.&lt;/strong&gt; Un hook del que no te puedes salir obstruye el primer caso legítimo, y alguien lo desinstala esa semana. El mensaje está escrito para la persona que va a estar aquí a las once de la noche sin paciencia para acordarse de una bandera.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;La lista de rutas es deliberadamente conservadora.&lt;/strong&gt; Los falsos positivos se descartan con una bandera. Una funcionalidad visible saliendo en silencio no se descarta, se descubre meses después.&lt;/p&gt;

&lt;h3&gt;
  
  
  Elegir la capa
&lt;/h3&gt;

&lt;p&gt;No "cuál es la mejor". Cuánto cuesta la falla.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Si la regla&lt;/th&gt;
&lt;th&gt;Ponla en&lt;/th&gt;
&lt;th&gt;Porque&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Es una preferencia de estilo&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Un fallo ocasional no le hace daño a nadie&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vino de un incidente específico&lt;/td&gt;
&lt;td&gt;Memoria&lt;/td&gt;
&lt;td&gt;Necesita la razón y el alcance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Se descubre semanas después&lt;/td&gt;
&lt;td&gt;Hook&lt;/td&gt;
&lt;td&gt;Nadie va a revisar. Se acumula&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cuesta dinero o uptime&lt;/td&gt;
&lt;td&gt;Hook&lt;/td&gt;
&lt;td&gt;El fallo ocasional deja de ser aceptable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No tengo una tasa de falla medida para ninguna capa y no la voy a inventar. El argumento es costo esperado, no frecuencia. Es el mismo cálculo que ya usas para decidir si algo se lleva una prueba automatizada.&lt;/p&gt;

&lt;h2&gt;
  
  
  Una nota sobre los subagentes
&lt;/h2&gt;

&lt;p&gt;Adyacente, y muy malentendido: un subagente cuesta más tokens totales, no menos. Paga por su propio system prompt, su propia copia de &lt;code&gt;CLAUDE.md&lt;/code&gt; y sus propios schemas de herramientas, y luego su resumen te regresa como input.&lt;/p&gt;

&lt;p&gt;Lo que ahorras es ventana de contexto principal, que es un recurso distinto y a veces el que de verdad te falta. Vale la pena cuando la exploración abarca módulos que no conoces, o cuando las investigaciones pueden correr en paralelo.&lt;br&gt;
No vale la pena para una tarea que toca tres archivos que ya identificaste.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qué hacer el lunes
&lt;/h2&gt;

&lt;p&gt;Tres cosas, menos de una hora en total.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Abre tu &lt;code&gt;CLAUDE.md&lt;/code&gt; y pregúntale al agente qué es redundante.&lt;/strong&gt; "¿Qué en este archivo puedes deducir leyendo el repo?" Borra eso.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;La próxima vez que algo se rompa, archívalo mientras está caliente.&lt;/strong&gt; Antes de arreglarlo, con la razón. Es cuando lo tienes fresco y cuando menos ganas tienes de escribirlo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escoge una regla y conviértela en un hook.&lt;/strong&gt; La que duele si se resbala.  Una. Con su salida documentada en el mensaje de error.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;El modelo no recuerda. Tu repositorio sí.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Construyendo un recomendador de emparejamiento de expertos</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Mon, 24 Aug 2026 00:03:34 +0000</pubDate>
      <link>https://dev.to/aws-builders/construyendo-un-recomendador-de-emparejamiento-de-expertos-57h2</link>
      <guid>https://dev.to/aws-builders/construyendo-un-recomendador-de-emparejamiento-de-expertos-57h2</guid>
      <description>&lt;h2&gt;
  
  
  La forma del problema
&lt;/h2&gt;

&lt;p&gt;Un directorio es una superficie: el miembro lo abre y adivina. &lt;br&gt;
Un recomendador es una superficie de empujar: el sistema propone y tiene que justificarse. La justificación es la parte difícil, y es donde vive la estadística.&lt;/p&gt;

&lt;p&gt;Tres restricciones hicieron esto distinto de un recomendador de contenido:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;El item es una persona con capacidad finita.&lt;/strong&gt; Un hilo se le puede recomendar a diez mil personas. Un experto no.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Una mala recomendación es cara de los dos lados.&lt;/strong&gt; Quien pide desperdicia una petición, el experto desperdicia una hora, y los dos aprenden a ignorar la superficie.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;La afirmación tiene que ser checable.&lt;/strong&gt; "Quizá te guste este hilo" no necesita evidencia. "Esta persona está un nivel adelante de ti en diseño de sistemas" sí.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Recuperación: híbrida, fusionada con RRF
&lt;/h2&gt;

&lt;p&gt;Tres recuperadores independientes sobre el conjunto de expertos elegibles, fusionados con Reciprocal Rank Fusion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rrf_fuse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ranked_lists&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Fusiona listas de ids rankeadas. El score depende solo del rank, nunca
    de la escala propia del recuperador, que es el punto: la similitud coseno y
    un conteo de hilos resueltos no son números comparables.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;fused&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;lst&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ranked_lists&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lst&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;fused&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fused&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fused&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RRF es la primitiva correcta aquí por una razón que vale la pena decir: los recuperadores emiten cantidades incomparables. Uno regresa un coseno en &lt;code&gt;[-1, 1]&lt;/code&gt;, uno regresa un conteo entero de hilos resueltos, uno regresa un delta de nivel de escalera. Normalizarlos a una escala común requiere supuestos sobre sus distribuciones que nadie tiene a este volumen de datos. &lt;br&gt;
RRF descarta las magnitudes y se queda solo con el orden, que es exactamente la información que sobrevive a una muestra chica.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;k = 60&lt;/code&gt; es la constante estándar de la formulación original de Cormack et al.&lt;br&gt;
Aplana la cabeza: la diferencia entre el rank 1 y el rank 2 es&lt;br&gt;
&lt;code&gt;1/61 - 1/62 ≈ 0.00026&lt;/code&gt;, así que un recuperador no puede dominar por estar confiado, solo por estar consistentemente temprano a lo largo de las listas.&lt;/p&gt;
&lt;h2&gt;
  
  
  Scoring: un compuesto ponderado, con los pesos como datos
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SCORE_WEIGHTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;compass_gap_fit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# explícito y direccional
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;semantic_fit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="mf"&gt;0.20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# coseno del embedding de perfil
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;skill_overlap&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# tecnologías compartidas, ponderadas por rareza
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;capacity_fit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# también una compuerta dura
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expert_quality&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# rating, aceptación, experiencia que satura
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;specialty_match&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# burda, pero muy poblada
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fairness&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="mf"&gt;0.03&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# penalización de exposición amortiguada por log
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Dos componentes valen la pena desempacar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;expert_quality&lt;/code&gt; satura.&lt;/strong&gt; Un experto sin historial puntúa 0.5, no 0:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;expert_quality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;avg_rating&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;proposed&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;completed&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;proposed&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;                      &lt;span class="c1"&gt;# prior neutral, no cero
&lt;/span&gt;    &lt;span class="n"&gt;rating_part&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;avg_rating&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mf"&gt;4.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;4.0&lt;/span&gt;
    &lt;span class="n"&gt;acceptance&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;accepted&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;proposed&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;proposed&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;
    &lt;span class="n"&gt;experience&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;completed&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;5.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;rating_part&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;acceptance&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;experience&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La saturación exponencial sobre &lt;code&gt;experience&lt;/code&gt; codifica que la diferencia entre 0 y 5 sesiones completadas es grande y la diferencia entre 40 y 45 es ruido.&lt;br&gt;
Un término lineal habría hecho inalcanzables a los veteranos. El prior de 0.5 para los no probados es la decisión de &lt;em&gt;cold start&lt;/em&gt; que deja crecer el pool de expertos más allá de quien haya ido primero; sin él, el sistema es un loop de &lt;em&gt;rico se hace más rico&lt;/em&gt; por construcción.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;fairness&lt;/code&gt; es exposición amortiguada por log.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fairness_factor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;times_recommended&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;times_recommended&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ln(e + n)&lt;/code&gt; da exactamente 1.0 en &lt;code&gt;n = 0&lt;/code&gt; y decae lento. Una penalización lineal habría hecho irrecomendable a un buen experto después de un puñado de ciclos.&lt;/p&gt;

&lt;h2&gt;
  
  
  La parte que importa: esto es un problema de asignación
&lt;/h2&gt;

&lt;p&gt;El instinto es computar un top-N por cada quien pide. Ese instinto está mal, y el modo de falla no es sutil.&lt;/p&gt;

&lt;p&gt;Si cada quien pide escoge de forma independiente a su mejor experto, las mismas tres personas más fuertes juntan todas las peticiones. Son las que tienen los mejores ratings y el historial más profundo, así que ganan cada ranking, y dejan de contestar en un mes. El recomendador entonces destruye el recurso que existe para asignar.&lt;/p&gt;

&lt;p&gt;Así que los pares se puntúan, y luego se asignan de forma global bajo una restricción de capacidad por experto:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;allocate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;per_requester&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Asignación global greedy. Ordenada por score, cada par consume una unidad
    de la capacidad de su experto.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;assigned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;pair&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expert_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;assigned&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;requester_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;per_requester&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expert_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;assigned&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;requester_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Esta es la aproximación greedy a un matching bipartito con restricción de grado. A esta escala (cientos de pares) la solución óptima vía &lt;code&gt;scipy.optimize.linear_sum_assignment&lt;/code&gt; y la greedy difieren por ruido, y la versión greedy tiene una propiedad que la óptima no: es inspeccionable en un dry run, línea por línea, en orden de score. Cuando un operador pregunta "¿por qué esta persona obtuvo ese experto?", la respuesta es una sola pasada hacia abajo por una lista ordenada.&lt;/p&gt;

&lt;p&gt;A quienes piden que la asignación no puede colocar no se les tira. Por construcción son aquellos cuyos mejores expertos están llenos, lo que los hace el insumo exacto para el clustering uno-a-muchos: agrúpalos por celda de escalera y propón una sola sesión.&lt;/p&gt;

&lt;h2&gt;
  
  
  La capa de datos, y la estadística que la hace defendible
&lt;/h2&gt;

&lt;p&gt;La segunda capa responde "qué debería aprender después, y cuánto vale".&lt;br&gt;
Compara la mediana de datos ponderada por fuente de los puntos de datos que reportan una habilidad contra los que no.&lt;/p&gt;

&lt;p&gt;Esa oración contiene tres maneras de engañar a alguien. Las tres necesitaron una compuerta.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Ponderación por fuente con decaimiento exponencial por recencia
&lt;/h3&gt;

&lt;p&gt;No todos los puntos de datos merecen voto igual. Cada fuente carga un peso de confianza y una corrección de sesgo, y cada punto decae con la edad:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;base_weight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bias_correction_pct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;POWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EPOCH&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;sdp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scraped_at&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="n"&gt;halflife&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Una vida media de 365 días significa que una publicación de dos años todavía cuenta, a un cuarto del peso de una fresca. El &lt;code&gt;LEFT JOIN&lt;/code&gt; sobre la tabla de fuentes es deliberado: un punto de datos cuya fila de fuente nunca se registró cuenta en el default neutral de 0.50 en lugar de desvanecerse, porque tirar datos en silencio es peor que ponderarlos de forma conservadora.&lt;/p&gt;

&lt;p&gt;El agregado es una &lt;strong&gt;mediana ponderada&lt;/strong&gt;, no una media ponderada. Las&lt;br&gt;
distribuciones de datos están sesgadas a la derecha y la cola es donde viven los errores de scraping; una sola cifra mal parseada mueve una media y no mueve una mediana.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;weighted_median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;pairs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                       &lt;span class="c1"&gt;# todos los pesos en cero: degrada a mediana simple
&lt;/span&gt;        &lt;span class="n"&gt;mid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
        &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
                &lt;span class="nf"&gt;else &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;mid&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;half&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;half&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Estratificación, porque el número ingenuo mide antigüedad
&lt;/h3&gt;

&lt;p&gt;Este es el confusor que hace inútiles a la mayoría de las afirmaciones de "la habilidad X paga Y% más". La gente senior sabe más herramientas. Compara a todos los que reportan Kubernetes contra todos los que no, y una parte grande del delta es nada más antigüedad filtrándose por la comparación.&lt;/p&gt;

&lt;p&gt;Cada comparación por lo tanto pasa &lt;strong&gt;dentro&lt;/strong&gt; de un estrato &lt;code&gt;(rol, seniority, país, bucket de experiencia)&lt;/code&gt;. Los buckets son burdos a propósito (&lt;code&gt;0-2&lt;/code&gt;, &lt;code&gt;3-5&lt;/code&gt;, &lt;code&gt;6-9&lt;/code&gt;, &lt;code&gt;10+&lt;/code&gt;): estratos más finos matan de hambre a la muestra y el intervalo explota. Una habilidad cuyo efecto desaparece una vez estratificada se tira, no se reporta.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Un intervalo por bootstrap, no un estimado puntual
&lt;/h3&gt;

&lt;p&gt;Un estimado puntual se lee como una promesa. El bootstrap por percentil re muestrea las dos cohortes con reemplazo, recomputa las medianas ponderadas, y toma los percentiles 5/95 de la distribución de deltas resultante:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;bootstrap_ci&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;with_vals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;with_w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;without_vals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;without_w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.10&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;rng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Random&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SEED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# determinista: los mismos insumos deben
&lt;/span&gt;    &lt;span class="n"&gt;deltas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;                          &lt;span class="c1"&gt;# producir el mismo intervalo en cada corrida
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;with_vals&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;with_vals&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;without_vals&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;without_vals&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;med_a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;weighted_median&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;with_vals&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;with_w&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;med_b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;weighted_median&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;without_vals&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;without_w&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;med_b&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;deltas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;med_a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;med_b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;med_b&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;100.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;deltas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;deltas&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deltas&lt;/span&gt;&lt;span class="p"&gt;)))]&lt;/span&gt;
    &lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;deltas&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deltas&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deltas&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El bootstrap es la herramienta correcta porque la distribución muestral de una &lt;em&gt;mediana ponderada de una distribución sesgada&lt;/em&gt; no tiene una forma cerrada limpia. El re muestreo esquiva la derivación por completo.&lt;/p&gt;

&lt;p&gt;El RNG sembrado importa más de lo que parece. Un operador refrescando un dry run de admin no debe ver el número bambolearse; un intervalo de confianza que cambia al recargar es indistinguible de un bug.&lt;/p&gt;

&lt;p&gt;Una fila se surge solo si las tres se cumplen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;significant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ci_low&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;                  &lt;span class="c1"&gt;# el intervalo se queda de un lado del cero
&lt;/span&gt;               &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;premium&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;min_pct&lt;/span&gt;        &lt;span class="c1"&gt;# bastante grande para valer el tiempo de una persona
&lt;/span&gt;               &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;premium&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;claim_cap_pct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# no un outlier absurdo
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Las filas no significativas de todos modos se computan y se guardan. La tabla de admin muestra qué se rechazó y por qué, porque un número que el sistema se negó a usar es tan interesante como uno que usó.&lt;/p&gt;

&lt;h2&gt;
  
  
  La medición que reencuadró el proyecto
&lt;/h2&gt;

&lt;p&gt;Todo lo de arriba estaba en verde en CI. Luego la primera dry run contra producción:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;requesters&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;82  experts=1  scored_pairs=82  affinity=3  office_hours=0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;1 experto de 51.&lt;/strong&gt; La causa:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;requester_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;requesters&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;eligible_experts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;experts&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;open_load&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;requester_ids&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;-- esto
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La intención era "no emparejes a alguien consigo mismo". La implementación era "excluye a cualquier experto que sea también un candidato a pedir", y casi todos los expertos lo son, porque no tienen ninguna petición abierta propia.&lt;/p&gt;

&lt;p&gt;Medido:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Roles activos elegibles como experto:                51
...que se apuntaron:                                 51
...bajo el tope de carga:                            51
...excluidos por ser también quienes piden:             50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El único sobreviviente era la única persona que resultó estar a media&lt;br&gt;
interacción, y las tres propuestas apuntaban a ella. El sistema había&lt;br&gt;
encontrado el modo de falla de burnout por su cuenta, en la primera corrida, a través de una línea pensada para prevenir un problema completamente distinto.&lt;/p&gt;

&lt;p&gt;La guarda de auto emparejamiento ya existía por par, que es donde va.&lt;/p&gt;

&lt;p&gt;Dos hallazgos más de la misma corrida:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Las cards podían salir sin razón.&lt;/strong&gt; Solo 4 miembros tenían una colocación de escalera de competencias, así que el componente más pesado casi siempre era 0 y la señal sobreviviente era un coseno de embedding que nadie puede leer. Una propuesta tenía una lista de razones vacía. Una card que no puede decir por qué es peor que ninguna card.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;El badge de tendencia disparaba con todo.&lt;/strong&gt; La ingesta era reciente, así que casi cada publicación caía en la ventana de 30 días y la línea base de 90 días era una o dos filas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;golang   229 publicaciones  trend=2.00
go       155 publicaciones  trend=462.00
python   109 publicaciones  trend=62.40
aws       43 publicaciones  trend=61.50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Una razón necesita un denominador que valga la pena dividir. El arreglo es un conteo mínimo de línea base antes de que se afirme una tendencia siquiera, y una cota sobre la razón.&lt;/p&gt;

&lt;h2&gt;
  
  
  La lección de frecuencia inversa, aprendida tres veces
&lt;/h2&gt;

&lt;p&gt;Esta es la parte con el mayor valor de transferencia, porque la misma idea estadística tuvo que aplicarse en tres niveles distintos y cada nivel se veía bien hasta que se inspeccionó.&lt;/p&gt;

&lt;h3&gt;
  
  
  Nivel 1: lift, en el minero de adyacencia
&lt;/h3&gt;

&lt;p&gt;La adyacencia de habilidades es minería clásica de reglas de asociación sobre habilidades co-ocurrentes en publicaciones de vacantes. Para una regla&lt;br&gt;
&lt;code&gt;A -&amp;gt; B&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;soporte&lt;/strong&gt; = conteo de transacciones que contienen las dos&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;confianza&lt;/strong&gt; = &lt;code&gt;P(B | A)&lt;/code&gt; = soporte / conteo(A)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;lift&lt;/strong&gt; = &lt;code&gt;confianza / P(B)&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;La confianza sola es inútil, y la razón es instructiva. Linux co-ocurre con todo, así que &lt;code&gt;P(Linux | lo que sea)&lt;/code&gt; es alta y cada regla apunta a Linux. El lift divide por la tasa base: si saber A no sube la probabilidad de B por encima del azar, el lift es 1 y la regla no carga información.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;lift&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;singles&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;consequent&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lift&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;min_lift&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;# 1.15
&lt;/span&gt;    &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reglas que sobrevivieron sobre datos reales, ordenadas por &lt;code&gt;confianza * lift&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;laravel        -&amp;gt; php            conf=0.73  lift=11.33  support=8
terraform      -&amp;gt; kubernetes     conf=0.75  lift=9.25   support=9
rails          -&amp;gt; ruby           conf=0.78  lift=7.94   support=7
react native   -&amp;gt; react          conf=1.00  lift=5.48   support=7
gcp            -&amp;gt; aws            conf=0.73  lift=6.15   support=16
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Esas se leen bien para un humano, que es la única validación disponible a este tamaño de muestra.&lt;/p&gt;

&lt;p&gt;Una trampa que vale la pena registrar: las primeras pruebas unitarias de esto eran degeneradas. Un corpus donde el antecedente aparece en &lt;em&gt;cada&lt;/em&gt; transacción tiene lift 1.0 por construcción, así que nada puede ser significativo jamás.&lt;/p&gt;

&lt;p&gt;Los fixtures tuvieron que reescribirse para incluir publicaciones que no contuvieran ningún lado de la regla. Un corpus de mercado es diverso; un corpus de prueba tiene que serlo también, o no prueba nada.&lt;/p&gt;

&lt;h3&gt;
  
  
  Nivel 2: frecuencia de documento, en la señal de tema
&lt;/h3&gt;

&lt;p&gt;El riel salió y casi cada card decía:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Comparten temas: introduccion-plataforma.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;La distribución de frecuencia de tags lo explica:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;talento-tecnologico        27 hilos   50.0%
introduccion-profesional   17 hilos   31.5%
introduccion-plataforma    14 hilos   25.9%
espacio-relajacion          4 hilos    7.4%
servidores-caseros          4 hilos    7.4%
ai-local-vs-nube            2 hilos    3.7%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tres tags cubren la mitad de un corpus de 54 hilos, luego un acantilado a 7%.&lt;/p&gt;

&lt;p&gt;Este es el problema de Linux otra vez, un nivel arriba: un tag sobre la mitad del corpus no carga información sobre un par. Los tags por encima de 20% de frecuencia de documento se tiraron del scoring y del copy.&lt;/p&gt;

&lt;p&gt;Ese arreglo hizo el copy menos vergonzoso sin hacerlo significar nada, lo que lleva al tercer nivel.&lt;/p&gt;

&lt;h3&gt;
  
  
  Nivel 3: el vocabulario era la ontología equivocada
&lt;/h3&gt;

&lt;p&gt;Existían nueve tags en total. &lt;strong&gt;Ninguno de ellos era una tecnología.&lt;/strong&gt; &lt;br&gt;
Eran secciones del foro: un tablero de anuncios, dos áreas de introducción, un espacio fuera de tema. Ningún umbral de frecuencia puede rescatar una señal que está midiendo lo equivocado. Compartir "introducciones" significa que las dos personas se presentaron.&lt;/p&gt;

&lt;p&gt;La señal técnica que sí existía era el &lt;code&gt;tech_stack&lt;/code&gt; del perfil:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;python 11 · aws 8 · docker 7 · node.js 7 · typescript 7
git 6 · react 6 · fastapi 5 · kubernetes 3 · terraform 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flaca (20 de 83 miembros) pero real, y nombra cosas que un experto puede enseñar. El componente de tema se reemplazó por completo, normalizado por el menor de los dos stacks para que un stack de 30 items no pueda diluir un buen match de 3-de-4, y extendido a través de las reglas de adyacencia minadas para que quien pide sobre Python conecte con un experto sobre FastAPI con la regla como la justificación declarada.&lt;/p&gt;

&lt;p&gt;Y luego, predeciblemente, el nivel 3 tuvo su propio nivel:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;También trabaja con python.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Python está en 11 de 20 stacks. El mismo problema, tercera recursión. La resolución esta vez &lt;em&gt;no&lt;/em&gt; fue un filtro duro, porque a diferencia de una sección de foro, Python es una habilidad enseñable real y ponerla en cero descarta traslape genuino. &lt;/p&gt;

&lt;p&gt;Las habilidades ubicuas se &lt;strong&gt;bajan de peso a un quinto&lt;/strong&gt; en lugar de tirarse, el copy nombra las habilidades más raras&lt;br&gt;
primero, y un stack compartido solo justifica una card cuando al menos una habilidad compartida es poco común:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;weight_of&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;sk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;common_skills&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Una habilidad compartida rara ahora le gana a una ubicua 1.0 a 0.2, que es lo que pone a Terraform encima de Python en el ranking en lugar de debajo por accidente de quién lista más tecnologías.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;La generalización: cada vez que una señal es un traslape de conjuntos, pregúntate cuál es la tasa base de cada elemento antes de dejar hablar al traslape.&lt;/strong&gt; El lift, el IDF, y este bajado de peso de habilidades son la misma corrección con tres nombres distintos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Afinado de pesos como learning to rank offline
&lt;/h2&gt;

&lt;p&gt;Los pesos compuestos se guardan en config, no en código, y hay una dry run que propone un rebalanceo a partir de los resultados calificados de los propios miembros:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;correlations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;safe_corr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;per_key&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tres decisiones deliberadas alrededor de ella:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;La clase positiva es una sesión agendada, no un click.&lt;/strong&gt; Un click en un hilo es barato; convertir una propuesta en una sesión de verdad agendada no. &lt;/p&gt;

&lt;p&gt;Optimizar por clicks afinaría el ranking hacia la curiosidad en lugar de hacia sesiones que pasan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;La propuesta es una mezcla conservadora&lt;/strong&gt;, nunca el vector crudo derivado de la correlación:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;blended&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BLEND&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;corr_w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;BLEND&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
           &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Con &lt;code&gt;BLEND = 0.5&lt;/code&gt; y un piso de 30 muestras, una semana flaca no puede mover el ranking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nada se aplica automáticamente.&lt;/strong&gt; Un operador previsualiza y aplica de forma explícita. Auto aplicar una correlación computada sobre decenas de muestras es como un ranker oscila.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trampas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Trampa 1: la restricción de unicidad que creó duplicados.&lt;/strong&gt; Las filas son únicas por &lt;code&gt;(requester, expert, cycle_key)&lt;/code&gt;, lo cual es correcto por ciclo y mal a través de ellos. El cron semanal escribió un segundo ciclo encima del primero y el riel de inmediato se dobló:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;riel para &amp;lt;miembro&amp;gt;:
   &amp;lt;experto-a&amp;gt;  0.3997
   &amp;lt;experto-a&amp;gt;  0.3871   &amp;lt;--
   &amp;lt;experto-b&amp;gt;  0.3589
   &amp;lt;experto-b&amp;gt;  0.3462   &amp;lt;--
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Arreglado en dos lugares, porque cualquiera solo deja un hoyo: la lectura se colapsa con &lt;code&gt;DISTINCT ON (expert_id)&lt;/code&gt; quedándose con la mejor fila, y un ciclo nuevo reemplaza las propuestas sin tocar de los viejos para que la tabla no pueda crecer una card por persona por semana para siempre. Solo se tiran las filas no mostradas; cualquier cosa que un miembro vio es historial de embudo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trampa 2: el CTA que no hacía nada.&lt;/strong&gt; El riel se renderizaba dentro de la misma página a la que enlazaba su botón, así que el click actualizaba el query string y la página se quedaba ahí. Peor, el enlace no cargaba id de match, así que una petición enviada nunca podía reportar de vuelta y la máquina de estados nunca podía avanzar más allá de &lt;code&gt;CLICKED&lt;/code&gt;. La tasa de aceptación, la única métrica sobre la que se juzga el rollout y la que está cableada a la alarma, se habría leído como &lt;strong&gt;cero para siempre&lt;/strong&gt;. El botón muerto se habría&lt;br&gt;
reportado en un día; la métrica muerta se habría creído por semanas.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trampa 3: una auto-recomendación latente.&lt;/strong&gt; Cero filas en producción tenían &lt;code&gt;requester_id = expert_id&lt;/code&gt;, pero el clustering uno-a-muchos escogía su experto del pool completo sin excluir el cluster, y esas filas cargan al primer miembro del grupo como quien pide. Podía apuntarse a sí mismo. Simplemente nunca había&lt;br&gt;
disparado porque ningún cluster había alcanzado el umbral.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lecciones
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Mide contra datos de producción antes de confiar en un diseño.&lt;/strong&gt; La primera dry run contra filas reales invalidó más del diseño que todas las pruebas juntas. Córrela antes de que se emita nada, no después.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Un recomendador para un recurso finito es un problema de asignación.&lt;/strong&gt; El top-N por usuario es la forma equivocada y su modo de falla es destruir el lado de la oferta. La restricción de capacidad va en la asignación, no en un post filtro.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Chequea la tasa base de cualquier señal de traslape de conjuntos.&lt;/strong&gt; El lift, la frecuencia de documento y la rareza de habilidad son la misma corrección. Una señal compartida por la mitad de la población no carga información sobre un par, no importa qué tan cierta sea.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Antes de afinar un umbral, verifica el vocabulario.&lt;/strong&gt; Se gastaron dos rondas en cortes de frecuencia para un conjunto de tags que no contenía ninguna tecnología en absoluto. Pregúntate qué &lt;em&gt;son&lt;/em&gt; las etiquetas antes de preguntar qué tan comunes son.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Un intervalo de confianza es una decisión de producto, no un detalle de estadística.&lt;/strong&gt; El intervalo, el piso de muestra y la cota de efecto son lo que se para entre una correlación y una promesa que los datos no pueden cumplir. Muestra el rango, declara la muestra, y niégate a imprimir el número cuando las compuertas fallan.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Siembra el bootstrap.&lt;/strong&gt; Un número que cambia al recargar es indistinguible de un bug, y destruye la confianza del operador más rápido que estar mal una vez.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Instrumenta la conversión antes de publicar la superficie.&lt;/strong&gt; Un botón muerto se reporta en un día. Una métrica muerta se cree por un mes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Publica el motor a oscuras.&lt;/strong&gt; Emisión apagada por default y una dry run como la acción de admin por default significaron que cuatro bugs significativos se encontraron con datos reales y cero notificaciones enviadas.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>algorithms</category>
      <category>datascience</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Cómo mantener siempre disponibles las sesiones de tu agente de código remoto (parte 2)</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Wed, 19 Aug 2026 00:53:33 +0000</pubDate>
      <link>https://dev.to/aws-builders/mover-un-agente-de-ia-siempre-encendido-de-un-vps-de-24-a-fargate-spot-pt-2-o4f</link>
      <guid>https://dev.to/aws-builders/mover-un-agente-de-ia-siempre-encendido-de-un-vps-de-24-a-fargate-spot-pt-2-o4f</guid>
      <description>&lt;p&gt;La Parte 1 construyó una caja siempre encendida para una sesión de &lt;code&gt;remote-control&lt;/code&gt; de Claude Code y terminó con una alarma que convierte seis días de silencio en quince minutos. Esa alarma atrapa una caja &lt;em&gt;muerta&lt;/em&gt;. Esta es la secuela, y la caja de aquí nunca estuvo muerta. Estaba &lt;code&gt;active&lt;/code&gt;, su token estaba fresco, y en cada reinicio imprimía:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Take this session with you and pick up right where you left off on any device.
Open the Code tab in the Claude mobile app, or visit claude.ai/code in a browser.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;y la sesión no estaba en &lt;code&gt;claude.ai/code&lt;/code&gt;. Nunca se había registrado. El banner de arriba se imprime al arrancar, &lt;em&gt;antes&lt;/em&gt; de que haya pasado lo que anuncia, así que le mintió al monitoreo, me mintió a mí cuando lo "arreglé", y casi se mintió hasta meterse en este blog post como el arreglo. Este post es sobre la diferencia entre un proceso que dice que está listo y una sesión que de verdad lo está.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Lo que creía&lt;/th&gt;
&lt;th&gt;Lo que era cierto&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Causa raíz&lt;/td&gt;
&lt;td&gt;el registro se puso viejo a lo largo de ~6 días de uptime&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;nunca se registró&lt;/strong&gt;, el CLI se colgó en un prompt interactivo sin TTY&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;El "arreglo"&lt;/td&gt;
&lt;td&gt;reinicia el servicio; el banner regresa; el heartbeat en verde&lt;/td&gt;
&lt;td&gt;el reinicio imprimió el banner otra vez y aun así no se registró&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;La señal de salud que construí&lt;/td&gt;
&lt;td&gt;grep del journal por &lt;code&gt;claude.ai/code&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;ese string es el &lt;strong&gt;banner de pre-registro&lt;/strong&gt;, verde para un proceso colgado&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;La señal real&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;la &lt;strong&gt;URL registrada&lt;/strong&gt; (&lt;code&gt;claude.ai/code/session_…&lt;/code&gt;), que solo se imprime después del registro&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Placeholders, como en la parte 1: &lt;code&gt;myapp&lt;/code&gt;, &lt;code&gt;appuser&lt;/code&gt;, &lt;code&gt;acme/myapp&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  El síntoma
&lt;/h2&gt;

&lt;p&gt;La sesión simplemente no estaba en la lista de &lt;code&gt;claude.ai/code&lt;/code&gt;, mientras que todo en la caja decía que debería estar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;systemctl is-active claude-remote-control
&lt;span class="go"&gt;active
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; %y ~/.claude/.credentials.json
&lt;span class="gp"&gt;2026-07-28 02:54:11          #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;token refrescado hace horas, la auth está bien
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;aws cloudwatch describe-alarms &lt;span class="nt"&gt;--alarm-names&lt;/span&gt; myapp-agent-box-down &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    --query 'MetricAlarms[].StateValue' --output text
OK
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Así que hice lo obvio: reinicié el servicio. Imprimió el banner, el proceso subió, el heartbeat se puso verde, y la sesión seguía sin estar ahí. Ese es el momento en el que vale la pena detenerse. Cada señal que tenía decía sano, y el producto real (una sesión que puedo abrir desde un navegador) no existía.&lt;br&gt;
&lt;strong&gt;Un reinicio que produce un banner no es un reinicio que produce una&lt;br&gt;
sesión.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Conseguir una señal que no puede mentir
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;systemctl is-active&lt;/code&gt; reporta que el proceso existe. El banner reporta que el proceso &lt;em&gt;arrancó&lt;/em&gt;. Ninguno reporta que la caja se registró con el punto de encuentro hospedado, lo único que mete la sesión en la lista. Para ver eso, pídele al CLI que escriba su propio log de debug y lee lo que de verdad pasa:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude remote-control &lt;span class="nt"&gt;--name&lt;/span&gt; myapp-cloud &lt;span class="nt"&gt;--continue&lt;/span&gt; &lt;span class="nt"&gt;--debug-file&lt;/span&gt; /tmp/rc.log &lt;span class="nt"&gt;--verbose&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dos fallas cayeron de inmediato, y ninguna era "vieja":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Take this session with you and pick up right where you left off on any device.
Open the Code tab in the Claude mobile app, or visit claude.ai/code in a browser.
The session keeps running on this machine. ... Press Ctrl+C to stop.

Enable Remote Control? (y/n)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El proceso imprime el banner y luego &lt;strong&gt;se bloquea en un prompt interactivo&lt;/strong&gt;.&lt;br&gt;
Bajo systemd no hay TTY que lo conteste, así que espera ahí para siempre: &lt;code&gt;active&lt;/code&gt;, banner emitido, registro ni siquiera intentado. Los CLI recientes agregaron esta confirmación; la caja de la parte 1 es anterior a ella.&lt;/p&gt;

&lt;p&gt;Y cuando le di una &lt;code&gt;y&lt;/code&gt;, la segunda falla:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: No recent session found in this directory or its worktrees.
Run `claude remote-control` to start a new one.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--continue&lt;/code&gt;, la bandera de la parte 1 que mantiene el marcador estable reanudando la misma sesión, &lt;strong&gt;da error duro cuando no hay sesión que reanudar&lt;/strong&gt;. En una caja cuyo historial de sesión local se había borrado en una reconstrucción, &lt;code&gt;--continue&lt;/code&gt; no era una optimización; era un arranque que solo podía fallar.&lt;/p&gt;

&lt;h2&gt;
  
  
  El arreglo real: contesta el prompt, y no dependas de que una sesión exista
&lt;/h2&gt;

&lt;p&gt;Las dos fallas viven en una línea, así que el arreglo es una línea, un wrapper en &lt;code&gt;ExecStart&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/bin/bash -c 'cd /home/appuser/myapp; &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="s"&gt;echo y | /home/appuser/.local/bin/claude remote-control --name myapp-cloud --continue || &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="s"&gt;echo y | /home/appuser/.local/bin/claude remote-control --name myapp-cloud'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tres cosas se ganan su lugar:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;echo y&lt;/code&gt;&lt;/strong&gt; contesta el prompt. Importa que el proceso siga corriendo después de que su stdin se cierra (verificado, sí lo hace), así que un &lt;code&gt;echo&lt;/code&gt; de una sola vez basta; sin &lt;code&gt;yes&lt;/code&gt; inundando el pipe, sin &lt;code&gt;sleep infinity&lt;/code&gt; manteniéndolo abierto.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--continue || &amp;lt;nuevo&amp;gt;&lt;/code&gt;&lt;/strong&gt; trata de reanudar (marcador estable) y cae de vuelta a una sesión nueva cuando no hay nada que reanudar. Como &lt;code&gt;echo y&lt;/code&gt; cierra limpio, un &lt;code&gt;--continue&lt;/code&gt; fallido de verdad &lt;em&gt;regresa&lt;/em&gt; y corre el fallback; un &lt;code&gt;sleep infinity&lt;/code&gt; aquí colgaría el pipeline en el camino de falla y reintroduciría el atoro silencioso exacto.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sin &lt;code&gt;$@&lt;/code&gt; ni &lt;code&gt;$VAR&lt;/code&gt;&lt;/strong&gt; en &lt;code&gt;ExecStart&lt;/code&gt;. systemd expande &lt;code&gt;${}&lt;/code&gt; y &lt;code&gt;$VAR&lt;/code&gt; pelón él mismo, y mutila &lt;code&gt;$@&lt;/code&gt;; la jugada segura es repetir la ruta en lugar de factorizarla en una variable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ahora el log de debug muestra lo que el banner solo fingía:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[bridge:init] Resuming session session_01Pp8… on environment env_01Vp…
[bridge:init] Registered, server environmentId=env_01Vp…
[bridge:title] server title: myapp-cloud
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Registered&lt;/code&gt;. Ese es el recibo. El banner era el menú.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defensa en profundidad, todavía vale la pena conservarla
&lt;/h2&gt;

&lt;p&gt;El wrapper arregla el outage. Dos guardas del primer borrador de este trabajo se quedan, porque endurecen la caja contra &lt;em&gt;otras&lt;/em&gt; maneras en que la sesión puede caducar:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reciclado diario.&lt;/strong&gt; &lt;code&gt;RuntimeMaxSec=86400&lt;/code&gt; rebota la unidad cada 24h; &lt;code&gt;Restart=always&lt;/code&gt; la trae de vuelta y &lt;code&gt;--continue&lt;/code&gt; reanuda la misma sesión, así que el marcador aguanta. Si un registro alguna vez se pone viejo en un uptime largo, esto le pone tope al radio de impacto en un día en lugar de confiar en&lt;br&gt;
que viva para siempre. &lt;code&gt;TimeoutStopSec=20&lt;/code&gt; mantiene cada reciclado rápido, ya que el CLI ignora &lt;code&gt;SIGTERM&lt;/code&gt; por ~90s.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Escritura de vuelta de credenciales.&lt;/strong&gt; La parte 1 señaló que el CLI rota su refresh token y la captura del secreto se pudre. Un helper chico empuja el token vivo de vuelta a Secrets Manager en cada corrida del watchdog, protegiendo con fuerza contra escribir un archivo local malformado encima de un secreto bueno, para que una instancia &lt;em&gt;reemplazada&lt;/em&gt; ya no arranque sin autenticar.&lt;/p&gt;
&lt;h2&gt;
  
  
  El health check al que engañó el mismo banner
&lt;/h2&gt;

&lt;p&gt;Aquí está la parte que arde. La primera versión del chequeo de readiness, la que se suponía atrapaba justo este estado de "arriba pero sin registrar", hacía esto:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# MAL: hace match al banner de pre-registro, así que está verde para un proceso colgado&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; claude-remote-control &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;AGE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s ago"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"claude.ai/code"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;claude.ai/code&lt;/code&gt; está en el banner que se imprime &lt;em&gt;antes&lt;/em&gt; del prompt en el que el proceso estaba colgado. El chequeo que construí para detectar la falla quedaba satisfecho por la falla. Es el bug original vestido con el disfraz de su propio arreglo.&lt;/p&gt;

&lt;p&gt;La sesión registrada imprime una línea distinta, una URL con un id de sesión o de environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Continue coding in the Claude mobile app or https://claude.ai/code/session_01Pp8…
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El discriminador entre "anunciada" y "registrada" es un solo carácter: lo que sigue a &lt;code&gt;code&lt;/code&gt;. El banner dice &lt;code&gt;code in a browser&lt;/code&gt;; la cosa real dice &lt;code&gt;code/session_…&lt;/code&gt; o &lt;code&gt;code?environment=…&lt;/code&gt;. Así que haz match a eso:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# solo una sesión REGISTRADA imprime una URL con '/' o '?' después de 'code'&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; claude-remote-control &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;AGE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s ago"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qE&lt;/span&gt; &lt;span class="s1"&gt;'claude\.ai/code[/?]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Con el chequeo corregido el heartbeat significa lo que dice: active, lo bastante joven para haberse registrado, y una URL de registro en su propio log.&lt;br&gt;
Cualquier otra cosa degrada el latido a &lt;code&gt;DEGRADED&lt;/code&gt; y reinicia, y &lt;code&gt;DEGRADED&lt;/code&gt; no hace match al filtro &lt;code&gt;OK&lt;/code&gt; de la parte 1, así que un estado que no se va a auto-sanar de todos modos le suena a la alarma existente.&lt;/p&gt;

&lt;h2&gt;
  
  
  Callejones sin salida (los dos el mismo error)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Checar por una conexión de red abierta.&lt;/strong&gt; La sonda de readiness intuitiva:
seguramente una sesión registrada sostiene un socket al punto de encuentro.
Medido en la caja sana: cero sockets establecidos. Una sesión registrada
inactiva no mantiene ninguno. El chequeo reiniciaría una caja &lt;em&gt;funcionando&lt;/em&gt;
en cada tick.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grep del banner de arranque.&lt;/strong&gt; Cubierto arriba. Verde antes de que el
trabajo esté hecho.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Los dos fallan de la misma manera: afirman sobre algo que es cierto &lt;em&gt;antes&lt;/em&gt; de&lt;br&gt;
que pase la cosa real. Un socket que quizá todavía no esté ahí; un banner que&lt;br&gt;
está ahí demasiado pronto. El arreglo en los dos casos es afirmar sobre el&lt;br&gt;
artefacto que existe &lt;em&gt;solo después del éxito&lt;/em&gt;: la línea &lt;code&gt;Registered&lt;/code&gt;, la URL&lt;br&gt;
con un id.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lecciones
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Un banner es un menú, no un recibo.&lt;/strong&gt; Cualquier mensaje de "ya puedes
hacer X" impreso al arrancar es una intención, no una confirmación. Los
health checks tienen que indexarse en el artefacto que existe solo una vez
que X de verdad pasó.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reiniciar hasta que reaparezca el mensaje feliz no es verificación.&lt;/strong&gt; Vi
el banner, vi el heartbeat ponerse verde, y seguí adelante, dos veces. El
mensaje era el mismo las dos veces porque nunca dependió del éxito.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Los prompts interactivos son minas en cajas sin atender.&lt;/strong&gt; Una sola
compuerta &lt;code&gt;(y/n)&lt;/code&gt; nueva, incontestable sin un TTY, convierte en silencio un
servicio que funciona en uno colgado. Dale la respuesta explícitamente; no
asumas que un servicio hereda la de tu terminal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Las banderas de conveniencia tienen modos de falla.&lt;/strong&gt; &lt;code&gt;--continue&lt;/code&gt;
mantiene un marcador estable y falla duro cuando no hay nada que continuar.
En una caja que se puede reconstruir desde nada, "reanudar" necesita un "o
arranca de nuevo" al lado.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cuando no hay API de estado, afirma sobre el log de éxito, el
específico.&lt;/strong&gt; No el banner optimista. La línea que solo el camino exitoso
puede producir.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>agents</category>
      <category>aws</category>
      <category>devops</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Arquitectura de 10 agentes, 1k users, presupuesto: dos pizzas al mes</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Sun, 16 Aug 2026 23:36:58 +0000</pubDate>
      <link>https://dev.to/aws-builders/arquitectura-de-10-agentes-1k-users-presupuesto-dos-pizzas-al-mes-gjg</link>
      <guid>https://dev.to/aws-builders/arquitectura-de-10-agentes-1k-users-presupuesto-dos-pizzas-al-mes-gjg</guid>
      <description>&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://speakerdeck.com/elchesco/arquitectura-de-10-agentes-1k-users-presupuesto-dos-pizzas-al-mes" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Ffiles.speakerdeck.com%2Fpresentations%2F5b4afa7a49aa43a89c24d86b3e3f905b%2Fslide_0.jpg%3F40294994" height="1080" class="m-0" width="1920"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://speakerdeck.com/elchesco/arquitectura-de-10-agentes-1k-users-presupuesto-dos-pizzas-al-mes" rel="noopener noreferrer" class="c-link"&gt;
            Arquitectura de 10 agentes, 1k users, presupuesto: dos pizzas al mes - Speaker Deck
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Construir sistemas multi-agente no tiene que ser caro. En esta sesión comparto cómo diseñé una arquitectura con 10 agentes de IA sirviendo a más de 1,00&amp;amp;hellip;
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fd1eu30co0ohy4w.cloudfront.net%2Fassets%2Ffavicon-bdd5839d46040a50edf189174e6f7aacc8abb3aaecd56a4711cf00d820883f47.png" width="512" height="512"&gt;
          speakerdeck.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Este post es el acompañamiento de mi charla en el AWS Summit CDMX 2026 (DEV302). No es un truco de free tier ni una demo de juguete: son 8 o 9 decisiones de ingeniería, cada una medida, que convierten el camino default (unos 250 dólares al mes) en algo que cabe en dos pizzas. Voy a mostrarte los números y el código.&lt;/p&gt;

&lt;p&gt;Para que sea concreto uso un caso ficticio llamado &lt;strong&gt;Aula&lt;/strong&gt;, una plataforma de cursos en línea con unos 1,000 estudiantes. El conocimiento ya existe: dudas resueltas, material de cada lección, ejercicios. Diez agentes convierten ese conocimiento en respuestas fundamentadas en el historial. Aula no es un producto real, pero los números sí salen de un sistema real.&lt;/p&gt;

&lt;h2&gt;
  
  
  El caso: qué hacen los diez agentes
&lt;/h2&gt;

&lt;p&gt;Cinco flujos que ve el estudiante:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Una duda&lt;/strong&gt; ("no entiendo los closures"), que devuelve una explicación fundamentada en el historial.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Una entrega&lt;/strong&gt; (sube un ejercicio), que devuelve feedback editable, no la respuesta.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Qué sigue&lt;/strong&gt; ("¿qué estudio después?"), que devuelve la siguiente lección más un plan de 3 pasos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buscar&lt;/strong&gt; ("¿ya lo preguntaron?"), que devuelve dudas similares ya resueltas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resumen&lt;/strong&gt; de un hilo largo, que devuelve los puntos clave.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Diez agentes en total: classifier, extractor, retriever, enricher, synthesizer, drafter, summarizer, recommender, action_plan y quality_judge. Cada uno es una clase, no un framework. Ahí empieza la historia de costo.&lt;/p&gt;

&lt;h2&gt;
  
  
  El camino default cuesta 22 pizzas
&lt;/h2&gt;

&lt;p&gt;Si sigues el tutorial que la mayoría encuentra, armas esto: Amazon Bedrock Agents, AgentCore, Amazon OpenSearch Serverless, un NAT Gateway, un Application Load Balancer, Amazon RDS Proxy y AWS X-Ray. Suma unos 250 dólares al mes. Diecisiete pizzas solo de piso fijo, antes de la primera petición.&lt;/p&gt;

&lt;p&gt;El reto no es recortar. Recortar es fácil. El reto es sostener el mismo sistema, en producción, seguro, manejable y escalable, gastando lo que dos pizzas. Voy a cotizar cada decisión en pizzas que se comería de tu factura. Una rebanada son unos 1.75 dólares (unos 30 pesos). Una pizza son 8 rebanadas, unos 14 dólares. El tope: dos pizzas, 16 rebanadas, unos 28 dólares al mes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decisión 1: un modelo por tarea (el 90/10)
&lt;/h2&gt;

&lt;p&gt;La palanca más grande, y la que no te vende nadie, es el tier por agente. No todos los pasos necesitan el modelo premium.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;7 agentes en &lt;strong&gt;Amazon Nova Lite&lt;/strong&gt; (clasificar, extraer, condensar, recomendar, evaluar).&lt;/li&gt;
&lt;li&gt;2 agentes en &lt;strong&gt;Amazon Nova Pro&lt;/strong&gt;, solo los que producen lo que LEE el estudiante (synthesizer y drafter).&lt;/li&gt;
&lt;li&gt;1 agente con &lt;strong&gt;Amazon Titan Embeddings&lt;/strong&gt; (el retriever, que no es un LLM de chat).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;El resultado medido: el 90% de las llamadas al modelo van al tier económico, y el costo baja a unos 0.004 dólares por duda. Si todo corriera en Nova Pro, sería cerca de 10 veces más.&lt;/p&gt;

&lt;p&gt;¿Y la calidad? No es fe. Un shadow run midió que Nova Lite clasifica con un 98.5% de acuerdo contra Nova Pro en esa tarea. El tier se elige por dificultad, no por default.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# El tier lo fija el agente, no el framework.
&lt;/span&gt;&lt;span class="n"&gt;MODEL_LITE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amazon.nova-lite-v1:0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# 7 agentes
&lt;/span&gt;&lt;span class="n"&gt;MODEL_PRO&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amazon.nova-pro-v1:0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;    &lt;span class="c1"&gt;# 2 agentes: lo que LEE el estudiante
&lt;/span&gt;&lt;span class="n"&gt;MODEL_EMB&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amazon.titan-embed-text-v2:0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# 1 agente: retrieval
&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DrafterAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseAgent&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default_model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;drafter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MODEL_PRO&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Ver &lt;a href="//code/base_agent.py"&gt;&lt;code&gt;code/base_agent.py&lt;/code&gt;&lt;/a&gt; y &lt;a href="//code/tier_routing.py"&gt;&lt;code&gt;code/tier_routing.py&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Decisión 2: mata el piso fijo (ingreso y red)
&lt;/h2&gt;

&lt;p&gt;Dos ausencias que casi nadie cuestiona, y que juntas valen casi 5 pizzas que no pago.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Amazon API Gateway, no Application Load Balancer.&lt;/strong&gt; El ALB cuesta unos 16 dólares al mes fijos, pizza y media, llegue 1 persona o un millón. Amazon API Gateway HTTP API cobra por request: a 1,000 usuarios con tráfico modesto, cerca de 1.5 dólares al mes. Llega al contenedor por VPC Link y service discovery, sin ALB de por medio. ¿Cuándo sí ALB? Routing L7 sofisticado, WebSockets, o miles de peticiones por segundo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AWS Fargate en subnet pública, no NAT Gateway.&lt;/strong&gt; El NAT cuesta unos 33 dólares al mes fijos (3 pizzas) aunque el tráfico sea cero. Lo mío: subnet pública con IP pública y un security group que solo deja entrar al VPC Link. La IP es pública pero funcionalmente inalcanzable. Cuidado con el consejo de "usa VPC endpoints": 4 o 5 endpoints a 7 dólares cada uno salen más caros que el NAT. Valen por compliance, no por ahorro.&lt;/p&gt;
&lt;h2&gt;
  
  
  Decisión 3: usa lo que ya pagas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;pgvector, no Amazon OpenSearch Service.&lt;/strong&gt; A escala de miles de embeddings, pgvector basta y sobra. El menú arranca en OpenSearch a 174 dólares o más, entre 8 y 15 veces mi presupuesto, en una sola línea. Lo mío: &lt;code&gt;CREATE EXTENSION vector&lt;/code&gt; en la misma Amazon RDS que ya pago. La pregunta correcta no es si pgvector es tan bueno, es si necesito lo que OpenSearch hace de más. A 3,000 vectores, no.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- El retriever: distancia coseno con índice HNSW, prefiltrando por curso.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;contenido&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resolucion&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;dudas_embedding&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;curso_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;curso&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Tus filas ya son la memoria.&lt;/strong&gt; AgentCore Memory ofrece memoria semántica gestionada. Pero la memoria de Aula, qué respuestas sirvieron por tema, ya está en filas de Postgres. Leer 20 filas, agruparlas y meterlas al prompt como un bloque de historial son unas 120 líneas y 50 milisegundos. No gana por económico, gana por simplicidad y por no necesitar código nuevo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Workers asyncio, no AWS Lambda por tarea.&lt;/strong&gt; Los trabajos programados (reindex, resumen semanal, barrido de evaluaciones, recordatorios) corren como tareas asyncio dentro del contenedor de AWS Fargate que ya está prendido para la API. El break point honesto: con más de una réplica, un cron in process dispara doble, y ahí sí subes a Amazon EventBridge Scheduler apuntando a ECS RunTask (cerca de 0 dólares). Managed cuando el patrón lo justifica, no por default.&lt;/p&gt;
&lt;h2&gt;
  
  
  Decisión 4: observabilidad por log, no por traza
&lt;/h2&gt;

&lt;p&gt;Latencia, tokens y costo por agente salen de &lt;strong&gt;una línea de log en formato EMF&lt;/strong&gt; (Embedded Metric Format), que Amazon CloudWatch convierte en métrica sin ninguna llamada a la API en el hot path. AWS X-Ray a bajo volumen es casi gratis (unos 0.40 dólares), así que aquí el argumento no es el costo, es el acoplamiento: EMF no necesita SDK ni escala por traza, y tú decides qué métricas existen.&lt;/p&gt;

&lt;p&gt;La trampa que sí cuesta dinero: meter un ID de alta cardinalidad (como &lt;code&gt;student_id&lt;/code&gt;) como dimensión son miles de métricas custom, cientos de dólares al mes. Los IDs van al cuerpo del log, nunca a la dimensión.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 1 línea EMF -&amp;gt; métrica en CloudWatch, sin PutMetricData.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;emit_emf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;latency_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cost_usd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_aws&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CloudWatchMetrics&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Namespace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aula/Agents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Dimensions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AgentName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ModelUsed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Metrics&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LatencyMs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CostUsd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
        &lt;span class="p"&gt;}]},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AgentName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ModelUsed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LatencyMs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;latency_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CostUsd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cost_usd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Ver &lt;a href="//code/emf.py"&gt;&lt;code&gt;code/emf.py&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Decisión 5: seguridad y resiliencia sin inflar la factura
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Seguridad.&lt;/strong&gt; El input del usuario entra a un LLM, así que va como dato delimitado, no como instrucción. Salida estructurada más el juez como respaldo. IAM al mínimo: &lt;code&gt;bedrock:InvokeModel&lt;/code&gt; solo sobre los ARNs de mis 2 modelos, no un comodín. Guardrails gestionados solo a escala (PII, toxicidad) o por compliance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resiliencia.&lt;/strong&gt; El estudiante espera unos 2 segundos. Amazon Bedrock puede responder 429 en picos. La respuesta: retry con backoff acotado por el presupuesto de latencia (2 intentos máximo) y fallback de tier. Si Nova Pro está throttled, degrada a Nova Lite con la confianza más baja. Degrada, no revienta. Provisioned Throughput solo si el 429 es crónico, no para un pico.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;invoke_resilient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;primary&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MODEL_PRO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fallback&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MODEL_LITE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;budget_ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2500&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout_ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;budget_ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ThrottlingException&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;jitter&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="c1"&gt;# sigue throttled: degrada, no revienta
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;confidence_penalty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Ver &lt;a href="//code/resilience.py"&gt;&lt;code&gt;code/resilience.py&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Dónde se va el costo, de verdad
&lt;/h2&gt;

&lt;p&gt;Aquí está lo bonito de medir con EMF: puedes ver dónde vive cada dólar sin instrumentar nada extra.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concepto&lt;/th&gt;
&lt;th&gt;Rebanadas&lt;/th&gt;
&lt;th&gt;Costo aprox&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AWS Fargate ×2, 24/7&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;22 dólares&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon Bedrock (10 agentes)&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;3 dólares&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EMF (métricas por log)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2 dólares&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon API Gateway&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1 dólar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~28 dólares&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;El 72% de la factura son los dos contenedores de AWS Fargate prendidos todo el tiempo. Todo lo demás junto son 5 rebanadas. La lección: &lt;strong&gt;el costo fijo que no usas es tu mayor fuga&lt;/strong&gt;, y aquí ya está exprimido.&lt;/p&gt;
&lt;h2&gt;
  
  
  La parte honesta
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Redondeo a 2 pizzas, pero son unos 2 y cuarto. El redondeo lo absorbe.&lt;/li&gt;
&lt;li&gt;Las cifras del core (AWS Fargate, Amazon Bedrock, EMF) son medidas. Las de tool use en Aula son estimadas.&lt;/li&gt;
&lt;li&gt;El costo real de este sistema no es dinero, es tiempo de ingeniería y seguridad. Medir cada decisión toma más trabajo que aceptar el default.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Lo contraintuitivo: baja al crecer
&lt;/h2&gt;

&lt;p&gt;Costo por estudiante al mes, a 1,000 estudiantes: unos 2 centavos.&lt;/p&gt;

&lt;p&gt;Como la factura es costo fijo (los contenedores no escalan con el uso), se reparte. Más estudiantes, menos por cabeza. Es lo opuesto a una arquitectura lineal por request. A 10 usuarios sale carísimo por cabeza; a 1,000 sale a 2 centavos, y sigue bajando.&lt;/p&gt;
&lt;h2&gt;
  
  
  Para llevar
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;El piso fijo que no usas es tu mayor fuga: NAT, ALB, OCU.&lt;/li&gt;
&lt;li&gt;Usa lo que ya pagas: pgvector, asyncio, filas como memoria.&lt;/li&gt;
&lt;li&gt;Haz coincidir el tier del modelo con la dificultad de la tarea (el 90/10).&lt;/li&gt;
&lt;li&gt;El patrón de uso elige la herramienta, no el default.&lt;/li&gt;
&lt;li&gt;La pregunta correcta no es "¿uso managed?", es "¿qué NO quiero mantener?".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Las slides, el código y las notas están en el repositorio. Si vas a construir agentes sobre Amazon Bedrock y el presupuesto importa, empieza por medir dónde se va cada dólar. Casi siempre es una sola cosa.&lt;/p&gt;

&lt;p&gt;Demo: &lt;a href="https://awssummitcdmx2026.elches.co/" rel="noopener noreferrer"&gt;https://awssummitcdmx2026.elches.co/&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/elchesco" rel="noopener noreferrer"&gt;
        elchesco
      &lt;/a&gt; / &lt;a href="https://github.com/elchesco/diez-agentes-dos-pizzas" rel="noopener noreferrer"&gt;
        diez-agentes-dos-pizzas
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Diez agentes en producción sobre Amazon Bedrock por el costo de dos pizzas al mes. Código de acompañamiento (AWS Summit CDMX 2026, DEV302).
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Diez agentes en producción por el costo de dos pizzas al mes&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;Código de acompañamiento de la charla &lt;strong&gt;AWS Summit CDMX 2026 (DEV302)&lt;/strong&gt;: un sistema multi agente en producción sobre Amazon Bedrock, con Q&amp;amp;A, feedback de ejercicios, recomendaciones, búsqueda y resumen, para unos 1,000 usuarios, por unos &lt;strong&gt;28 dólares al mes&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Post: &lt;a href="https://dev.to/aws-builders/arquitectura-de-10-agentes-1k-users-presupuesto-dos-pizzas-al-mes-gjg" rel="nofollow"&gt;Arquitectura de 10 agentes, 1k users, presupuesto: dos pizzas al mes&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Slides: &lt;a href="https://speakerdeck.com/elchesco/arquitectura-de-10-agentes-1k-users-presupuesto-dos-pizzas-al-mes" rel="nofollow noopener noreferrer"&gt;Speaker Deck&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;El caso es &lt;strong&gt;ficticio&lt;/strong&gt;: &lt;em&gt;Aula&lt;/em&gt;, una plataforma de cursos en línea con ~1,000 estudiantes. No es un producto real. Los números (latencia, costo por duda, picos de conexiones) salen de un sistema real. Los snippets son &lt;strong&gt;ilustrativos&lt;/strong&gt;: muestran cada decisión de costo, no son un proyecto ejecutable completo.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Las decisiones, en código&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;Cada archivo es una decisión de la charla. Los marcados con 🔒 estaban en slides que oculté por tiempo, pero el código va aquí completo.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;div class="table-wrapper-paragraph"&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;table&gt;

&lt;thead&gt;

&lt;tr&gt;

&lt;th&gt;Archivo&lt;/th&gt;

&lt;th&gt;Decisión&lt;/th&gt;

&lt;/tr&gt;

&lt;/thead&gt;

&lt;/table&gt;&lt;/div&gt;…&lt;p&gt;&lt;/p&gt;&lt;/div&gt;
&lt;br&gt;
  &lt;/div&gt;
&lt;br&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/elchesco/diez-agentes-dos-pizzas" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;br&gt;
&lt;/div&gt;
&lt;br&gt;


&lt;p&gt;Gracias por leer. Preguntas y correcciones son bienvenidas.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>architecture</category>
      <category>aws</category>
    </item>
    <item>
      <title>¿Cómo revertir una migración promovida a Aurora sin perder los datos nuevos?</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Sat, 25 Jul 2026 22:56:24 +0000</pubDate>
      <link>https://dev.to/aws-builders/como-revertir-una-migracion-promovida-a-aurora-sin-perder-los-datos-nuevos-3gcn</link>
      <guid>https://dev.to/aws-builders/como-revertir-una-migracion-promovida-a-aurora-sin-perder-los-datos-nuevos-3gcn</guid>
      <description>&lt;p&gt;Migraste una base de datos MySQL de Amazon RDS a Amazon Aurora. La read replica de Aurora se puso al día, la promoviste, cambiaste el endpoint de la app, y ahora los writes aterrizan en Aurora. &lt;/p&gt;

&lt;p&gt;Diez minutos después, o quizá tres horas después, algo anda mal y quieres regresar a la vieja instancia de RDS.&lt;/p&gt;

&lt;p&gt;Aquí está la trampa. La vieja instancia de RDS está congelada en el momento en que hiciste el corte. Cada pedido, registro y edición que pegó en Aurora desde entonces no existe en RDS. "Revertir" de forma ingenua significa "tirar a la basura todo lo que pasó en la base de datos nueva". Y el arreglo de siempre, tomar una ventana de mantenimiento y recargar RDS desde Aurora, no escala: a 100 TB una recarga se mide en días, no en minutos.&lt;/p&gt;

&lt;p&gt;Esta es la historia de construir una reversión que conserva los datos&lt;br&gt;
nuevos, probarla en AWS real hasta que se rompió de maneras interesantes, y reducirla a un manual de operaciones que una persona cansada pueda seguir a las 3 de la mañana. Todo lo de abajo lo corrí en instancias desechables en una región aislada y las borré después. &lt;/p&gt;
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;La vieja instancia de RDS no debería ser un backup congelado. Hazla una &lt;strong&gt;réplica inversa viva&lt;/strong&gt; de Aurora: después de promover Aurora, apunta RDS de vuelta a Aurora sobre replicación por binary-log para que siga tragándose cada write nuevo. La reversión es entonces "déjala terminar, luego cambia el endpoint".&lt;/li&gt;
&lt;li&gt;Con la réplica inversa mantenida caliente, &lt;strong&gt;el tiempo de reversión es constante e independiente del tamaño de la base de datos&lt;/strong&gt;: como 1 a 5 minutos a 1 GB y a 100 TB, porque haces el corte del delta, no de la base de datos.&lt;/li&gt;
&lt;li&gt;La única compuerta que importa es &lt;code&gt;Seconds_Behind_Source = 0&lt;/code&gt;. Llegar ahí significa que RDS tiene el 100% de los writes de la era Aurora. Hacer el corte antes pierde datos.&lt;/li&gt;
&lt;li&gt;Dos rutas funcionan: replicación nativa por binlog (recomendada, barata, replica DDL) y AWS DMS (más manejada por consola, pero ignora en silencio &lt;code&gt;DROP TABLE&lt;/code&gt; y unos cuantos ALTER). Las dos las validé en vivo.&lt;/li&gt;
&lt;li&gt;Un "standby frío vacío" (una instancia lista pero sin datos) cuesta lo &lt;strong&gt;mismo por hora&lt;/strong&gt; que una réplica caliente y te da la velocidad de reversión de no tener nada. Si pagas por mantener una caja corriendo, mantenla caliente.&lt;/li&gt;
&lt;li&gt;Los filos que costaron tiempo real de debug: una regla de security group que falla en silencio, una edición de parameter group que es todo-o-nada, un stored procedure cuyo conteo de argumentos no es lo que los docs implican, y una regla de compatibilidad de versión que bloquea la migración de plano.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  La forma del problema
&lt;/h2&gt;

&lt;p&gt;La migración hacia adelante de RDS a Aurora es una cosa resuelta de un solo botón: creas una read replica de Aurora desde la instancia de RDS, esperas, promueves. AWS hace el snapshot y convierte por ti. Esa parte la tomo como dada.&lt;/p&gt;

&lt;p&gt;La dirección interesante es hacia atrás. Una vez que Aurora es primaria y toma writes, la vieja instancia de RDS se va quedando más desactualizada cada segundo. Una reversión que importa tiene que responder una pregunta: &lt;strong&gt;¿a dónde van los writes de la era Aurora?&lt;/strong&gt; Si la respuesta es "a ningún lado, los perdemos", no es una reversión, es un desastre con pasos extra.&lt;/p&gt;

&lt;p&gt;La respuesta que escala es invertir la replicación. Cuando RDS era la fuente y Aurora la réplica, los datos fluían de RDS a Aurora. Después de la promoción lo configuras al revés: Aurora es la fuente, RDS es la réplica.&lt;br&gt;
Ahora RDS jala de forma continua todo lo que Aurora escribe. La vieja&lt;br&gt;
instancia no está congelada, es un espejo caliente que siempre va un segundo o dos atrás de Aurora. Revertir se vuelve: detén Aurora, deja que RDS termine el último segundo, cambia el endpoint.&lt;/p&gt;

&lt;p&gt;La parte elegante es el costo de la sincronización inicial. En el instante de la promoción, RDS ya es byte por byte igual a Aurora, porque &lt;em&gt;era&lt;/em&gt; la fuente. Así que el enlace inverso no re-copia la base de datos. La siembras en la coordenada de la promoción y de ahí en adelante solo carga el delta.&lt;br&gt;
Por eso el tamaño de la base de datos deja de importar.&lt;/p&gt;
&lt;h2&gt;
  
  
  Construyéndolo, y viéndolo romperse
&lt;/h2&gt;

&lt;p&gt;Levanté el mundo post corte en instancias desechables: un clúster de Aurora MySQL como "prod nuevo", una instancia de RDS for MySQL como el objetivo de reversión, en una región aislada. Luego cableé la replicación inversa y la empujé por el camino feliz y por los caminos de falla.&lt;/p&gt;
&lt;h3&gt;
  
  
  Callejón sin salida 1: el security group que falla en silencio
&lt;/h3&gt;

&lt;p&gt;Primer intento de arrancar la replicación, la réplica nada más se quedó ahí sentada:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Replica_IO_Running: Connecting
Source_Server_Id: 0
Last_IO_Errno: 0
Last_IO_Error:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Connecting&lt;/code&gt;, para siempre. Sin número de error, sin texto de error. El hilo de IO estaba tratando de alcanzar Aurora y no recibía nada, pero nada se reportaba. La causa: las dos bases de datos eran públicamente accesibles (para que mi cliente de SQL las alcanzara), lo que significaba que la réplica resolvía el endpoint de Aurora a su IP &lt;strong&gt;pública&lt;/strong&gt;. La conexión de replicación entonces llegaba a Aurora desde la IP pública de la réplica, que el security group no permitía. Un drop silencioso, presentado como un esperanzado &lt;code&gt;Connecting&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;La lección no es "abre la IP pública". Es lo contrario. En producción, mantén las dos bases de datos privadas. Entonces el tráfico de réplica a fuente fluye sobre IPs privadas dentro de la VPC, y una sola regla de autorreferencia del security group lo cubre. La accesibilidad pública fue lo que creó el problema.&lt;/p&gt;

&lt;h3&gt;
  
  
  El camino feliz
&lt;/h3&gt;

&lt;p&gt;Con la red arreglada, la mecánica funcionó exactamente como se anunciaba.&lt;br&gt;
Escribe dos filas a Aurora, y en unos segundos aparecen en RDS. Detén el enlace, drena a cero lag, corre los dos procedimientos de reversión, y RDS se vuelve una primaria independiente que acepta writes mientras Aurora queda desacoplada. Cero datos perdidos. La secuencia completa está en el manual de operaciones al final.&lt;/p&gt;
&lt;h3&gt;
  
  
  Callejón sin salida 2: revertir demasiado pronto pierde exactamente lo que crees
&lt;/h3&gt;

&lt;p&gt;Todo el diseño descansa en una sola disciplina, así que me obligué a verla fallar. Detuve la réplica, escribí dos filas más a Aurora, y luego "reverti" sin esperar a que la réplica se pusiera al día.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Aurora: 7 filas.&lt;/li&gt;
&lt;li&gt;RDS después de revertir: 5 filas.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Las dos filas escritas durante la ventana se fueron. Esta es toda la razón por la que &lt;code&gt;Seconds_Behind_Source = 0&lt;/code&gt; es una compuerta dura y no una sugerencia. La reversión es sin pérdidas solo si congelas los writes en la fuente y dejas que la réplica drene a cero &lt;em&gt;antes&lt;/em&gt; de hacer el corte.&lt;/p&gt;
&lt;h3&gt;
  
  
  Callejón sin salida 3: qué rompe de verdad la réplica
&lt;/h3&gt;

&lt;p&gt;Esperaba que los writes divergentes en el objetivo rompieran la replicación ruidosamente. No lo hicieron. Las read replicas de RDS traen por default &lt;code&gt;slave_exec_mode = IDEMPOTENT&lt;/code&gt;, que en silencio hace que la fuente gane un conflicto de primary key en lugar de dar error. Así que un write perdido en el objetivo de reversión se sobreescribe calladito, no se marca.&lt;/p&gt;

&lt;p&gt;Lo que &lt;em&gt;sí&lt;/em&gt; la rompe es el schema drift. Tiré una columna en el objetivo, luego escribí una fila en la fuente que usaba esa columna. El aplicador de SQL se detuvo en seco:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Last_SQL_Errno: 13146
Replica_SQL_Running: No
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La réplica se congeló y se quedó atrás en silencio, todavía reportando un hilo de IO sano. La conclusión operativa: alarma sobre que &lt;code&gt;Replica_SQL_Running&lt;/code&gt; voltee a &lt;code&gt;No&lt;/code&gt;, no solo sobre el lag. Y durante la ventana de standby caliente, trata el objetivo como estrictamente solo lectura y congela el DDL en la fuente.&lt;/p&gt;

&lt;h3&gt;
  
  
  Callejón sin salida 4: el conteo de argumentos que los docs implican está mal
&lt;/h3&gt;

&lt;p&gt;La variante GTID es la que quieres en producción, porque sobrevive a un failover de Aurora. Al configurarla, el stored procedure rechazó mi llamada:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;ERROR&lt;/span&gt; &lt;span class="mi"&gt;1318&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;Incorrect&lt;/span&gt; &lt;span class="n"&gt;number&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="n"&gt;arguments&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;PROCEDURE&lt;/span&gt;
&lt;span class="n"&gt;mysql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rds_set_external_master_with_auto_position&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El procedimiento toma seis argumentos: host, port, user, password,&lt;br&gt;
&lt;code&gt;ssl_encryption&lt;/code&gt;, y &lt;code&gt;delay&lt;/code&gt;. Leyendo ejemplos que omiten el &lt;code&gt;delay&lt;/code&gt; final, es fácil pasar cinco. Trampa relacionada: el tunable se llama &lt;code&gt;gtid-mode&lt;/code&gt; con guion en el parameter group, mientras que &lt;code&gt;gtid_mode&lt;/code&gt; con guion bajo es una vista de runtime de solo-lectura que rechaza modificaciones.&lt;/p&gt;
&lt;h3&gt;
  
  
  Callejón sin salida 5: la edición del parameter group que se aplicó a medias
&lt;/h3&gt;

&lt;p&gt;Esta costó el más tiempo y produjo el síntoma más confuso. Después de&lt;br&gt;
habilitar GTID, la replicación no arrancaba:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Got fatal error 1236 from source when reading data from binary log:
'Binary log is not open'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;En Aurora, &lt;code&gt;@@gtid_mode&lt;/code&gt; era &lt;code&gt;ON&lt;/code&gt;, pero &lt;code&gt;@@log_bin&lt;/code&gt; era &lt;code&gt;0&lt;/code&gt;. El binary logging estaba apagado, así que no había nada que replicar. La causa: había puesto &lt;code&gt;binlog_format=ROW&lt;/code&gt; y dos parámetros de GTID en una sola edición del parameter group, y uno de los nombres de parámetro estaba mal. &lt;strong&gt;Un modify de parameter group es todo o nada.&lt;/strong&gt; Toda la edición se rechazó, lo que en silencio dejó &lt;code&gt;binlog_format&lt;/code&gt; en su default &lt;code&gt;OFF&lt;/code&gt;. Los parámetros de GTID, puestos en una llamada posterior exitosa, estaban bien, que es por lo que el&lt;br&gt;
estado se veía a medio arreglar.&lt;/p&gt;

&lt;p&gt;El arreglo: pon &lt;code&gt;binlog_format=ROW&lt;/code&gt;, reinicia el writer de Aurora, y&lt;br&gt;
verifica &lt;code&gt;@@log_bin = 1&lt;/code&gt; y un &lt;code&gt;SHOW MASTER STATUS&lt;/code&gt; no vacío &lt;em&gt;antes&lt;/em&gt; de cablear la réplica. Aurora necesita el reinicio para de verdad abrir el binary log.&lt;/p&gt;

&lt;p&gt;Una vez que eso quedó bien, el auto-posicionamiento de GTID funcionó de principio a fin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Auto_Position: 1
Replica_IO_Running: Yes
Replica_SQL_Running: Yes
Seconds_Behind_Source: 0
Retrieved_Gtid_Set: &amp;lt;aurora-uuid&amp;gt;:1-3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  La segunda ruta: AWS DMS
&lt;/h2&gt;

&lt;p&gt;El binlog nativo es la ruta recomendada, pero AWS DMS vale la pena conocerlo porque es casi por completo manejado por consola. Corrí una tarea de full load más CDC de Aurora a una segunda instancia de RDS. Funcionó, y es rápido:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Métrica&lt;/th&gt;
&lt;th&gt;Resultado&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Full load, 2 tablas, 274 MB&lt;/td&gt;
&lt;td&gt;20.2 s (como 48 GB/h en la instancia más chica)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CDC, un solo insert&lt;/td&gt;
&lt;td&gt;propagado en menos de 12 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pero DMS tiene un hueco que el binlog no. Creé una tabla en Aurora (se replicó bien), luego la tiré:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Aurora: tabla ida.&lt;/li&gt;
&lt;li&gt;Objetivo de DMS: tabla todavía ahí.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;El CDC de DMS no replica &lt;code&gt;DROP TABLE&lt;/code&gt; ni &lt;code&gt;RENAME TABLE&lt;/code&gt;, e ignora unos cuantos ALTER. Sobre una ventana de reversión larga eso es schema drift silencioso. Usa DMS si quieres el flujo de consola y puedes vivir con la restricción; usa binlog si quieres que el DDL venga incluido.&lt;/p&gt;

&lt;h2&gt;
  
  
  Los números, y por qué el tamaño deja de importar
&lt;/h2&gt;

&lt;p&gt;El punto de todo el ejercicio es que el tiempo de reversión se desacopla del tamaño de la base de datos. Dos tablas cuentan la historia. La primera es la ejecución de la reversión cuando la réplica caliente se mantiene corriendo:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tamaño de la BD&lt;/th&gt;
&lt;th&gt;Tiempo de reversión (réplica caliente)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 GB&lt;/td&gt;
&lt;td&gt;~1 a 2 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100 GB&lt;/td&gt;
&lt;td&gt;~1 a 3 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 TB&lt;/td&gt;
&lt;td&gt;~1 a 5 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100 TB&lt;/td&gt;
&lt;td&gt;~1 a 5 min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Constante. La única variable es cuánto delta se apiló durante el incidente, y una instancia de clase producción drena eso a como 1 a 2 GB por minuto, así que hasta un backlog gordo de 10 GB se limpia en minutos de un solo dígito.&lt;/p&gt;

&lt;p&gt;La segunda tabla es lo que pasa si &lt;strong&gt;no&lt;/strong&gt; mantuviste la réplica caliente y tienes que reconstruir el fallback desde cero. Este es el camino a evitar:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tamaño de la BD&lt;/th&gt;
&lt;th&gt;Reconstruir vía DMS paralelo (~0.9 TB/h)&lt;/th&gt;
&lt;th&gt;Reconstruir vía dump + restore (~60 GB/h)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100 GB&lt;/td&gt;
&lt;td&gt;~7 min&lt;/td&gt;
&lt;td&gt;~1.7 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 TB&lt;/td&gt;
&lt;td&gt;~1.1 h&lt;/td&gt;
&lt;td&gt;~17 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100 TB&lt;/td&gt;
&lt;td&gt;~4 a 5 días&lt;/td&gt;
&lt;td&gt;inviable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A 100 TB la diferencia es minutos contra días. Ese es todo el argumento para mantener la vieja instancia caliente.&lt;/p&gt;

&lt;p&gt;Un ancla medida para los números de reconstrucción: en una instancia de clase producción (32 vCPU, 256 GB) un insert lógico de un solo hilo corrió a como 1.2 GB por minuto, o 72 GB/h. El apply de binlog basado en filas y el restore lógico son del mismo orden de magnitud, que es sobre lo que están construidos los estimados de arriba. Las mediciones de instancia chica (un par de MB/s de apply de binlog, como 48 GB/h de full load de DMS) son pisos conservadores que escalan hacia arriba con el tamaño de la instancia.&lt;/p&gt;

&lt;h2&gt;
  
  
  La trampa del standby frío vacío
&lt;/h2&gt;

&lt;p&gt;Un punto medio tentador es mantener una instancia de RDS aprovisionada y corriendo pero vacía, lista para llenarse bajo demanda. Suena ahorrador. Es lo peor de los dos mundos.&lt;/p&gt;

&lt;p&gt;Una instancia vacía e inactiva cuesta lo &lt;strong&gt;mismo por hora&lt;/strong&gt; que una réplica caliente, porque pagas por el cómputo, no por los datos. Pero una instancia vacía no tiene línea base, así que revertir significa cargar la base de datos entera en ella primero. Ese es el camino de reconstrucción lineal con el tamaño: minutos a 100 GB, días a 100 TB. Pagas precio completo de standby por la velocidad de reversión de no tener nada. Peor, estarías recargando desde Aurora, la base de datos de la que estás tratando de escapar, así que si el gatillo fue corrupción la copias fielmente.&lt;/p&gt;

&lt;p&gt;Si estás pagando por mantener una caja corriendo como objetivo de reversión, mantenla caliente. Si de plano no puedes, la opción honesta es ningún standby y una reversión aceptada de varios días con pérdida alta, no una caja vacía que cuesta lo mismo que la buena opción.&lt;/p&gt;

&lt;h2&gt;
  
  
  El manual de operaciones
&lt;/h2&gt;

&lt;p&gt;Todo el asunto en una oración: la vieja instancia de RDS es una copia viva que ha estado tragándose cada write de Aurora desde el corte, así que revertir es detener Aurora, dejar que RDS termine los últimos writes, cambiar el endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configúralo una vez, cuando promuevas Aurora:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Agarra la coordenada de binlog de Aurora en el instante en que la app empieza a escribirle (&lt;code&gt;SHOW MASTER STATUS&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Apunta RDS a Aurora desde esa coordenada (&lt;code&gt;mysql.rds_set_external_master&lt;/code&gt; o la variante de auto-posición de GTID) y arranca la replicación.&lt;/li&gt;
&lt;li&gt;Confirma &lt;code&gt;Replica_IO_Running: Yes&lt;/code&gt; y &lt;code&gt;Replica_SQL_Running: Yes&lt;/code&gt;. Déjala corriendo. Alarma sobre que el hilo de SQL se detenga. No le escribas a RDS y no cambies el schema en Aurora mientras corre.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Revierte, cuando lo decidas:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Detén que la app le escriba a Aurora.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SET GLOBAL read_only = ON&lt;/code&gt; en Aurora, para que nada se cuele.&lt;/li&gt;
&lt;li&gt;Observa RDS hasta &lt;code&gt;Seconds_Behind_Source = 0&lt;/code&gt; con los dos hilos en &lt;code&gt;Yes&lt;/code&gt;. Esta es la compuerta. Significa que RDS ahora tiene cada write de la era Aurora. Si un hilo dice &lt;code&gt;No&lt;/code&gt;, párate y escala, no hagas el corte.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mysql.rds_stop_replication&lt;/code&gt; y luego &lt;code&gt;mysql.rds_reset_external_master&lt;/code&gt; en RDS.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SET GLOBAL read_only = OFF&lt;/code&gt; en RDS.&lt;/li&gt;
&lt;li&gt;Apunta el endpoint de la app a RDS. Vuélvela a levantar.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No hay deshacer después del paso 4. Una vez que el enlace se corta y RDS es escribible, las dos bases de datos son independientes. Que es exactamente por lo que el paso 3 no es opcional: es la garantía de que te estás llevando todos los datos de vuelta contigo.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cloud</category>
      <category>database</category>
      <category>infrastructure</category>
    </item>
    <item>
      <title>Sesiones remotas siempre disponibles para tus agentes de codigo</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Sat, 25 Jul 2026 19:19:33 +0000</pubDate>
      <link>https://dev.to/aws-builders/sesiones-remotas-siempre-disponibles-para-tus-agentes-de-codigo-549f</link>
      <guid>https://dev.to/aws-builders/sesiones-remotas-siempre-disponibles-para-tus-agentes-de-codigo-549f</guid>
      <description>&lt;p&gt;Una sesión de agente que sobrevive a cerrar la laptop, corriendo en una sola instancia EC2 chica que manejas desde el teléfono. Este post cubre tanto Claude Code como Kiro CLI, porque resuelven el problema de "alcanzar la sesión desde otro lado" de maneras completamente distintas, y esa diferencia decide la mayor parte de tu arquitectura.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Versión ingenua&lt;/th&gt;
&lt;th&gt;Después&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Las credenciales viven en&lt;/td&gt;
&lt;td&gt;el volumen raíz de la instancia&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Secrets Manager&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sobrevive al reemplazo de la instancia&lt;/td&gt;
&lt;td&gt;no, re-login manual&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;sí, sin atender (~2 min)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tiempo para notar una caja muerta&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;6 días&lt;/strong&gt; (a mano)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;15 min&lt;/strong&gt; (alarma)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clone fallido al arrancar&lt;/td&gt;
&lt;td&gt;tragado por `\&lt;/td&gt;
&lt;td&gt;\&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ciclo de vida acoplado a&lt;/td&gt;
&lt;td&gt;el stack de producción&lt;/td&gt;
&lt;td&gt;su propio stack&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;El costo es una instancia ARM on-demand chica más un volumen gp3 de 30 GB: revisa el precio de lista actual de tu región, pero es la parte más barata de todo esto.&lt;/p&gt;

&lt;p&gt;Una instancia chica siempre encendida lo resuelve, con dos condiciones:&lt;br&gt;
puedes alcanzar la sesión desde donde estés, y la caja regresa por su&lt;br&gt;
cuenta cuando la infraestructura se mueve por debajo de ella. La segunda es de lo que este post trata en realidad.&lt;/p&gt;
&lt;h2&gt;
  
  
  Los dos modelos remotos
&lt;/h2&gt;

&lt;p&gt;Esta es la bifurcación del camino, así que hazla bien antes de construir nada.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude Code tiene una sesión remota de primera mano.&lt;/strong&gt; {% raw %}&lt;code&gt;claude&lt;br&gt;
remote-control&lt;/code&gt; registra la sesión en curso con un punto de encuentro&lt;br&gt;
hospedado, y la manejas desde &lt;code&gt;claude.ai/code&lt;/code&gt; o la app móvil. La caja hace una conexión &lt;strong&gt;de salida&lt;/strong&gt; nada más:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude remote-control &lt;span class="nt"&gt;--name&lt;/span&gt; myapp-cloud &lt;span class="nt"&gt;--continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;--continue&lt;/code&gt; reanuda la misma sesión a través de reinicios del proceso, así que un enlace en marcadores se queda estable cuando el servicio rebota.&lt;br&gt;
Esto necesita un CLI reciente: la caja de este post corría la 2.1.211. Al tener éxito imprime:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Take this session with you and pick up right where you left off on any device.
Open the Code tab in the Claude mobile app, or visit claude.ai/code in a browser.
The session keeps running on this machine. Use your other devices as a remote control. Press Ctrl+C to stop.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;La consecuencia de seguridad es grande: &lt;strong&gt;sin puertos de entrada, sin SSH, sin listener público.&lt;/strong&gt; El security group puede ser solo de salida.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kiro CLI no tiene equivalente.&lt;/strong&gt; Sus docs describen dos modos, ninguno de los cuales es una sesión remota:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Headless, para CI: &lt;code&gt;kiro-cli chat --no-interactive "prompt"&lt;/code&gt;, autenticado por &lt;code&gt;KIRO_API_KEY&lt;/code&gt;. Los docs son explícitos en que "No es posible input del usuario a media sesión": un solo prompt, de principio a fin, sin reanudar.&lt;/li&gt;
&lt;li&gt;Interactivo con persistencia local: las sesiones se auto-guardan por directorio y regresan con &lt;code&gt;kiro-cli chat --resume&lt;/code&gt; (también &lt;code&gt;--resume-picker&lt;/code&gt;, &lt;code&gt;--resume-id &amp;lt;ID&amp;gt;&lt;/code&gt;, &lt;code&gt;--list-sessions&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Así que para Kiro la sesión siempre encendida es algo que &lt;em&gt;tú&lt;/em&gt; construyes:&lt;br&gt;
mantén el proceso vivo en un multiplexor de terminal, y conéctate a él por SSM.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# en la caja, como el usuario de la app&lt;/span&gt;
tmux new-session &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; agent &lt;span class="s1"&gt;'kiro-cli chat'&lt;/span&gt;

&lt;span class="c"&gt;# desde donde sea&lt;/span&gt;
aws ssm start-session &lt;span class="nt"&gt;--target&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INSTANCE_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-iu&lt;/span&gt; appuser tmux attach &lt;span class="nt"&gt;-t&lt;/span&gt; agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;--resume&lt;/code&gt; es la red de seguridad más que el mecanismo: si el proceso se muere, la conversación sigue en disco, indexada por el directorio de trabajo.&lt;/p&gt;

&lt;p&gt;Lectura práctica: en un teléfono, Claude Code gana de calle, una pestaña de navegador le gana a un shell de SSM móvil conectándose a tmux. El modelo de Kiro le queda mejor a una laptop o tablet con una terminal de verdad. El resto de este post aplica a los dos, porque las partes difíciles (credenciales, recuperación, monitoreo) son idénticas.&lt;/p&gt;
&lt;h3&gt;
  
  
  Construir el lado de Claude Code
&lt;/h3&gt;

&lt;p&gt;Una unidad de systemd, y el proceso mismo es la sesión. Como la conexión es de salida, no se requiere nada más.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/appuser/.local/bin/claude remote-control --name myapp-cloud --continue&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;
&lt;span class="py"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;La salud es lo que sea que diga systemd: &lt;code&gt;systemctl is-active&lt;/code&gt;. Esa es toda la integración.&lt;/p&gt;
&lt;h3&gt;
  
  
  Construir el lado de Kiro
&lt;/h3&gt;

&lt;p&gt;Kiro necesita que lo durable sea la &lt;em&gt;terminal&lt;/em&gt;, no el proceso del agente, porque no hay sesión que re-registrar. Corre tmux bajo systemd y deja que el agente viva adentro:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Kiro agent session (tmux)&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;
&lt;span class="py"&gt;Wants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;
&lt;span class="py"&gt;StartLimitIntervalSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;forking&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;appuser&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/appuser/myapp&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;PATH=/home/appuser/.local/bin:/usr/local/bin:/usr/bin:/bin&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/tmux new-session -d -s agent 'kiro-cli chat --resume'&lt;/span&gt;
&lt;span class="py"&gt;ExecStop&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/tmux kill-session -t agent&lt;/span&gt;
&lt;span class="py"&gt;RemainAfterExit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;
&lt;span class="py"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;30&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;--resume&lt;/code&gt; en &lt;code&gt;ExecStart&lt;/code&gt; es deliberado: si la caja reinicia, la sesión nueva de tmux se reconecta a la conversación que ya está en disco para ese directorio en lugar de arrancar en frío.&lt;/p&gt;

&lt;p&gt;Conéctate desde donde sea por SSM, todavía sin puertos de entrada:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ssm start-session &lt;span class="nt"&gt;--target&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INSTANCE_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-iu&lt;/span&gt; appuser tmux attach &lt;span class="nt"&gt;-t&lt;/span&gt; agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;La salud significa algo distinto aquí, y este es el único lugar donde los dos caminos de verdad divergen. &lt;code&gt;systemctl is-active&lt;/code&gt; sobre una unidad tmux de tipo forking te dice que tmux está vivo, no que el agente adentro lo esté. Pregúntale a tmux directo:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; appuser tmux has-session &lt;span class="nt"&gt;-t&lt;/span&gt; agent 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;PANES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; appuser tmux list-panes &lt;span class="nt"&gt;-t&lt;/span&gt; agent &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s1"&gt;'#{pane_dead}'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'^0$'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PANES&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; heartbeat OK &lt;span class="s2"&gt;"tmux:&lt;/span&gt;&lt;span class="nv"&gt;$PANES&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; fail &lt;span class="s2"&gt;"tmux session has no live pane"&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;fail &lt;span class="s2"&gt;"tmux session missing"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;#{pane_dead}&lt;/code&gt; es el detalle que vale la pena guardar. Una sesión de tmux cuyo único panel ya salió sigue respondiendo &lt;code&gt;has-session&lt;/code&gt; con éxito, así que el chequeo ingenuo reporta sano una sesión con un agente muerto adentro, la misma clase de falso negativo que el timer inerte de más adelante.&lt;/p&gt;

&lt;p&gt;La historia de credenciales de Kiro es más simple en un aspecto: la auth headless es una sola API key en &lt;code&gt;KIRO_API_KEY&lt;/code&gt;, sin flujo de navegador y sin expiración de sesión, que cae directo en el mismo patrón de Secrets Manager. Vale la pena tenerla en la caja aunque manejes de forma interactiva, porque hace útil la caja para one-shots por script:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kiro-cli chat &lt;span class="nt"&gt;--no-interactive&lt;/span&gt; &lt;span class="nt"&gt;--trust-all-tools&lt;/span&gt; &lt;span class="s2"&gt;"run the test suite and summarise failures"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Sé deliberado con &lt;code&gt;--trust-all-tools&lt;/code&gt; en una máquina sin atender:&lt;br&gt;
auto-aprueba cada tool call. &lt;code&gt;--trust-tools=read,grep&lt;/code&gt; es el default más seguro para cualquier cosa agendada.&lt;/p&gt;

&lt;p&gt;Si de plano prefieres no construir nada de esto para Kiro, AWS publica una &lt;a href="https://aws-samples.github.io/sample-one-click-generative-ai-solutions/en/solutions/kiro-ide/" rel="noopener noreferrer"&gt;muestra Kiro IDE Remote de un clic&lt;/a&gt; que pone el IDE completo en un escritorio remoto alcanzable desde un navegador, con Kiro CLI y el AWS CLI preinstalados. Más pesada que una instancia chica, y un perfil de costo distinto, pero se salta el armado.&lt;/p&gt;
&lt;h2&gt;
  
  
  El punto de partida, y qué tenía de malo
&lt;/h2&gt;

&lt;p&gt;La caja original era una instancia EC2 definida dentro del stack del&lt;br&gt;
backend de producción. El user-data instalaba el toolchain, clonaba el repo, escribía una unidad de systemd, y la dejaba &lt;strong&gt;deshabilitada&lt;/strong&gt; a propósito: arrancar un agente sin autenticar nada más entra en crash-loop.&lt;br&gt;
Un humano luego se metía por SSM una vez, iniciaba sesión de forma&lt;br&gt;
interactiva, y habilitaba el servicio.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Dentro del stack del backend de producción. Tres errores separados.&lt;/span&gt;
&lt;span class="nx"&gt;userData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addCommands&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sudo -u appuser bash -lc 'curl -fsSL https://example-agent-installer.sh | bash' || true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;`sudo -u appuser bash -lc 'test -d ~/myapp || git clone https://github.com/acme/myapp.git ~/myapp' || true`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ...archivo de unidad escrito aquí, a propósito sin habilitar...&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;systemctl daemon-reload&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Eso funcionó por meses. Luego se movió el AMI.&lt;/p&gt;

&lt;p&gt;La instancia usaba &lt;code&gt;MachineImage.fromSsmParameter(...)&lt;/code&gt; para seguir la imagen actual de Ubuntu 24.04. Cuando ese parámetro resolvió un AMI más nuevo, CloudFormation vio cambiar el &lt;code&gt;ImageId&lt;/code&gt;, lo cual es un &lt;strong&gt;reemplazo&lt;/strong&gt;, no una actualización. Instancia nueva, volumen nuevo, y todo lo del volumen raíz viejo se fue.&lt;/p&gt;

&lt;p&gt;El reemplazo arrancó y se veía bien. No lo estaba:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;+ sudo -u ubuntu bash -lc 'test -d ~/myapp || git clone https://github.com/acme/myapp.git ~/myapp'
Cloning into '/home/ubuntu/myapp'...
fatal: could not read Username for 'https://github.com': No such device or address
+ true
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Un repo &lt;strong&gt;privado&lt;/strong&gt;, clonado sobre https pelón, sin credenciales. &lt;code&gt;|| true&lt;/code&gt;&lt;br&gt;
se lo tragó, cloud-init reportó un arranque limpio, y la falla era&lt;br&gt;
invisible a menos que alguien leyera el log.&lt;/p&gt;

&lt;p&gt;Las consecuencias se encadenaron. Sin repo, el &lt;code&gt;WorkingDirectory&lt;/code&gt; de la unidad no existía, así que el servicio no podía arrancar aunque estuviera habilitado. No estaba habilitado, porque eso es un paso manual. Y el login del agente vivía solo en el volumen que se acababa de destruir:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;systemctl status agent-remote-control
&lt;span class="gp"&gt;   Loaded: loaded (/etc/systemd/system/agent-remote-control.service;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;disabled&lt;span class="p"&gt;;&lt;/span&gt; preset: enabled&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;   Active: inactive (dead)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Seis días después alguien fue a usarla.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tres fallas independientes, una causa raíz: cada camino de recuperación requería un humano, y nada decía que se necesitaba un humano.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Ronda 1: las credenciales van en un almacén de secretos, no en un volumen
&lt;/h2&gt;

&lt;p&gt;Si la caja no puede re-autenticarse sola, cada reemplazo es un outage. Dos secretos, sembrados una vez por cuenta en lugar de por instancia:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/myapp/agent-box-github-token        -&amp;gt; string de token pelón
/myapp/agent-box-agent-credentials   -&amp;gt; blob JSON de credenciales
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;El script de bootstrap jala los dos, autentica, clona, y habilita el&lt;br&gt;
servicio. Cada paso es idempotente, así que es seguro correrlo en cada arranque y en un timer.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;log &lt;span class="s2"&gt;"1/5 GitHub auth"&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;gh auth status &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;log &lt;span class="s2"&gt;"    already authenticated"&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;&lt;span class="nv"&gt;GH_TOKEN_VALUE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;secret &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GH_SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GH_TOKEN_VALUE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; fail &lt;span class="s2"&gt;"could not read &lt;/span&gt;&lt;span class="nv"&gt;$GH_SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GH_TOKEN_VALUE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | gh auth login &lt;span class="nt"&gt;--with-token&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; fail &lt;span class="s2"&gt;"gh auth login failed"&lt;/span&gt;
  &lt;span class="nb"&gt;unset &lt;/span&gt;GH_TOKEN_VALUE
&lt;span class="k"&gt;fi
&lt;/span&gt;gh auth setup-git &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Dos detalles no obvios:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;El script no se puede descargar.&lt;/strong&gt; La jugada obvia es hacerle &lt;code&gt;curl&lt;/code&gt; desde el repo en el user-data. Ese repo es privado, y el token que autorizaría la descarga es lo que el script instala. Circular. En su lugar, incrusta el script en el user-data: léelo en tiempo de synth y mételo en base64.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;El alcance de IAM es exactamente dos ARNs.&lt;/strong&gt; La caja obtiene &lt;code&gt;secretsmanager:GetSecretValue&lt;/code&gt; sobre sus propios secretos de bootstrap y nada más. No tiene por qué leer el password de la base de datos.&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addToPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PolicyStatement&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ReadBootstrapSecrets&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;secretsmanager:GetSecretValue&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;`arn:aws:secretsmanager:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:secret:/myapp/agent-box-github-token-*`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`arn:aws:secretsmanager:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:secret:/myapp/agent-box-agent-credentials-*`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Trampa 1: el blob de credenciales no es solo el token
&lt;/h3&gt;

&lt;p&gt;Restaurar solo el token OAuth producía un servicio que arrancaba y se moría de inmediato:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Unable to determine your organization for Remote Control eligibility. Run `claude auth login` to refresh your account information.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;La funcionalidad de remote-control necesita contexto de cuenta, los&lt;br&gt;
identificadores de la cuenta y de la organización, junto con el token.&lt;br&gt;
Incómodamente, en macOS estos viven en dos lugares distintos: el token en el keychain de login, el objeto de cuenta en el propio archivo de config del CLI. Una restauración que lee solo uno de los dos se ve completa y falla en runtime.&lt;/p&gt;

&lt;p&gt;Guarda los dos en un secreto y escríbelos a sus dos destinos en la caja:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;oauthAccount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;oauthAccount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;oauthAccount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;organizationUuid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;organizationUuid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;organizationUuid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Trampa 2: el diálogo de trust headless
&lt;/h3&gt;

&lt;p&gt;Un CLI de agente pregunta si debería confiar en un workspace en el primer uso dentro de un directorio. Un servicio de systemd no tiene TTY y no puede contestar, así que entra en crash-loop en el prompt. Pre-configura la bandera:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;projects&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workdir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hasTrustDialogAccepted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hasCompletedProjectOnboarding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hasCompletedOnboarding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;En esta es fácil perder una hora, porque el texto del error es sobre trust y no dice nada de TTYs.&lt;/p&gt;
&lt;h3&gt;
  
  
  Trampa 3: un heredoc se come tu pipe en silencio
&lt;/h3&gt;

&lt;p&gt;La primera versión del paso de contexto de cuenta hacía pipe de un secreto hacia Python mientras también usaba un heredoc para el script:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ROTO&lt;/span&gt;
secret &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLAUDE_SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | python3 - &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WORKDIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;TPY&lt;/span&gt;&lt;span class="sh"&gt;'
sec = json.load(sys.stdin)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;El heredoc &lt;em&gt;es&lt;/em&gt; stdin. El pipe se descarta, y Python lee su propio código fuente como el payload JSON:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Escribe el secreto a un archivo &lt;code&gt;mktemp&lt;/code&gt; con &lt;code&gt;umask 077&lt;/code&gt; y pasa la ruta como argumento.&lt;/p&gt;
&lt;h2&gt;
  
  
  Ronda 2: mantenerlo corriendo
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Restart=always&lt;/code&gt; maneja el caso fácil: el proceso crasheó, arráncalo de nuevo.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Agent remote-control session&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;
&lt;span class="py"&gt;Wants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;
&lt;span class="py"&gt;StartLimitIntervalSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;appuser&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/appuser/myapp&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;PATH=/home/appuser/.local/bin:/usr/local/bin:/usr/bin:/bin&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/appuser/.local/bin/claude remote-control --name myapp-cloud --continue&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;
&lt;span class="py"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;30&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;StartLimitIntervalSec=0&lt;/code&gt; importa más de lo que parece. Por default systemd se rinde después de una ráfaga de fallas rápidas y estaciona la unidad en &lt;code&gt;failed&lt;/code&gt; &lt;strong&gt;permanentemente&lt;/strong&gt;. En una caja sin atender eso convierte un problema de red pasajero en un outage que dura hasta que alguien se da cuenta, que es justo el modo de falla que estamos diseñando para que no pase.&lt;/p&gt;

&lt;p&gt;Pero &lt;code&gt;Restart=always&lt;/code&gt; no puede ayudar con los estados que de verdad mataron esta caja: credenciales faltantes, un clone borrado, una unidad dejada deshabilitada. Esos necesitan algo fuera de la unidad. Como el script de bootstrap es idempotente, el watchdog es nada más el bootstrap en un timer:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Watchdog for the agent session&lt;/span&gt;

&lt;span class="nn"&gt;[Timer]&lt;/span&gt;
&lt;span class="py"&gt;OnBootSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;60&lt;/span&gt;
&lt;span class="py"&gt;OnCalendar&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;*:0/5&lt;/span&gt;
&lt;span class="py"&gt;AccuracySec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;30s&lt;/span&gt;
&lt;span class="py"&gt;Persistent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;timers.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Trampa 4: &lt;code&gt;OnUnitActiveSec&lt;/code&gt; en una unidad que nunca ha corrido
&lt;/h3&gt;

&lt;p&gt;El primer timer usaba &lt;code&gt;OnUnitActiveSec=5min&lt;/code&gt;. Se instaló limpio, reportó habilitado, y nunca disparó: ese setting es relativo a la última activación de la unidad, y una unidad que nunca ha corrido no tiene siguiente disparo:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NEXT  LEFT  LAST                          PASSED     UNIT
-     -     Wed 2026-07-22 16:43:57 UTC   13ms ago   agent-bootstrap.timer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;NEXT&lt;/code&gt; es &lt;code&gt;-&lt;/code&gt;. Un watchdog que está inerte en silencio es peor que ninguno, porque se lee como cubierto. Usa &lt;code&gt;OnCalendar&lt;/code&gt; de reloj de pared en su lugar.&lt;/p&gt;
&lt;h2&gt;
  
  
  Ronda 3: la parte que de verdad lo habría atrapado
&lt;/h2&gt;

&lt;p&gt;Todo lo de arriba acorta el outage. Nada de eso acorta los &lt;strong&gt;seis días&lt;/strong&gt;, porque una recuperación de la que nunca te enteras es indistinguible de ninguna recuperación.&lt;/p&gt;

&lt;p&gt;El watchdog ya corre cada cinco minutos y ya sabe si el servicio está sano.&lt;br&gt;
Haz que lo diga, en voz alta, a algún lugar fuera de la caja:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;heartbeat&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;stream ts
  &lt;span class="nv"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /var/lib/cloud/data/instance-id 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nv"&gt;ts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;000"&lt;/span&gt;
  aws logs create-log-stream &lt;span class="nt"&gt;--region&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REGION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--log-group-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_GROUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--log-stream-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
  &lt;/span&gt;aws logs put-log-events &lt;span class="nt"&gt;--region&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REGION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--log-group-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_GROUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--log-stream-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--log-events&lt;/span&gt; &lt;span class="s2"&gt;"timestamp=&lt;/span&gt;&lt;span class="nv"&gt;$ts&lt;/span&gt;&lt;span class="s2"&gt;,message=AGENT_BOX_HEARTBEAT &lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$detail&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; log &lt;span class="s2"&gt;"WARN: heartbeat not delivered"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

fail&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; log &lt;span class="s2"&gt;"ERROR: &lt;/span&gt;&lt;span class="nv"&gt;$*&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; heartbeat DOWN &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$*&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Un metric filter cuenta las líneas sanas, y la alarma dispara ante su&lt;br&gt;
&lt;strong&gt;ausencia&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MetricFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HeartbeatMetricFilter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;logGroup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;metricNamespace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;myapp/AgentBox&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;metricName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;RemoteControlHeartbeat&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;filterPattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FilterPattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"AGENT_BOX_HEARTBEAT OK"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;metricValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;defaultValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;cw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Alarm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;RemoteControlDownAlarm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;alarmName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;myapp-agent-box-down&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;cw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Metric&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;myapp/AgentBox&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;metricName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;RemoteControlHeartbeat&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;period&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;statistic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sum&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;comparisonOperator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ComparisonOperator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LESS_THAN_THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;evaluationPeriods&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;treatMissingData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TreatMissingData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BREACHING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;addAlarmAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;cwactions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SnsAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alarmTopic&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;&lt;code&gt;treatMissingData: BREACHING&lt;/code&gt; es todo el diseño.&lt;/strong&gt; El instinto es alarmar sobre una línea de error, pero cada modo de falla en este postmortem produjo &lt;em&gt;cero logs&lt;/em&gt;: una instancia que nunca arrancó, un rol sin permisos, un watchdog que nunca disparó. La ausencia es la señal. Esta es la única alarma de la cuenta que no debe caer por default en &lt;code&gt;NOT_BREACHING&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Dos detalles de apoyo. El heartbeat dispara también en el camino de falla, así que una corrida rota es tan visible como una sana: la alarma se indexa en la ausencia de &lt;code&gt;OK&lt;/code&gt;, no en la presencia de &lt;code&gt;DOWN&lt;/code&gt;. Y &lt;code&gt;activating&lt;/code&gt; cuenta como sano, ya que es el estado normal por unos segundos después de cualquier reinicio:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;STATE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;systemctl is-active agent-remote-control 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in
  &lt;/span&gt;active|activating&lt;span class="p"&gt;)&lt;/span&gt; heartbeat OK &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
  &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; fail &lt;span class="s2"&gt;"service is &lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="k"&gt;esac&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Cambia por el chequeo de &lt;code&gt;tmux has-session&lt;/code&gt; + &lt;code&gt;#{pane_dead}&lt;/code&gt; de antes para una caja de Kiro. Todo lo de aguas abajo (log group, metric filter, alarma, topic de SNS) es idéntico, porque la línea de heartbeat es el único contrato entre la caja y la alarma.&lt;/p&gt;
&lt;h2&gt;
  
  
  Ronda 4: deja de compartir ciclo de vida con producción
&lt;/h2&gt;

&lt;p&gt;La caja original vivía en el stack del backend de producción. Eso es lo que la mató: un parámetro de AMI dentro de &lt;em&gt;ese&lt;/em&gt; stack reemplazó la instancia. Cada deploy de producción también la detenía/arrancaba.&lt;/p&gt;

&lt;p&gt;Una comodidad de desarrollo no tiene por qué compartir ciclo de vida con producción. Su propio stack, con un lookup de VPC en lugar de un export entre stacks, un export recrearía el acoplamiento que la separación existe para quitar:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AgentBoxStack&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Stack&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Construct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;vpc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ec2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromLookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Vpc&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;vpcName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;myapp-vpc&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alarmTopic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;sns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Topic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AgentBoxAlerts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;topicName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;myapp-agent-box-alerts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;alarmTopic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;snssub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;EmailSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ops@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AgentBox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AgentBox&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;appName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;myapp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;vpc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;githubRepo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;acme/myapp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;alarmTopic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Su propio topic de SNS, también. Importar el topic de producción metería el stack de vuelta justo en el grafo de dependencias.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;El movimiento destruye y recrea la instancia&lt;/strong&gt;, y el orden no es opcional si algún recurso tiene un nombre físico fijo. El rol de IAM aquí lo tiene:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx cdk deploy MyApp-Backend    &lt;span class="c"&gt;# borra la caja vieja y su rol de nombre fijo&lt;/span&gt;
npx cdk deploy MyApp-AgentBox   &lt;span class="c"&gt;# los crea de nuevo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Al revés, el segundo deploy falla por un nombre de rol duplicado.&lt;/p&gt;
&lt;h2&gt;
  
  
  El resultado
&lt;/h2&gt;

&lt;p&gt;La separación del stack destruyó la instancia y construyó una nueva desde cero, lo que reproduce la falla original exactamente. Esta vez nadie la tocó:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MyApp-AgentBox | 12/13 | CREATE_COMPLETE | AWS::EC2::Instance | AgentBox/Instance
MyApp-AgentBox | 13/13 | CREATE_COMPLETE | AWS::CloudFormation::Stack | MyApp-AgentBox
 ✅  MyApp-AgentBox
✨  Deployment time: 177.81s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Como dos minutos después, sin atender:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[agent-box-bootstrap] 1/5 GitHub auth
[agent-box-bootstrap] 2/5 Repo clone
[agent-box-bootstrap] 3/5 Agent credentials
[agent-box-bootstrap] 4/5 Account context + workspace trust
[agent-box-bootstrap] 5/5 Enabling the always-on service
[agent-box-bootstrap] done (active)
systemd[1]: Finished agent-bootstrap.service.
systemd[1]: agent-bootstrap.service: Consumed 2.954s CPU time, 52.5M memory peak
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Y la alarma hizo su trabajo a lo largo de toda la ventana. Durante el hueco del arranque:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Threshold Crossed: no datapoints were received for 1 period and 1 missing
datapoint was treated as [Breaching].    ALARM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;luego, por su cuenta, en cuanto aterrizó el primer heartbeat:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Threshold Crossed: 1 datapoint [1.0 (22/07/26 20:40:00)] was not less than
the threshold (1.0).    OK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Una alarma que dispara ante un outage real y se limpia sola sin ayuda es todo el entregable. La recuperación está padre; la alarma es lo que convierte seis días en quince minutos.&lt;/p&gt;
&lt;h2&gt;
  
  
  Lo que no ayudó
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fijar el AMI.&lt;/strong&gt; Tentador, ya que el drift del AMI fue el gatillo. Pero solo cierra la única puerta: un reemplazo forzado por un cambio de tipo de instancia o una edición de user-data marcada como reemplazo borra el volumen igual, y heredas una imagen congelada sin parchar. Una vez que la caja se puede reconstruir sola, fijar compra muy poco.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Un volumen de datos persistente para el home.&lt;/strong&gt; Una opción real, y sobrevive al reemplazo, pero vuelve las credenciales un copo de nieve otra vez. Una caja que se reconstruye desde un almacén de secretos es más fácil de razonar que una que depende de que un volumen sobreviva.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mover la caja a su propio repo.&lt;/strong&gt; Considerado y rechazado. La falla era clonar un repo privado sin credenciales; un repo separado no cambia eso, porque la caja de todos modos tiene que clonar el privado. Stack separado, mismo repo, esa fue la separación útil.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Lecciones
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Diseña la alarma antes que la recuperación.&lt;/strong&gt; Un auto-arreglo del que nunca te enteras es infalsificable. Los seis días no los causó un camino de reparación faltante; los causó una señal faltante.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alarma sobre la ausencia, no sobre errores.&lt;/strong&gt; La infraestructura muerta es callada. Cualquier monitor construido sobre hacer match a un string de error es ciego a las fallas totales que más importan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;|| true&lt;/code&gt; es una decisión de nunca enterarte.&lt;/strong&gt; Va solo en pasos de verdad opcionales. En cualquier cosa de carga estructural convierte una falla ruidosa en una silenciosa, y un reporte de arranque limpio desde una máquina muerta es lo que hace que un outage dure días en lugar de minutos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Las credenciales en el volumen de una instancia son un outage esperando un gatillo.&lt;/strong&gt; Todo lo que pueda ser reemplazado, lo será. Pon las credenciales donde un reemplazo las pueda alcanzar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sabe cuáles cambios de config son reemplazos.&lt;/strong&gt; Una edición de user-data es un detener/actualizar/arrancar sobre el mismo volumen; un cambio de &lt;code&gt;ImageId&lt;/code&gt; lo destruye. La misma superficie de diff, un radio de impacto salvajemente distinto: lee el plan antes de desplegar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;La infraestructura de conveniencia no va en el stack de producción.&lt;/strong&gt; No por costo, no por orden, por radio de impacto. Fue un deploy de producción el que destruyó la caja.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Un watchdog que nunca ha disparado no es un watchdog.&lt;/strong&gt; Revisa el &lt;code&gt;NEXT&lt;/code&gt; del timer. El monitoreo inerte es peor que ninguno, porque se cuenta como cobertura.&lt;/li&gt;
&lt;/ol&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/elchesco" rel="noopener noreferrer"&gt;
        elchesco
      &lt;/a&gt; / &lt;a href="https://github.com/elchesco/blog-always-on-agent-box-code" rel="noopener noreferrer"&gt;
        blog-always-on-agent-box-code
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Companion code — "Six days of silence: an always-on agent box for Claude Code and Kiro"
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Companion code — "Six days of silence: an always-on agent box for Claude Code and Kiro"&lt;/h1&gt;
&lt;/div&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Reading order&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;&lt;/p&gt;&lt;div class="table-wrapper-paragraph"&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;table&gt;

&lt;thead&gt;

&lt;tr&gt;

&lt;th&gt;Folder&lt;/th&gt;

&lt;th&gt;Blog section&lt;/th&gt;

&lt;th&gt;What is in it&lt;/th&gt;

&lt;/tr&gt;

&lt;/thead&gt;

&lt;tbody&gt;

&lt;tr&gt;

&lt;td&gt;&lt;code&gt;00-starting-point/&lt;/code&gt;&lt;/td&gt;

&lt;td&gt;The starting point, and what was wrong with it&lt;/td&gt;

&lt;td&gt;The naive user-data, with the three mistakes intact&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;&lt;code&gt;01-bootstrap-from-secrets/&lt;/code&gt;&lt;/td&gt;

&lt;td&gt;Round 1&lt;/td&gt;

&lt;td&gt;The idempotent bootstrap script + its IAM scope&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;&lt;code&gt;02-keep-it-running/&lt;/code&gt;&lt;/td&gt;

&lt;td&gt;Round 2&lt;/td&gt;

&lt;td&gt;systemd units — Claude path, Kiro path, watchdog timer&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;&lt;code&gt;03-heartbeat-and-alarm/&lt;/code&gt;&lt;/td&gt;

&lt;td&gt;Round 3&lt;/td&gt;

&lt;td&gt;Heartbeat emitter, both health checks, metric filter + alarm&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;&lt;code&gt;04-own-stack/&lt;/code&gt;&lt;/td&gt;

&lt;td&gt;Round 4&lt;/td&gt;

&lt;td&gt;The isolated CDK stack&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;&lt;code&gt;05-dead-ends/&lt;/code&gt;&lt;/td&gt;

&lt;td&gt;Traps 3 and 4&lt;/td&gt;

&lt;td&gt;Kept deliberately, with an explanation of why they fail&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;&lt;code&gt;06-final/&lt;/code&gt;&lt;/td&gt;

&lt;td&gt;The result&lt;/td&gt;

&lt;td&gt;Cumulative final state of the script and the construct&lt;/td&gt;

&lt;/tr&gt;

&lt;/tbody&gt;

&lt;/table&gt;&lt;/div&gt;&lt;p&gt;&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Which agent path to use&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;The two agents differ only in what keeps the session alive and how you check its health.
Everything else — secrets, bootstrap, watchdog, heartbeat, alarm, stack — is shared.&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;

&lt;th&gt;Claude Code&lt;/th&gt;
&lt;th&gt;Kiro CLI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Remote&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/elchesco/blog-always-on-agent-box-code" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>automation</category>
      <category>aws</category>
      <category>cli</category>
      <category>devops</category>
    </item>
    <item>
      <title>Resolviendo los 404 de Google Search Console</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Sun, 19 Jul 2026 21:30:19 +0000</pubDate>
      <link>https://dev.to/aws-builders/resolviendo-los-404-de-google-search-console-3bc3</link>
      <guid>https://dev.to/aws-builders/resolviendo-los-404-de-google-search-console-3bc3</guid>
      <description>&lt;p&gt;Tres semanas después de publicar un sitemap dinámico que orgullosamente listaba cada perfil de miembro, Google Search Console me dijo que esos perfiles eran un error. No con un error de plano, sino con dos veredictos más callados: &lt;strong&gt;Soft 404&lt;/strong&gt; y &lt;strong&gt;Duplicada sin canónica seleccionada por el usuario&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;Esta es la historia de leer ese reporte, separar el ruido de la&lt;br&gt;
única señal real, y el arreglo, que fue &lt;em&gt;quitar&lt;/em&gt; páginas del índice, no agregarlas.&lt;/p&gt;
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;El reporte "Por qué las páginas no se indexan" de Google es ~80% benigno por diseño. Aprende a triarlo o vas a perseguir fantasmas.&lt;/li&gt;
&lt;li&gt;Mis perfiles de miembros salieron marcados como &lt;strong&gt;Soft 404&lt;/strong&gt; (uno) y &lt;strong&gt;Duplicada sin canónica seleccionada por el usuario&lt;/strong&gt; (otro). La misma causa raíz: el perfil público anónimo es &lt;em&gt;deliberadamente&lt;/em&gt; flaco, un esqueleto con el username, todo lo demás es PII oculta a quien no ha iniciado sesión. Flaco + casi idéntico entre usuarios se lee como "página vacía" y "clúster de duplicados".&lt;/li&gt;
&lt;li&gt;No puedes arreglar un Soft 404 enriqueciendo una página que por contrato no tienes permitido enriquecer. Así que el arreglo es &lt;code&gt;noindex,follow&lt;/code&gt; en la captura, más sacar las URLs del sitemap (un sitemap que lista una URL noindex es una autocontradicción que Google va a señalar).&lt;/li&gt;
&lt;li&gt;El modelo mental en una línea: &lt;strong&gt;una página que a propósito no tiene contenido para los visitantes anónimos no tiene nada que hacer en un índice construido para visitantes anónimos.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  El reporte que lo empezó todo
&lt;/h2&gt;

&lt;p&gt;El reporte de cobertura del índice, ordenado por número de páginas:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Razón&lt;/th&gt;
&lt;th&gt;Fuente&lt;/th&gt;
&lt;th&gt;Páginas&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Descubierta, actualmente sin indexar&lt;/td&gt;
&lt;td&gt;Sistemas de Google&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicada sin canónica seleccionada por el usuario&lt;/td&gt;
&lt;td&gt;Sitio web&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Página alternativa con etiqueta canónica correcta&lt;/td&gt;
&lt;td&gt;Sitio web&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Página con redirección&lt;/td&gt;
&lt;td&gt;Sitio web&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rastreada, actualmente sin indexar&lt;/td&gt;
&lt;td&gt;Sistemas de Google&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Soft 404&lt;/td&gt;
&lt;td&gt;Sitio web&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Excluida por etiqueta 'noindex'&lt;/td&gt;
&lt;td&gt;Sitio web&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No encontrada (404)&lt;/td&gt;
&lt;td&gt;Sitio web&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bloqueada por acceso prohibido (403)&lt;/td&gt;
&lt;td&gt;Sitio web&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Noventa y tantas URLs "sin indexar". El instinto es entrar en pánico. La primera jugada correcta es acomodar cada renglón en &lt;strong&gt;benigno-por-diseño&lt;/strong&gt; o &lt;strong&gt;señal-real&lt;/strong&gt;, porque la mayor parte de esta lista es Google trabajando exactamente como se pretende.&lt;/p&gt;
&lt;h2&gt;
  
  
  Triaje: qué ignorar
&lt;/h2&gt;

&lt;p&gt;Antes de tocar código, clasifiqué cada renglón. La mayoría no necesitaba acción:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Página con redirección (2):&lt;/strong&gt; &lt;code&gt;http://&lt;/code&gt; → &lt;code&gt;https://&lt;/code&gt; y &lt;code&gt;www.&lt;/code&gt; → apex.
Eso es canonicalización &lt;em&gt;funcionando&lt;/em&gt;. Google me está diciendo que siguió la redirección e indexó el destino. Nada que arreglar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Excluida por etiqueta 'noindex' (1):&lt;/strong&gt; la página de reset de
contraseña. Ese &lt;code&gt;noindex&lt;/code&gt; lo pusimos a propósito. Una palomita verde vestida de advertencia.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No encontrada 404 (1):&lt;/strong&gt; la raíz del origen de la API (el host &lt;code&gt;api.&lt;/code&gt;).
No tiene homepage; no debería indexarse. Un rastreador encontró un enlace a ella una vez. Inofensivo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bloqueada 403 (1):&lt;/strong&gt; una URL detrás de auth que regresa 403 a los
rastreadores sin sesión. Correcto.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Descubierta / Rastreada, actualmente sin indexar (57):&lt;/strong&gt; el número grande y aterrador, y el más benigno de todos. Estas son las decisiones de crawl budget de Google &lt;em&gt;mismo&lt;/em&gt; ("conocemos esta URL, no hemos priorizado indexarla"). No las arreglas directo; se resuelven conforme el sitio se gana la prioridad de rastreo a través de contenido y enlaces.
Tratarlas como bugs es el clásico hoyo de conejo de Search Console.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Eso deja los únicos renglones que describen &lt;em&gt;mis&lt;/em&gt; páginas portándose mal:&lt;br&gt;
&lt;strong&gt;Duplicada sin canónica seleccionada por el usuario (9 + un 3&lt;br&gt;
relacionado)&lt;/strong&gt; y &lt;strong&gt;Soft 404 (1)&lt;/strong&gt;. Los dos, resultó, eran los mismos dos &lt;em&gt;tipos&lt;/em&gt; de página.&lt;/p&gt;
&lt;h2&gt;
  
  
  Señal 1: las páginas de tag del blog (una falsa alarma, verificada)
&lt;/h2&gt;

&lt;p&gt;Nueve de las URLs "Duplicada sin canónica" eran filtros de tag del blog: &lt;code&gt;/blog?tag=TechCareers&lt;/code&gt;, &lt;code&gt;/blog?tag=CloudComputing&lt;/code&gt;, y así. Las tres "Página alternativa con canónica correcta" tenían la misma forma&lt;br&gt;
(&lt;code&gt;/blog?tag=gestión&lt;/code&gt;, …).&lt;/p&gt;

&lt;p&gt;El reflejo es "¡agrega etiquetas canónicas!". Pero revisé primero el&lt;br&gt;
componente &lt;code&gt;PageMeta&lt;/code&gt;, y ya hacía lo correcto:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveCanonical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;SITE_URL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// nota: pathname, sin query string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La canónica se construye desde &lt;code&gt;origin + pathname&lt;/code&gt;: &lt;strong&gt;el query string se descarta&lt;/strong&gt;. Así que cada &lt;code&gt;/blog?tag=X&lt;/code&gt; se auto-reporta con canónica &lt;code&gt;/blog&lt;/code&gt;. Ese es el estado final buscado: los filtros de tag son vistas flacas y duplicativas que &lt;em&gt;no&lt;/em&gt; deberían indexarse por separado; deberían consolidarse en &lt;code&gt;/blog&lt;/code&gt;. La división 9-contra-3 es nada más Google a media reclasificación: algunas URLs ya se habían asentado en "página alternativa&lt;br&gt;
con canónica correcta" (la cubeta deseada), el resto seguía en la cubeta genérica de duplicados de camino para allá.&lt;/p&gt;

&lt;p&gt;Lección: &lt;strong&gt;antes de "arreglar" una advertencia de canónica, confirma qué canónica emite la página en realidad.&lt;/strong&gt; La mitad de estas advertencias son Google reportando un estado transitorio de algo que ya hiciste bien. Cerré esta como funcionando-como-se-diseñó y seguí adelante.&lt;/p&gt;
&lt;h2&gt;
  
  
  Señal 2: los perfiles (el bug real)
&lt;/h2&gt;

&lt;p&gt;Las dos URLs restantes eran perfiles de miembros:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/profile/rafael-ortiz&lt;/code&gt; → &lt;strong&gt;Soft 404&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/profile/hectorC&lt;/code&gt; → &lt;strong&gt;Duplicada sin canónica seleccionada por el
usuario&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Un Soft 404 significa que la página regresa HTTP 200 pero &lt;em&gt;parece&lt;/em&gt; un&lt;br&gt;
no-encontrado: vacía, flaca, o de relleno. Para ver por qué Google lo&lt;br&gt;
pensó, aquí está el cuerpo rastreable entero de la captura de un perfil:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;section class=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mx-auto max-w-3xl py-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;h1&amp;gt;@&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;p class=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text-xs text-white/50&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meta_bits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;   &lt;span class="c1"&gt;# rol · miembro-desde
&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;p&amp;gt;&amp;lt;a href=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;MEMBER COMMUNITY: tech community&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/section&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Un username, una etiqueta de rol, una fecha de ingreso, un enlace a home.&lt;br&gt;
Eso es todo, y es &lt;em&gt;deliberadamente&lt;/em&gt; eso. El perfil público que se le&lt;br&gt;
muestra a un visitante sin sesión está anonimizado por privacidad: nombre real, bio, avatar, especialidad, país, enlaces, posts recientes son todos PII, ocultos para &lt;code&gt;viewer=None&lt;/code&gt;. La captura pre-renderizada se construye justo desde esa vista anónima, a propósito, para que el rastreador nunca pueda exponer más de lo que ve un humano sin sesión.&lt;/p&gt;

&lt;p&gt;Ahora los dos veredictos tienen sentido como una sola causa raíz:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Soft 404&lt;/strong&gt;: la página es tan flaca que Google decide que no hay
contenido real. A &lt;code&gt;rafael-ortiz&lt;/code&gt; le tocó cruzar el umbral.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicada sin canónica&lt;/strong&gt;: quítale el username y cada captura de perfil es &lt;em&gt;byte por byte el mismo esqueleto&lt;/em&gt;. Google agrupa las páginas casi idénticas y elige una representante; al resto le cae el veredicto de duplicada. A &lt;code&gt;hectorC&lt;/code&gt; le tocó el palito corto.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Por qué los arreglos obvios están los dos mal
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"Enriquece la página para que no sea flaca."&lt;/strong&gt; No se puede. La flacura es una garantía de privacidad, no un descuido. Agregarle bio/avatar/posts a la captura filtraría PII a un HTML que los rastreadores sin sesión (y quien sea que vea el código fuente) pueden leer. La regla, &lt;em&gt;la captura nunca expone más de lo que ve un humano sin sesión&lt;/em&gt;, es de carga estructural.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Pon una canónica para que los duplicados se consoliden."&lt;/strong&gt; Una canónica apunta los duplicados a una página &lt;em&gt;representante&lt;/em&gt;. Pero no hay perfil representante: cada uno es una persona distinta con contenido (oculto) distinto. Y consolidar no tocaría el Soft 404. La canónica es la herramienta equivocada.&lt;/p&gt;

&lt;p&gt;Así que ninguno de los dos arreglos orientados a indexar aplica. Lo que fuerza una pregunta más honesta: &lt;strong&gt;¿estas páginas pertenecen a un índice de búsqueda siquiera?&lt;/strong&gt; Una página que a un visitante anónimo le muestra nada más &lt;code&gt;@username&lt;/code&gt; y un rol no tiene por qué rankear. Indexarla es puro ruido:&lt;br&gt;
reportes de Soft 404, clústeres de duplicados, y crawl budget gastado&lt;br&gt;
re-jalando esqueletos (alimentando esa pila de 55 URLs "Descubierta, sin indexar").&lt;/p&gt;

&lt;p&gt;La respuesta es no. Sírvele la página a los humanos y a los desplegadores de enlaces en redes; dile a los rastreadores que no la indexen.&lt;/p&gt;
&lt;h2&gt;
  
  
  El arreglo: &lt;code&gt;noindex,follow&lt;/code&gt;, y la contradicción del sitemap
&lt;/h2&gt;

&lt;p&gt;Tres cambios.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Enséñale al core del pre-render a sobreescribir robots.&lt;/strong&gt; El embudo compartido &lt;code&gt;render_into_shell&lt;/code&gt; parcha el &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; del shell. Le di un argumento opcional &lt;code&gt;robots&lt;/code&gt; que reemplaza quirúrgicamente el&lt;br&gt;
&lt;code&gt;&amp;lt;meta name="robots"&amp;gt;&lt;/code&gt; por default del shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;robots&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;meta name=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;robots&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; content=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;]*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; /&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;meta name=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;robots&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; content=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;robots&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; /&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Default &lt;code&gt;None&lt;/code&gt; → sin tocar, así que las capturas de blog / reportes /&lt;br&gt;
eventos mantienen su &lt;code&gt;index,follow&lt;/code&gt;. Solo los callers que se apuntan&lt;br&gt;
cambian de comportamiento.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Apunta el adaptador de perfil.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;render_into_shell&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;shell_html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;canonical&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;body_html&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;jsonld&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;_person_jsonld&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;og_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;profile&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;robots&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;noindex,follow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;¿Por qué &lt;code&gt;noindex,**follow**&lt;/code&gt; y no &lt;code&gt;noindex,nofollow&lt;/code&gt;? Porque la página todavía enlaza hacia afuera (a home, y con el tiempo a contenido indexable). &lt;code&gt;follow&lt;/code&gt; deja que el link equity fluya &lt;em&gt;a través&lt;/em&gt; del perfil aunque el perfil mismo no se indexe. &lt;code&gt;nofollow&lt;/code&gt; lo dejaría varado. La distinción importa: &lt;code&gt;noindex&lt;/code&gt; es sobre &lt;em&gt;esta&lt;/em&gt; página; &lt;code&gt;follow&lt;/code&gt; es sobre las páginas a las que apunta.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Quita los perfiles del sitemap.&lt;/strong&gt; Este es el paso que la gente se salta. Un sitemap es una lista de "por favor indexa estas". Un meta &lt;code&gt;noindex&lt;/code&gt; es "por favor no indexes esta". Si una URL aparece en las dos, le entregaste a Google una contradicción, y va a sacar una advertencia nueva:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;URL enviada marcada como 'noindex'&lt;/strong&gt;. Acabas de cambiar dos advertencias por una nueva.&lt;/p&gt;

&lt;p&gt;Así que el endpoint del sitemap de perfiles, y su entrada en el índice de sitemaps, salieron por completo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!--
  Sin sitemap-profiles.xml: las capturas de perfil anónimas son noindex,follow
  (esqueletos flacos de solo-username, PII oculta por diseño → Soft 404 / duplicada
  en GSC). Listarlas aquí chocaría con el meta noindex.
--&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La ironía no se me escapa: la pasada de SEO anterior &lt;em&gt;agregó&lt;/em&gt; ese sitemap de perfiles. Me tomó tres semanas de datos de rastreo reales aprender que publicar un sitemap para páginas que un rastreador no debería querer era el instinto equivocado. Search Console es un maestro lento pero honesto.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pruebas de regresión
&lt;/h2&gt;

&lt;p&gt;Dos afirmaciones fijan el comportamiento. Primero, la captura voltea robots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_profile_snapshot_is_noindex&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;render_profile_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_anon_profile&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;SHELL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;meta name=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;robots&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; content=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;noindex,follow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; /&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;meta name=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;robots&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; content=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;index, follow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; /&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Segundo, el endpoint del sitemap ya no existe (para que nunca pueda&lt;br&gt;
derivar de vuelta en silencio a contradecir el meta):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_sitemap_profiles_removed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/sitemap-profiles.xml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El fixture del shell de la prueba de &lt;code&gt;noindex&lt;/code&gt; necesita la etiqueta&lt;br&gt;
&lt;code&gt;&amp;lt;meta name="robots"&amp;gt;&lt;/code&gt; real que carga el &lt;code&gt;index.html&lt;/code&gt; de producción, algo fácil de olvidar, y sin ella el reemplazo de regex es un no-op silencioso que pasa por la razón equivocada.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qué medir después
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;noindex&lt;/code&gt; es una señal, no un switch. Después de desplegar, vuelve a&lt;br&gt;
renderizar las capturas vivas para que de verdad carguen el meta nuevo, luego en Search Console:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Validar corrección&lt;/strong&gt; tanto en el item de Soft 404 como en el de
Duplicada. Esto le dice a Google que re-rastree el conjunto afectado.&lt;/li&gt;
&lt;li&gt;Observa los perfiles migrar a &lt;strong&gt;Excluida por etiqueta 'noindex'&lt;/strong&gt;: ese es el estado de &lt;em&gt;éxito&lt;/em&gt; aquí, no un problema nuevo. Significa que Google
vio el meta y obedeció.&lt;/li&gt;
&lt;li&gt;Confirma que el conteo de "Descubierta, actualmente sin indexar" va bajando conforme el crawl budget deja de gastarse re-jalando esqueletos.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Espera semanas, no horas. Los cambios de cobertura del índice se propagan en la agenda de Google.&lt;/p&gt;

&lt;h2&gt;
  
  
  La conclusión
&lt;/h2&gt;

&lt;p&gt;El reporte de índice de Google mezcla "tu&lt;br&gt;
página está rota" con "Google trabaja como se pretende" con "Google todavía no llega a ella", y se ven casi idénticos. Acomodar cada renglón en benigno contra señal &lt;em&gt;antes&lt;/em&gt; de abrir un editor me salvó de "arreglar" etiquetas canónicas que ya estaban correctas.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No toda página quiere estar indexada.&lt;/strong&gt; Una página construida para&lt;br&gt;
mostrarle nada a los visitantes anónimos no tiene por qué rankear, y&lt;br&gt;
forzarla a un índice produce exactamente el ruido de Soft-404-y-duplicada que esperarías. La jugada madura de SEO a veces es sacar páginas &lt;em&gt;fuera&lt;/em&gt;: &lt;code&gt;noindex,follow&lt;/code&gt; en la página, y fuera del sitemap, juntos, para que las dos señales concuerden.&lt;/p&gt;

</description>
      <category>google</category>
      <category>seo</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Alarmas que despiertan por causa real, no por un número random</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Sun, 19 Jul 2026 20:17:34 +0000</pubDate>
      <link>https://dev.to/aws-builders/alarmas-que-despiertan-por-causa-no-por-un-numero-mkm</link>
      <guid>https://dev.to/aws-builders/alarmas-que-despiertan-por-causa-no-por-un-numero-mkm</guid>
      <description>&lt;p&gt;El escenario es un trabajo en segundo plano en una plataforma de&lt;br&gt;
comunidad: un matcher nocturno que empareja items nuevos contra un pool de usuarios elegibles y escribe filas a una tabla &lt;code&gt;matches&lt;/code&gt;. Pero la lección es general: cualquier alarma sobre un &lt;em&gt;conteo de salida&lt;/em&gt; (items emparejados, correos enviados, registros procesados, recomendaciones generadas) tiene el mismo fallo.  &lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/elchesco" rel="noopener noreferrer"&gt;
        elchesco
      &lt;/a&gt; / &lt;a href="https://github.com/elchesco/blog-cause-aware-alarms-code" rel="noopener noreferrer"&gt;
        blog-cause-aware-alarms-code
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Code companion — alarms that page on cause, not on a number
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Code companion — alarms that page on cause, not on a number&lt;/h1&gt;
&lt;/div&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;00-output-threshold-alarm/   the naive version: emitter + alarm that page on a COUNT
01-diagnose/                 read-only SQL that falsifies the alarm's hypothesis
02-instrument-the-emitter/   the pure classifier + the instrumented job
03-cause-aware-alarm/        same threshold, cause-accurate line + a benign metric
04-test-the-gate/            test the classification, not the plumbing
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Reading order&lt;/h2&gt;
&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;00-output-threshold-alarm/&lt;/code&gt; — what pages on a coincidence. Note the
&lt;code&gt;3/3&lt;/code&gt; streak hedge: a workaround for an ambiguous metric, not a fix.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;01-diagnose/funnel.sql&lt;/code&gt; — before touching a threshold, confirm the
alarm caught its &lt;em&gt;stated&lt;/em&gt; failure. Here it hadn't: input + similarities
were healthy; the pool was quota-capped.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;02-instrument-the-emitter/&lt;/code&gt; — move the intelligence to the emitter
&lt;code&gt;classify.py&lt;/code&gt; is the whole idea in one pure function; &lt;code&gt;matcher.py&lt;/code&gt; shows
the two counters that feed it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;03-cause-aware-alarm/&lt;/code&gt; — the threshold is UNCHANGED. Only the log line
got smarter. Plus an unalarmed metric for the benign nights.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;04-test-the-gate/&lt;/code&gt;…&lt;/li&gt;
&lt;/ol&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/elchesco/blog-cause-aware-alarms-code" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Alarma por umbral de salida&lt;/th&gt;
&lt;th&gt;Alarma consciente de la causa&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qué vigila&lt;/td&gt;
&lt;td&gt;"¿el conteo llegó a 0?"&lt;/td&gt;
&lt;td&gt;"¿el conteo llegó a 0 &lt;em&gt;por una razón rota&lt;/em&gt;?"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dónde vive la lógica&lt;/td&gt;
&lt;td&gt;el umbral en el CDK&lt;/td&gt;
&lt;td&gt;el emisor (el trabajo mismo)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Despertadas falsas&lt;/td&gt;
&lt;td&gt;cada mes, en agenda&lt;/td&gt;
&lt;td&gt;ninguna observada&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Noches-0 benignas&lt;/td&gt;
&lt;td&gt;indistinguibles de una falla&lt;/td&gt;
&lt;td&gt;registradas como una métrica aparte, sin alarma&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arreglo cuando dispara en falso&lt;/td&gt;
&lt;td&gt;subir el umbral → quedar ciego&lt;/td&gt;
&lt;td&gt;ya está correcto&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Costo nuevo de AWS&lt;/td&gt;
&lt;td&gt;$0 (derivado de logs)&lt;/td&gt;
&lt;td&gt;$0 (derivado de logs)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tres ideas hacen el trabajo:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Una alarma por conteo de salida confunde causas.&lt;/strong&gt; &lt;code&gt;count == 0&lt;/code&gt; puede significar "roto" o "correctamente callado". Un solo umbral no los distingue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diagnostica antes de ajustar.&lt;/strong&gt; Cuando una alarma dispara, primero pregunta si atrapó la falla que &lt;em&gt;dice&lt;/em&gt; atrapar. Razona desde los datos, no desde la descripción de la alarma.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mueve la inteligencia al emisor.&lt;/strong&gt; Instrumenta el pipeline para que emita &lt;em&gt;por qué&lt;/em&gt; el conteo fue 0. Deja la alarma tonta; haz lista la línea de log.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  La trampa: un conteo no es un diagnóstico
&lt;/h2&gt;

&lt;p&gt;La alarma original era una métrica derivada de logs de manual. El trabajo loguea una línea cuando termina con 0 resultados; un metric filter la cuenta; la alarma despierta ante una racha sostenida:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 00-output-threshold-alarm/emit.py (la versión ingenua)
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;scanned_items&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;matcher_zero_results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scanned_items&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 00-output-threshold-alarm/alarm.ts&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MetricFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ZeroResultsFilter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;logGroup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;filterPattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FilterPattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"matcher_zero_results"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;metricNamespace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;myapp/Jobs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;metricName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;MatcherZeroResults&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;metricValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;defaultValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;cw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Alarm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ZeroResultsAlarm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;zeroResultsMetric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;evaluationPeriods&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;datapointsToAlarm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// 3 noches seguidas&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El &lt;code&gt;evaluationPeriods: 3&lt;/code&gt; ya es una cobertura: alguien sabía que una sola noche 0 era varianza normal y exigió una &lt;em&gt;racha&lt;/em&gt; antes de despertar. Esa cobertura es la pista. Compra tiempo; no arregla la ambigüedad. Si el conteo puede legítimamente quedarse en 0 por un rato, entonces "3 noches seguidas" no es "roto", es "ya esperamos tres noches". La alarma dispara de todos modos. Nada más te entrenaste a esperar más para la despertada falsa.&lt;/p&gt;

&lt;p&gt;El problema real: &lt;code&gt;created == 0&lt;/code&gt; tiene (al menos) dos causas.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Roto.&lt;/strong&gt; Un umbral o un embedding regresó y &lt;em&gt;nada&lt;/em&gt; pasa la barra ya, la clásica falla de "regresa 0 para siempre". Esto debería despertar, fuerte, rápido.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correctamente callado.&lt;/strong&gt; El pool elegible es chico y cada candidato que &lt;em&gt;podía&lt;/em&gt; emparejar ya lo hizo, o pegó contra una cuota mensual por usuario. El trabajo se está portando perfecto. Esto &lt;strong&gt;nunca&lt;/strong&gt; debería despertar.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Paso 1: diagnostica antes de ajustar
&lt;/h2&gt;

&lt;p&gt;El instinto cuando dispara una alarma "inestable" es subir el umbral o ensanchar la ventana. Resístelo. Primero responde una pregunta: &lt;strong&gt;¿la alarma atrapó lo que dice que atrapó?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;La propia descripción de la alarma culpaba a una regresión. Así que revisé las precondiciones de esa regresión directo, desde datos de producción, antes de tocar cualquier cosa:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- 01-diagnose/funnel.sql: ¿está sano el input? (abreviado)&lt;/span&gt;
&lt;span class="c1"&gt;-- 1. ¿Hay items frescos para emparejar siquiera?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'ACTIVE'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'7 days'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;-- sano: cientos&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. ¿Los candidatos pasan el piso de similitud? (la barra "regresada")&lt;/span&gt;
&lt;span class="c1"&gt;--    Puntúa el item más nuevo contra el pool elegible.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;item_vec&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;best_similarity&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;eligible_users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                               &lt;span class="c1"&gt;-- sano: bien arriba del piso&lt;/span&gt;

&lt;span class="c1"&gt;-- 3. ¿Los usuarios elegibles ya fueron servidos este periodo?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;usage_counters&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;feature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'matcher'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;period&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;this_month&lt;/span&gt;
 &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;               &lt;span class="c1"&gt;-- la respuesta&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Los datos contaban una historia clara: input sano, similitudes sanas, y &lt;strong&gt;9 de 11 usuarios servibles ya estaban en su tope mensual&lt;/strong&gt;. Una ráfaga más temprano en el mes había consumido legítimamente la cuota del pool; las "0 noches" eran el pool sentado en su tope hasta el siguiente reinicio.&lt;br&gt;
Ninguna regresión. La alarma había disparado sobre un sistema &lt;em&gt;correcto&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Este es el paso de carga estructural y el que más fácil se salta. La alarma me entregó una hipótesis ("regresión"); los datos la falsearon. Todo lo que viene después solo vale la pena hacerlo porque me detuve a revisar.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Regla de dedo: la descripción de una alarma es una &lt;em&gt;hipótesis&lt;/em&gt;, no un&lt;br&gt;
diagnóstico. El primer trabajo de un humano de guardia (o de un agente)&lt;br&gt;
es confirmar que la alarma atrapó su falla declarada, no silenciar el&lt;br&gt;
síntoma.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Paso 2: nombra la falla con precisión
&lt;/h2&gt;

&lt;p&gt;Antes de escribir código, escribe la oración que la alarma &lt;em&gt;debería&lt;/em&gt;&lt;br&gt;
codificar. La mía:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Despierta cuando el trabajo escaneó input real y candidatos elegibles&lt;br&gt;
llegaron a la compuerta final &lt;strong&gt;con cuota de sobra&lt;/strong&gt;, y aun así produjo 0&lt;br&gt;
resultados.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Todo lo que esa oración excluye es, por construcción, un no-incidente:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;0 porque ningún candidato llegó a la compuerta final → el pool ya estaba
servido o inactivo. &lt;strong&gt;Callado.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;0 porque cada candidato que llegó a la compuerta estaba bloqueado por
cuota → demanda real, correctamente limitada por tasa. &lt;strong&gt;Callado.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;0 porque candidatos llegaron a la compuerta, tenían cuota, y &lt;em&gt;nada
pasó&lt;/em&gt; → la barra está rota. &lt;strong&gt;Despierta.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nota que esto es una afirmación sobre los &lt;em&gt;estados internos del pipeline&lt;/em&gt;,&lt;br&gt;
no sobre el conteo de salida. Lo que significa que la métrica de salida&lt;br&gt;
nunca lo puede expresar. El emisor tiene que hacerlo.&lt;/p&gt;
&lt;h2&gt;
  
  
  Paso 3: mueve la inteligencia al emisor
&lt;/h2&gt;

&lt;p&gt;La alarma se queda tonta, un umbral sobre un conteo. Hacemos lista la&lt;br&gt;
&lt;em&gt;línea de log&lt;/em&gt; haciendo que el trabajo clasifique su propia corrida de 0 resultados. Dos contadores baratos, acumulados conforme corre el pipeline, capturan la distinción:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 02-instrument-the-emitter/matcher.py (forma)
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NightlyMatcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_reached_gate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;      &lt;span class="c1"&gt;# candidatos que pasaron los pre-filtros
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_quota_blocked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;     &lt;span class="c1"&gt;# descartados SOLO por el tope por usuario
&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_process_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_prefilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# similitud, dedup, ...
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_reached_gate&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;# llegaron a la compuerta
&lt;/span&gt;        &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;                              &lt;span class="c1"&gt;# encaje débil
&lt;/span&gt;            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_has_quota&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_quota_blocked&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;              &lt;span class="c1"&gt;# demanda real, en tope
&lt;/span&gt;                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_create_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El clasificador es una función pura, trivial de razonar y de probar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 02-instrument-the-emitter/classify.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;classify_zero_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reached_gate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quota_blocked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;¿Por qué una corrida que escaneó input real creó 0 resultados?&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;reached_gate&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;quota_blocked&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quiet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;        &lt;span class="c1"&gt;# pool servido/inactivo, o la demanda pegó el tope
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;regression&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;       &lt;span class="c1"&gt;# candidatos + cuota de sobra, nada pasó
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Y la compuerta de emisión enruta a dos eventos de log &lt;em&gt;distintos&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 02-instrument-the-emitter/emit.py
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;scanned_items&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;created_total&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;classify_zero_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_reached_gate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_quota_blocked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quiet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;matcher_quiet_night&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;              &lt;span class="c1"&gt;# solo observ., sin despertar
&lt;/span&gt;                    &lt;span class="n"&gt;reached_gate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_reached_gate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;quota_blocked&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_quota_blocked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;matcher_zero_results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;# la regresión real
&lt;/span&gt;                       &lt;span class="n"&gt;reached_gate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_reached_gate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El metric filter de la alarma no cambia: sigue haciendo match a&lt;br&gt;
&lt;code&gt;"matcher_zero_results"&lt;/code&gt;. Pero esa línea ahora solo aparece para la falla genuina. En una noche callada el trabajo emite &lt;code&gt;matcher_quiet_night&lt;/code&gt; en su lugar, que &lt;strong&gt;no&lt;/strong&gt; hace match al patrón de la alarma, así que la métrica publica su &lt;code&gt;defaultValue&lt;/code&gt; de 0 y la racha se rompe. La despertada falsa desapareció no porque subimos un umbral, sino porque el emisor dejó de mentir sobre la causa.&lt;/p&gt;
&lt;h2&gt;
  
  
  Paso 4: mantén observable el camino benigno
&lt;/h2&gt;

&lt;p&gt;"No despertar" no es "no registrar". Las noches calladas son una señal real: te dicen que el pool servible se está topando, lo cual es un dato de &lt;em&gt;crecimiento&lt;/em&gt;, no un incidente. Así que el evento benigno recibe su propia métrica, sin alarma:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 03-cause-aware-alarm/quiet-metric.ts: visibilidad, no una despertada&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MetricFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;QuietNightFilter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;logGroup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;filterPattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FilterPattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"matcher_quiet_night"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;metricNamespace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;myapp/Jobs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;metricName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;MatcherQuietNight&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;metricValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;defaultValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ahora el dashboard muestra las dos líneas: regresiones (que despiertan) y noches calladas (que no). Cuando alguien pregunta después "¿por qué cayeron&lt;br&gt;
los matches en la segunda mitad del mes?", la respuesta es una gráfica, no una sesión forense de SQL. Y si las noches calladas suben de manera sostenida, esa es la señal para subir los topes o ensanchar la elegibilidad, una decisión de producto que la alarma vieja sepultaba bajo un incidente falso.&lt;/p&gt;

&lt;h2&gt;
  
  
  Las lecciones del umbral
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No subas el umbral para silenciar una despertada falsa.&lt;/strong&gt; Es el
reflejo, y está al revés. Subirlo (o ensanchar la ventana) hace la alarma
&lt;em&gt;más lenta para atrapar la falla real&lt;/em&gt; mientras de todos modos termina disparando por la benigna. Cambias un falso negativo por un falso positivo demorado. Arregla la ambigüedad en su lugar; deja el umbral apretado.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Una cobertura en la config de la alarma es un olor.&lt;/strong&gt; &lt;code&gt;datapointsToAlarm: 3&lt;/code&gt;, &lt;code&gt;evaluationPeriods: N&lt;/code&gt;, un sospechosamente redondo &lt;code&gt;&amp;gt; 10&lt;/code&gt;: cada uno es a menudo un humano dándole la vuelta a una métrica ambigua. A veces es la decisión correcta (un solo throttle está bien; alarma sobre la ráfaga). Pero si la &lt;em&gt;causa&lt;/em&gt; es ambigua, ninguna cantidad de contar rachas la resuelve. Pregúntate cuál de las dos tienes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alarma sobre la falla, mide el resto.&lt;/strong&gt; Exactamente una señal debería despertar: el estado por el que despertarías a un humano. Todo lo de al lado, el cero benigno, el parpadeo reintentado, la demanda limitada por tasa, es una métrica sin acción de alarma. Despertar sobre estados ambiguos es como las alarmas terminan silenciadas.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Trampas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Los metric filters literales hacen match a subcadenas.&lt;/strong&gt; Este me
mordió. El &lt;code&gt;FilterPattern.literal('"matcher_zero_results"')&lt;/code&gt; de
CloudWatch hace match a cualquier evento de log que &lt;em&gt;contenga&lt;/em&gt; esa
cadena. Nombra el evento benigno &lt;code&gt;matcher_quiet_night&lt;/code&gt;, &lt;strong&gt;no&lt;/strong&gt;
&lt;code&gt;matcher_zero_results_ok&lt;/code&gt;: el segundo contiene el patrón de la alarma como subcadena y la dispararía de todos modos, derrotando en silencio todo el arreglo. Elige un token sin traslape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;defaultValue: 0&lt;/code&gt; es lo que rompe la racha.&lt;/strong&gt; El filter emite 0 por
cada periodo sin línea que haga match. Ese es el mecanismo que le permite
a una noche callada "publicar un 0" y reiniciar una alarma de datapoints
consecutivos. Sin él, los datapoints faltantes dependen de
&lt;code&gt;treatMissingData&lt;/code&gt; y la lógica de racha se vuelve turbia. Consérvalo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prueba la compuerta, no la plomería.&lt;/strong&gt; La prueba valiosa no es "¿sirve
el metric filter?" (eso es trabajo de AWS). Es "¿el trabajo emite el
&lt;em&gt;evento&lt;/em&gt; correcto para cada estado interno?". Maneja el emisor y afirma
la línea de log:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 04-test-the-gate/test_emit.py
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_pages_only_on_regression&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NightlyMatcher&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_process_item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reached_gate&lt;/span&gt;&lt;span class="o"&gt;=+&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quota_blocked&lt;/span&gt;&lt;span class="o"&gt;=+&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;structlog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;capture_logs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;matcher_zero_results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;        &lt;span class="c1"&gt;# regresión → despierta
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;matcher_quiet_night&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_quiet_when_quota_capped&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NightlyMatcher&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_process_item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reached_gate&lt;/span&gt;&lt;span class="o"&gt;=+&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quota_blocked&lt;/span&gt;&lt;span class="o"&gt;=+&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;structlog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;capture_logs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;matcher_quiet_night&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;         &lt;span class="c1"&gt;# demanda en tope → callado
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;matcher_zero_results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Una checklist reutilizable
&lt;/h2&gt;

&lt;p&gt;Cuando una alarma dispara y sospechas que es ruido, antes de tocar un&lt;br&gt;
umbral:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Confirma la falla declarada.&lt;/strong&gt; ¿Los datos muestran la condición exacta
que la alarma dice? Si no, la alarma está mal categorizando: un bug de
diseño, no un problema de ajuste.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enumera las causas del valor de la métrica.&lt;/strong&gt; &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;5xx&lt;/code&gt;, &lt;code&gt;lento&lt;/code&gt;,
&lt;code&gt;profundidad de cola&lt;/code&gt;: lista cada razón distinta por la que llega al
valor de la alarma. Si hay más de una, y difieren en si son incidentes,
la métrica es ambigua.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escribe la falla en una oración.&lt;/strong&gt; En términos del estado interno del
sistema, no de su salida. Si la métrica no puede expresar esa oración,
el emisor debe hacerlo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mueve la lógica al emisor.&lt;/strong&gt; Emite una señal distinta por causa.
Despierta sobre la del incidente; mide el resto.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cuida las colisiones de subcadena y el &lt;code&gt;defaultValue&lt;/code&gt;.&lt;/strong&gt; (Ve las
trampas.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prueba la clasificación, no la alarma.&lt;/strong&gt; Afirma que el emisor produce
el evento correcto para cada estado.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  La forma de todo esto
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ANTES:  count == 0  ─────────────────────────► alarma ──► despierta (a veces mal)

DESPUÉS:  corrida termina en 0 ─► clasifica(estado interno) ─┬─ "regression" ─► línea warn ─► alarma ─► despierta
                                                             └─ "quiet"      ─► línea info ─► métrica (sin despertar)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Un principio: &lt;strong&gt;una alarma debería disparar por una causa, no por una&lt;br&gt;
coincidencia.&lt;/strong&gt; Cuando un solo valor de salida tiene múltiples causas, no&lt;br&gt;
ajustes el umbral: empuja el diagnóstico río arriba, al código que ya sabe&lt;br&gt;
&lt;em&gt;por qué&lt;/em&gt; el número es lo que es, y deja que lo diga en la línea de log. La&lt;br&gt;
alarma se queda como un umbral tonto. La inteligencia vive donde está el&lt;br&gt;
contexto. Y el pager suena solo cuando algo de verdad anda mal.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>devops</category>
      <category>monitoring</category>
      <category>sre</category>
    </item>
    <item>
      <title>El día que `pnpm audit` tumbó todos los deploys (y qué correr en su lugar)</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Sun, 19 Jul 2026 19:23:46 +0000</pubDate>
      <link>https://dev.to/aws-builders/el-dia-que-pnpm-audit-tumbo-todos-los-deploys-y-que-correr-en-su-lugar-3leh</link>
      <guid>https://dev.to/aws-builders/el-dia-que-pnpm-audit-tumbo-todos-los-deploys-y-que-correr-en-su-lugar-3leh</guid>
      <description>&lt;p&gt;Un deploy de frontend falló. No había cambiado código en el pipeline, nose había subido ninguna dependencia, el lockfile era byte por byte&lt;br&gt;
idéntico al del deploy que había pasado en verde 23 horas antes. El falló:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERR_PNPM_AUDIT_BAD_RESPONSE  The audit endpoint (at
https://registry.npmjs.org/-/npm/v1/security/audits/quick) responded with
410: {"error":"This endpoint is being retired. Use the bulk advisory
endpoint instead."}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;npm había retirado el endpoint al que &lt;code&gt;pnpm audit&lt;/code&gt; hace POST. Mi compuerta de seguridad ahora estaba fallando por &lt;em&gt;infraestructura&lt;/em&gt;, no por vulnerabilidades, y como era una compuerta dura, bloqueaba &lt;strong&gt;todos&lt;/strong&gt; los deploys de frontend que iban detrás, incluyendo un arreglo de bug sin relación que estaba esperando para salir.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/elchesco" rel="noopener noreferrer"&gt;
        elchesco
      &lt;/a&gt; / &lt;a href="https://github.com/elchesco/blog-pnpm-audit-osv-scanner-code" rel="noopener noreferrer"&gt;
        blog-pnpm-audit-osv-scanner-code
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Code companion — replacing a dead `pnpm audit` gate with osv-scanner
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Code companion — replacing a dead &lt;code&gt;pnpm audit&lt;/code&gt; gate with osv-scanner&lt;/h1&gt;
&lt;/div&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Reading order&lt;/h2&gt;
&lt;/div&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;00-dead-gate/&lt;/code&gt; — what broke. Note it's a &lt;strong&gt;network call&lt;/strong&gt;, not a local
scan; when npm retired the endpoint it failed on infrastructure and
blocked every deploy.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;02-osv-scanner-step/&lt;/code&gt; — scan &lt;code&gt;pnpm-lock.yaml&lt;/code&gt; against the OSV DB. No
endpoint to retire. But osv-scanner is blunt: it fails on &lt;em&gt;any&lt;/em&gt; advisory
across the &lt;em&gt;whole&lt;/em&gt; lockfile, so we discard its exit code (&lt;code&gt;|| true&lt;/code&gt;) and
filter ourselves.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;03-preserve-semantics/audit-prod-gate.mjs&lt;/code&gt; — re-impose the old flags
&lt;code&gt;--prod&lt;/code&gt; (runtime tree from &lt;code&gt;pnpm ls --prod&lt;/code&gt;) and &lt;code&gt;--audit-level high&lt;/code&gt;
(CVSS ≥ 7 from the OSV report). Dev/build deps report but never block.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;04-verify/split.sh&lt;/code&gt; — the test that matters: does it block the thing it
should and pass the thing it should, on your real tree?&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;The two principles&lt;/h2&gt;

&lt;/div&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A gate that fails on infrastructure is worse than no gate.&lt;/strong&gt; It blocks
good work while…&lt;/li&gt;
&lt;/ol&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/elchesco/blog-pnpm-audit-osv-scanner-code" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;pnpm audit --prod --audit-level high&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;osv-scanner + filtro&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cómo obtiene los datos&lt;/td&gt;
&lt;td&gt;POST al endpoint de audit de npm&lt;/td&gt;
&lt;td&gt;escanea el lockfile contra la base de datos de OSV&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Falla cuando retiran el endpoint&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;sí, bloquea todos los deploys&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;no (no hay endpoint)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Solo prod&lt;/td&gt;
&lt;td&gt;integrado (&lt;code&gt;--prod&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;reconstruido desde &lt;code&gt;pnpm ls --prod&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Umbral de severidad&lt;/td&gt;
&lt;td&gt;integrado (&lt;code&gt;--audit-level&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;reconstruido del CVSS en el reporte&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Depende de&lt;/td&gt;
&lt;td&gt;que npm mantenga viva una API legada&lt;/td&gt;
&lt;td&gt;un binario de escáner versionado + una base de datos pública&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Dos ideas:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Una compuerta que falla por infraestructura es peor que no tener compuerta.&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No terceerices un chequeo de seguridad a un endpoint de un
proveedor.&lt;/strong&gt; Escanea un artefacto que tú controlas (el lockfile) contra una base de datos. Los endpoints se retiran; tu lockfile no.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Qué era &lt;code&gt;pnpm audit&lt;/code&gt; en realidad
&lt;/h2&gt;

&lt;p&gt;Parece un escaneo local. No lo es. &lt;code&gt;pnpm audit&lt;/code&gt; serializa tu árbol de&lt;br&gt;
dependencias, le hace POST al endpoint de audit del registry, y renderiza los advisories que regresen. El chequeo &lt;em&gt;es&lt;/em&gt; una llamada de red a una API específica de npm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST https://registry.npmjs.org/-/npm/v1/security/audits/quick
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;npm deprecó ese endpoint (y su hermano &lt;code&gt;/audits&lt;/code&gt;) en favor de una API más nueva de bulk-advisory, y con el tiempo puso los dos en &lt;strong&gt;HTTP 410 Gone&lt;/strong&gt;. Cualquier herramienta que siga llamando al endpoint viejo, incluyendo el &lt;code&gt;pnpm audit&lt;/code&gt; de un montón de imágenes de CI fijadas, ahora falla sin condición. No es "no se encontraron vulnerabilidades", no es "se encontraron algunas": es un error duro, en cada corrida.&lt;/p&gt;

&lt;p&gt;La compuerta había estado en verde por meses. Nada de mi lado cambió. Un tercero retiró una API y mi pipeline de deploy se puso en rojo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Los tres arreglos equivocados
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Ponle &lt;code&gt;|| true&lt;/code&gt;.&lt;/strong&gt; La forma más rápida de desbloquear. También la peor: convertiste una compuerta de seguridad en un comentario. No da ninguna protección y miente en verde para siempre. Si la compuerta no vale la pena arreglarla, &lt;em&gt;bórrala&lt;/em&gt;, no dejes una decorativa en la que la gente confía.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sube la versión de la herramienta.&lt;/strong&gt; Sospecha razonable: quizá un &lt;code&gt;pnpm&lt;/code&gt; más nuevo usa el endpoint nuevo. Lo revisé: tanto la versión fijada como la última le hacen POST al &lt;code&gt;/audits/quick&lt;/code&gt; retirado. El endpoint ya no existe para nadie; subir de versión no lo conjura de vuelta. (Puede variar conforme pnpm migre, pero "ojalá la herramienta se haya movido a la API nueva" no es un plan.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agrega una lista de ignorados.&lt;/strong&gt; Cámbiate a un escáner, luego suprime cada hallazgo que salte. Esto invierte la compuerta: ahora bloquea por default y tú justificas las excepciones con la mano, así que cada advisory nuevo, incluyendo el ruido de dev que el viejo flag &lt;code&gt;--prod&lt;/code&gt; excluía gratis, se vuelve un triaje manual. La lista de negados crece, y el día que caiga uno real queda sepultado en el mismo movimiento con el que descartas el ruido.&lt;/p&gt;

&lt;p&gt;El arreglo correcto mantiene la compuerta con sentido: la misma pregunta, otra fuente de datos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qué debería afirmar la compuerta en realidad
&lt;/h2&gt;

&lt;p&gt;Antes de reemplazarla, escribe qué &lt;em&gt;significaban&lt;/em&gt; los flags viejos, porque un escáner no los va a reproducir a menos que lo obligues:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm audit &lt;span class="nt"&gt;--prod&lt;/span&gt; &lt;span class="nt"&gt;--audit-level&lt;/span&gt; high
             ▲            ▲
             │            └─ piso de severidad: solo HIGH/CRITICAL.
             │               MEDIUM/LOW se reportan, nunca bloquean.
             └─ alcance: solo dependencias de RUNTIME. Las de dev/build son
                ruidosas &lt;span class="o"&gt;(&lt;/span&gt;bundlers, frameworks de pruebas, sus árboles
                transitivos&lt;span class="o"&gt;)&lt;/span&gt; y no llegan a los usuarios.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ese segundo flag es el que la gente olvida, y es de carga estructural. La mayor parte del ruido de CVEs en un proyecto de frontend vive en el árbol de dev. Un reemplazo que escanee el lockfile &lt;em&gt;entero&lt;/em&gt; va a "encontrar más vulnerabilidades" y se va a sentir más minucioso, cuando en realidad nada más bloquea deploys por tooling de build que nunca llega a un usuario. &lt;/p&gt;

&lt;p&gt;Preservar &lt;code&gt;--prod&lt;/code&gt; no es un lujo: es la diferencia entre una compuerta que la gente respeta y una que rodean.&lt;/p&gt;

&lt;h2&gt;
  
  
  El reemplazo: escanea el lockfile, no un endpoint
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/google/osv-scanner" rel="noopener noreferrer"&gt;osv-scanner&lt;/a&gt; lee &lt;code&gt;pnpm-lock.yaml&lt;/code&gt; directo y checa cada paquete contra la &lt;a href="https://osv.dev" rel="noopener noreferrer"&gt;base de datos de OSV&lt;/a&gt;. Ningún POST a npm, nada que retirar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 02-osv-scanner-step/audit.sh (forma)&lt;/span&gt;
osv-scanner scan &lt;span class="nb"&gt;source&lt;/span&gt; &lt;span class="nt"&gt;--lockfile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pnpm-lock.yaml &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; osv.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pero osv-scanner es tosco a propósito: reporta &lt;strong&gt;todos&lt;/strong&gt; los advisories en &lt;strong&gt;toda&lt;/strong&gt; severidad a lo largo del lockfile &lt;strong&gt;entero&lt;/strong&gt;, y sale con código distinto de cero si encuentra cualquier cosa. Apúntalo a un proyecto real y va a fallar por una dependencia transitiva de solo-dev de baja severidad desde el día uno. Tal cual sale de la caja, es la trampa de "escanear el lockfile entero" de la sección anterior.&lt;/p&gt;

&lt;p&gt;Así que no usamos su código de salida. Tomamos su JSON y le volvemos a imponer las dos reglas de la compuerta vieja nosotros mismos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preservando &lt;code&gt;--prod&lt;/code&gt; y &lt;code&gt;--audit-level high&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Dos insumos, un script chico:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;osv-scanner scan &lt;span class="nb"&gt;source&lt;/span&gt; &lt;span class="nt"&gt;--lockfile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pnpm-lock.yaml &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; osv.json &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;pnpm &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;--prod&lt;/span&gt; &lt;span class="nt"&gt;--depth&lt;/span&gt; Infinity &lt;span class="nt"&gt;--json&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; prod.json
node audit-prod-gate.mjs   &lt;span class="c"&gt;# sale con 1 solo ante un HIGH/CRITICAL de runtime&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;El &lt;code&gt;|| true&lt;/code&gt; en el escaneo: el "distinto de cero ante cualquier
advisory" de osv-scanner mataría el paso antes de que corra mi filtro.
Quiero sus hallazgos, no su veredicto.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pnpm ls --prod&lt;/code&gt; da el árbol de dependencias de runtime, el alcance de
&lt;code&gt;--prod&lt;/code&gt;, reconstruido. Lo que no esté ahí es de solo dev/build y no
puede bloquear.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;La compuerta en sí (archivo completo en la carpeta de código):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 03-preserve-semantics/audit-prod-gate.mjs (núcleo)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HIGH_CVSS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;7.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// HIGH empieza en 7.0, CRITICAL en 9.0: los dos bloquean.&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prodNames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                       &lt;span class="c1"&gt;// aplana pnpm ls --prod&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deps&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{}))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;prodNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dependencies&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})(&lt;/span&gt;&lt;span class="nx"&gt;prodTree&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dependencies&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blocking&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pkg&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;osv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;packages&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;prodNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kr"&gt;package&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// solo-dev → salta&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;groups&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                    &lt;span class="c1"&gt;// uno por advisory&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;max_severity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;HIGH_CVSS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// CVSS de OSV&lt;/span&gt;
      &lt;span class="nx"&gt;blocking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kr"&gt;package&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;@&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kr"&gt;package&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blocking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;groups[].max_severity&lt;/code&gt; es el puntaje CVSS que osv-scanner ya calculó: no re derivo la severidad, solo le pongo el umbral. La pertenencia a prod por nombre es conservadora a propósito: si un paquete aparece en cualquier parte del árbol de runtime, trato sus advisories como enviables. Mejor bloquear uno real que dejarlo pasar por un tecnicismo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verificar que hace lo correcto
&lt;/h2&gt;

&lt;p&gt;El valor de una compuerta de seguridad está por completo en su&lt;br&gt;
comportamiento en los bordes, así que prueba ambas direcciones contra el lockfile real. La mía encontró 8 advisories:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Total 2 packages affected by 8 known vulnerabilities
(0 Critical, 3 High, 3 Medium, 2 Low)
  undici     7.26.0   3× HIGH (CVSS 7.4–7.5)   ← solo-dev
  protobufjs 7.6.2    1× MEDIUM (CVSS 5.3)      ← runtime
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;De manera ingenua, "3 vulnerabilidades HIGH" suena a que la compuerta&lt;br&gt;
debería gritar. No debería, y aquí está la prueba de que los dos filtros funcionan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 04-verify/split.sh&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;pnpm why undici &lt;span class="nt"&gt;--prod&lt;/span&gt;
   &lt;span class="o"&gt;(&lt;/span&gt;empty&lt;span class="o"&gt;)&lt;/span&gt;                         &lt;span class="c"&gt;# undici NO está en el árbol de runtime → excluido&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;pnpm why protobufjs &lt;span class="nt"&gt;--prod&lt;/span&gt;
   protobufjs 7.6.2
   └─┬ amazon-chime-sdk-js         &lt;span class="c"&gt;# runtime, pero MEDIUM &amp;lt; HIGH → debajo de la compuerta&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Así que la compuerta &lt;strong&gt;pasa&lt;/strong&gt;, correctamente. Los tres advisories HIGH están en tooling de build que nunca se envía; el único advisory de runtime está debajo del piso de severidad. Para probar que no solo está por encima, baja el umbral a 5.0 en local: entonces marca el &lt;code&gt;protobufjs&lt;/code&gt; de runtime y falla. La compuerta discrimina; no está atorada en abierto.&lt;/p&gt;

&lt;p&gt;Esta es la prueba que importa. No "¿corre el escáner?" sino "¿bloquea lo que debe y deja pasar lo que debe?", sobre tu árbol real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trampas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Descarta el código de salida del escáner, quédate con sus hallazgos.&lt;/strong&gt; osv-scanner sale distinto de cero ante &lt;em&gt;cualquier&lt;/em&gt; advisory. Si no le pones &lt;code&gt;|| true&lt;/code&gt; (o configuras lo suyo), falla el paso antes de que corra tu filtro de prod/severidad, y vuelves a bloquear por ruido del árbol de dev.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fija la versión del escáner.&lt;/strong&gt; &lt;code&gt;releases/latest&lt;/code&gt; puede cambiar la forma de la salida o el comportamiento de falla por default entre corridas, justo la inestabilidad de la que estás tratando de escapar. Fija &lt;code&gt;v2.4.0&lt;/code&gt; (o la que sea), sube de versión a propósito.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Haz la descarga consciente de la arquitectura.&lt;/strong&gt; Los runners ARM
autoalojados y el &lt;code&gt;ubuntu-latest&lt;/code&gt; x86 necesitan binarios distintos.
&lt;code&gt;case "$(uname -m)" in aarch64|arm64) …&lt;/code&gt; le gana a un &lt;code&gt;_amd64&lt;/code&gt; fijo a mano.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide qué significa un CVSS ausente.&lt;/strong&gt; Algunos advisories no traen puntaje CVSS. &lt;code&gt;parseFloat("")&lt;/code&gt; es &lt;code&gt;NaN&lt;/code&gt;, así que no cruza el umbral y no bloquea. Es una decisión deliberada (empatar con el viejo comportamiento etiquetado por severidad), pero tómala a propósito, y loguea el conteo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Que &lt;code&gt;pnpm audit&lt;/code&gt; parezca local engaña a todos.&lt;/strong&gt; Es una llamada de red. Trata cualquier "audit" que le llame a un registry como una dependencia de disponibilidad de tu pipeline, no como un chequeo local.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lo que salté a propósito
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auditar dependencias de dev como compuerta bloqueante.&lt;/strong&gt; No se envían.
Las reporto (osv-scanner imprime las 8) pero solo bloquea un
HIGH/CRITICAL de &lt;em&gt;runtime&lt;/em&gt;, igual que el viejo &lt;code&gt;--prod&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subir versiones automático.&lt;/strong&gt; Renovate/Dependabot es otro ciclo aparte. El trabajo de esta compuerta es &lt;em&gt;detener un deploy malo&lt;/em&gt;, no abrir PRs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  La forma de todo esto
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ANTES:  árbol de dependencias ─POST─► endpoint de npm audit ──► veredicto ──► compuerta
                                             │
                                        (retirado: 410) ──► compuerta en rojo para siempre

DESPUÉS:  pnpm-lock.yaml ──► osv-scanner ──► hallazgos JSON ─┐
          pnpm ls --prod ──► conjunto de paquetes de runtime ┴─► filtro
                                    (prod ∧ CVSS ≥ 7) ──► compuerta
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Un principio, dos mitades. Una compuerta de seguridad debe fallar por&lt;br&gt;
&lt;strong&gt;vulnerabilidades, no por el clima&lt;/strong&gt;, así que escanea un artefacto que tú controlas contra una base de datos, no el endpoint de un proveedor que te puede dar 410 por debajo. Y cuando cambies de herramienta, migra la &lt;em&gt;semántica&lt;/em&gt;, no solo el comando: los flags viejos codificaban decisiones reales (solo runtime, solo HIGH/CRITICAL), y un reemplazo que los suelta en silencio no es más minucioso, es una compuerta distinta y más ruidosa que la gente va a aprender a ignorar.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>React Doctor marcó 1,249 problemas en una SPA de React. Cinco valían la pena arreglar</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Wed, 08 Jul 2026 00:00:04 +0000</pubDate>
      <link>https://dev.to/aws-builders/react-doctor-marco-1249-problemas-en-una-spa-de-react-cinco-valian-la-pena-arreglar-391f</link>
      <guid>https://dev.to/aws-builders/react-doctor-marco-1249-problemas-en-una-spa-de-react-cinco-valian-la-pena-arreglar-391f</guid>
      <description>&lt;p&gt;React Doctor es un linter de cero instalación que escanea un codebase de React buscando problemas de correctitud, seguridad, accesibilidad,&lt;br&gt;
rendimiento y mantenibilidad, luego los rankea y te entrega una lista de arreglos con forma de agente. &lt;/p&gt;

&lt;p&gt;Este post es un reporte de campo: lo corrí sobre una SPA real de React 18 + Vite + TypeScript (~50 rutas, TanStack Query, react-hook-form) y separé lo que de verdad me atrapó.&lt;/p&gt;

&lt;p&gt;El resultado honesto: 1,249 hallazgos, cinco que valía la pena arreglar, incluyendo dos bugs de seguridad reales. Los otros 1,244 fueron una mezcla de ruido, decisiones de criterio, y falsos positivos.&lt;/p&gt;
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Categoría&lt;/th&gt;
&lt;th&gt;Reportados&lt;/th&gt;
&lt;th&gt;Vale la pena actuar&lt;/th&gt;
&lt;th&gt;Por qué la brecha&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Seguridad&lt;/td&gt;
&lt;td&gt;18 warnings&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;13 eran sinks saneados en el servidor (falsos positivos); 1 mina de código muerto + 1 XSS real fueron el oro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bugs&lt;/td&gt;
&lt;td&gt;24 errores, 487 warnings&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1 fuga de timer, 1 key/spread; ~27 &lt;code&gt;exhaustive-deps&lt;/code&gt; piden criterio humano&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accesibilidad&lt;/td&gt;
&lt;td&gt;434 warnings, 1 error&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;el 1 error (&lt;code&gt;aria-selected&lt;/code&gt; faltante) era real; los 434 son ruido de &lt;code&gt;&amp;lt;Label&amp;gt;&lt;/code&gt;/&lt;code&gt;&amp;lt;button type&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rendimiento&lt;/td&gt;
&lt;td&gt;110 warnings&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;nada caliente; candidatos pero sin impacto medido&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mantenibilidad&lt;/td&gt;
&lt;td&gt;176 warnings&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;opiniones de estilo tipo "componente grande"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,249&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;señal-a-ruido ≈ 1 en 250&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;El puntaje que imprimió: &lt;code&gt;39 / 100&lt;/code&gt;. Dos cosas que ese encuadre esconde: los cinco que sacó a la superficie eran de alto valor (una fuga de token en &lt;code&gt;localStorage&lt;/code&gt; y un sink de XSS almacenado), y el conteo &lt;strong&gt;no es determinista&lt;/strong&gt;: una segunda corrida del mismo commit reportó 1,254, no 1,249.&lt;/p&gt;

&lt;p&gt;El veredicto en una línea: &lt;strong&gt;excelente generador de hipótesis, pésima&lt;br&gt;
compuerta.&lt;/strong&gt; Úsalo para encontrar candidatos, verifica cada uno contra el código, y nunca conectes el conteo al CI.&lt;/p&gt;
&lt;h2&gt;
  
  
  Qué es React Doctor
&lt;/h2&gt;

&lt;p&gt;Un solo binario que corres por &lt;code&gt;npx&lt;/code&gt; / &lt;code&gt;pnpm dlx&lt;/code&gt;, sin agregar dependencia a tu proyecto, sin archivo de configuración obligatorio. Parsea tu &lt;code&gt;src/&lt;/code&gt;, hace match contra un conjunto de reglas (sus familias: Seguridad, Bugs, Accesibilidad, Rendimiento, Mantenibilidad), e imprime:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;un &lt;strong&gt;Top 3&lt;/strong&gt; de lo que cree que deberías arreglar primero,&lt;/li&gt;
&lt;li&gt;un &lt;strong&gt;desglose por categoría&lt;/strong&gt; con conteos de error/warning,&lt;/li&gt;
&lt;li&gt;una &lt;strong&gt;advertencia de escala de migración&lt;/strong&gt; cuando una regla abarca
docenas de archivos,&lt;/li&gt;
&lt;li&gt;un bloque de &lt;strong&gt;guía para agente&lt;/strong&gt;: la herramienta está diseñada de
manera explícita para ser manejada por un agente LLM, no solo leída por un humano.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Esa última parte es el punto. React Doctor no arregla nada. Emite&lt;br&gt;
hipótesis rankeadas y le dice al agente que "lea el código relevante antes de confirmar o suprimir cada hallazgo". Tomada al pie de la letra, esa instrucción es el flujo de trabajo completo.&lt;/p&gt;
&lt;h2&gt;
  
  
  Cómo correrlo
&lt;/h2&gt;

&lt;p&gt;Sin instalación. Desde la raíz del paquete del frontend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Top-3 + resumen por categoría&lt;/span&gt;
npx react-doctor

&lt;span class="c"&gt;# Cada hallazgo con archivo:línea y la receta de arreglo&lt;/span&gt;
npx react-doctor@latest &lt;span class="nt"&gt;--verbose&lt;/span&gt;

&lt;span class="c"&gt;# Acotar a un directorio (recomendado para un repo grande)&lt;/span&gt;
npx react-doctor@latest src/components

&lt;span class="c"&gt;# Solo los archivos que cambiaron vs la rama base (el modo amigable con CI)&lt;/span&gt;
npx react-doctor@latest &lt;span class="nt"&gt;--verbose&lt;/span&gt; &lt;span class="nt"&gt;--scope&lt;/span&gt; changed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Si tu toolchain usa pnpm, &lt;code&gt;pnpm dlx react-doctor&lt;/code&gt; es el equivalente. La primera corrida baja ~200 paquetes al caché de dlx; las corridas&lt;br&gt;
siguientes son rápidas.&lt;/p&gt;

&lt;p&gt;El default (sin flags) es la vista de triaje:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;React Doctor v0.7.1

  Top 3 errors you should fix

  ✖ Bugs: Effect subscription or timer never cleaned up
    `setTimeout` creates a timer in useEffect without returning
    cleanup. Return a cleanup function so it does not leak
    after unmount.

    src/components/layout/SearchBar.tsx:114

  ...

  All 1249 issues

  Security › 18 warnings
  Bugs › 24 errors, 487 warnings
  Performance › 110 warnings
  Accessibility › 434 warnings
  Maintainability › 176 warnings
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--verbose&lt;/code&gt; expande cada regla en cada archivo:línea más una URL de docs con una "receta de chequeo de falso positivo". Lee esa frase como una etiqueta de advertencia: la herramienta sabe que sus hallazgos necesitan triaje.&lt;/p&gt;

&lt;h2&gt;
  
  
  Los cinco que importaron
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Un &lt;code&gt;setTimeout&lt;/code&gt; en &lt;code&gt;useEffect&lt;/code&gt; sin cleanup (bug real)
&lt;/h3&gt;

&lt;p&gt;Arriba de la lista, y correcto. Un timer de focus creado al abrir, nunca limpiado:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// antes: el timer se fuga si el componente se desmonta dentro de los 50ms&lt;/span&gt;
&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setRecentSearches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getRecentSearches&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;inputRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// después: el cleanup limpia el timer pendiente&lt;/span&gt;
&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;setRecentSearches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getRecentSearches&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;focusTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;inputRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;focusTimer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Radio de impacto bajo (una llamada de focus de 50ms), pero una fuga real y un arreglo trivial. Esta es justo la clase en la que React Doctor es bueno: mecánica, local, verificable.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. &lt;code&gt;JSON.stringify&lt;/code&gt; hacia un sink de &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;: XSS almacenado (seguridad real)
&lt;/h3&gt;

&lt;p&gt;Este solito justificó todo el ejercicio. El componente de SEO incrustaba JSON-LD de schema.org así:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;jsonLdPayloads&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;script&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"application/ld+json"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;script&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;))}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;JSON.stringify&lt;/code&gt; &lt;strong&gt;no&lt;/strong&gt; escapa HTML. Los payloads cargan campos&lt;br&gt;
controlados por el usuario: un nombre de perfil para mostrar, el título de un post de blog. Un nombre de &lt;code&gt;&amp;lt;/script&amp;gt;&amp;lt;img src=x onerror=alert(1)&amp;gt;&lt;/code&gt; se sale del elemento script. Y como estas etiquetas aterrizan en una &lt;strong&gt;captura de SEO pre-renderizada&lt;/strong&gt; que se sirve a rastreadores y en visitas directas, es XSS almacenado, no reflejado.&lt;/p&gt;

&lt;p&gt;El arreglo es un serializador que escapa los caracteres que pueden&lt;br&gt;
terminar el elemento o el contexto de string de JS, cada reemplazo una secuencia &lt;code&gt;\uXXXX&lt;/code&gt; válida de JSON para que el dato viaje de ida y vuelta sin cambiar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;serializeJsonLd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;u003c&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;u003e&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;amp;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;u0026&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;2028/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;u2028&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;2029/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;u2029&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;script&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"application/ld+json"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;serializeJsonLd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;script&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La etiqueta que la propia React Doctor le puso a esta regla, "Unescaped JSON in HTML or script sink", fue precisa. La herramienta se ganó su lugar con este único hallazgo.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Un cliente axios muerto que acumulaba tokens en &lt;code&gt;localStorage&lt;/code&gt; (seguridad real)
&lt;/h3&gt;

&lt;p&gt;Regla: "Auth token in web storage". Apuntó a &lt;code&gt;src/services/api/client.js:50&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/services/api/client.js (desde el primerísimo commit)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;refreshToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;refresh_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;apiClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/auth/refresh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;refresh_token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;refreshToken&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;access_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Guardar un access token, ya no digamos un refresh token, en &lt;code&gt;localStorage&lt;/code&gt; lo expone a cualquier XSS en la página. Esto contradecía de frente el modelo de auth real de la app: access token solo en memoria, refresh vía una cookie &lt;code&gt;HttpOnly&lt;/code&gt;, implementado en un archivo &lt;em&gt;distinto&lt;/em&gt; (&lt;code&gt;src/lib/axios.ts&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;El detalle: este archivo &lt;strong&gt;nunca se importaba en ningún lado&lt;/strong&gt;. Código muerto del scaffold inicial. No una fuga activa, pero un arma cargada. Quien autocompletara el import equivocado de &lt;code&gt;client&lt;/code&gt; reintroduciría en silencio la vulnerabilidad exacta que el codebase se construyó para evitar. &lt;/p&gt;

&lt;p&gt;Arreglo: bórralo.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rIn&lt;/span&gt; &lt;span class="s2"&gt;"services/api/client"&lt;/span&gt; src   &lt;span class="c"&gt;# → cero imports. Seguro de quitar.&lt;/span&gt;
git &lt;span class="nb"&gt;rm &lt;/span&gt;src/services/api/client.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;React Doctor encontró esto por patrón, no por alcanzabilidad. Que no pueda distinguir "sink vivo" de "mina muerta" es una limitación, pero aquí la mina muerta valía la pena quitarla igual.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. &lt;code&gt;role="option"&lt;/code&gt; sin &lt;code&gt;aria-selected&lt;/code&gt; (a11y real, el único error de a11y)
&lt;/h3&gt;

&lt;p&gt;De 435 hallazgos de accesibilidad, exactamente uno fue error, y estaba bien. Una opción de listbox sin estado de selección expuesto a la tecnología asistiva:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"option"&lt;/span&gt;
  &lt;span class="na"&gt;aria-selected&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// ← agregado&lt;/span&gt;
  &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handleSelect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El padre ya cargaba &lt;code&gt;role="listbox"&lt;/code&gt;; la opción nada más nunca declaraba su estado. Un atributo.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. &lt;code&gt;key&lt;/code&gt; antes de &lt;code&gt;{...spread}&lt;/code&gt; (menor, en el límite)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// marcado&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TourCard&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onCtaClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;// cambiado a&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TourCard&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onCtaClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La regla: un spread puede sobrescribir &lt;code&gt;key&lt;/code&gt;. En la práctica React extrae &lt;code&gt;key&lt;/code&gt; de manera especial y avisa ante un &lt;code&gt;key&lt;/code&gt; en el spread, así que el original difícilmente se iba a portar mal. Reordenar es inofensivo y satisface al linter, pero este es el más débil de los cinco: del tipo de hallazgo que arreglas de pasada, no uno por el que abrirías un PR por su cuenta.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lo que marcó y era ruido
&lt;/h2&gt;

&lt;h3&gt;
  
  
  13 sinks de "HTML injection": todos falsos positivos
&lt;/h3&gt;

&lt;p&gt;El grupo de Seguridad más grande, "HTML injection sink with dynamic&lt;br&gt;
content ×13", listó cada &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt; de la app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ⚠ Security: HTML injection sink with dynamic content ×13
    HTML is injected from a dynamic-looking source, which can
    become XSS if the value is user-controlled or unsanitized.

    src/components/dm/MessageBody.tsx:25
    src/pages/BlogPostPage.tsx:397
    src/pages/ThreadPage.tsx:523
    ... (10 more)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cada uno de estos renderiza HTML que &lt;strong&gt;ya venía saneado del lado del&lt;br&gt;
servidor&lt;/strong&gt; (una pasada de &lt;code&gt;nh3&lt;/code&gt;/ammonia en el backend), la frontera de confianza documentada de la app para contenido de blog, foro, y escrito por agentes. La herramienta ve el sink; no puede ver que el string llegó pre-saneado desde una API. Verificar el grupo me tomó más que arreglar los dos bugs reales. Los 13: descartados.&lt;/p&gt;

&lt;p&gt;Esta es la debilidad de fondo de la herramienta. Un linter estático lee el sink, no el flujo de datos a través de la frontera de red, así que cada &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt; legítimo es un warning que tienes que descartar a mano.&lt;/p&gt;
&lt;h3&gt;
  
  
  Ruido a escala de migración: &lt;code&gt;type&lt;/code&gt; ×242, &lt;code&gt;&amp;lt;Label&amp;gt;&lt;/code&gt; ×329
&lt;/h3&gt;

&lt;p&gt;La propia React Doctor los marca como "muestrea antes de barrer":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ⚠ Migration-scale change: sample before you sweep
    Button missing explicit type ×242 across 83 files
    Label missing associated control ×329 across 69 files
    Large component is hard to read and change ×62 across 60 files
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt; toma por default &lt;code&gt;type="submit"&lt;/code&gt; dentro de un form, así que un &lt;code&gt;type&lt;/code&gt; explícito faltante &lt;em&gt;puede&lt;/em&gt; morder, pero 242 instancias son un codemod mecánico y un diff de 60 archivos que nadie va a revisar con cuidado. Real, de bajo valor, y peligroso de arreglar en masa de una sola pasada. Buena decisión de la herramienta ponerlos en cuarentena; buena decisión tuya no tocarlos en un PR de arreglo de bugs.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;exhaustive-deps&lt;/code&gt; ×27: necesita un humano cada vez
&lt;/h3&gt;

&lt;p&gt;Aquí la herramienta es inusualmente honesta, imprimiendo anti-guía dentro del hallazgo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ⚠ Bugs: Missing effect dependencies ×27
    → Don't blindly add missing dependencies. Read the hook
      callback first.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Una dependencia faltante a veces es un bug y a veces es intencional. No hay arreglo mecánico; cada uno de los 27 es una tarea de lectura de código. Útil como lista para irla trabajando, inútil como "arréglalo todo".&lt;/p&gt;

&lt;h3&gt;
  
  
  El conteo no es determinista
&lt;/h3&gt;

&lt;p&gt;Dos corridas, el mismo commit, sin ediciones entre ellas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;run 1:  All 1254 issues   Bugs › 26 errors, 489 warnings
run 2:  All 1249 issues   Bugs › 24 errors, 487 warnings
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cinco hallazgos aparecieron y desaparecieron entre corridas idénticas. Eso solo descalifica al número crudo como compuerta de CI: un build que falla ante "issues &amp;gt; N" andaría parpadeando.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qué sirve, qué no
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sirve&lt;/th&gt;
&lt;th&gt;No sirve&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Encontrar bugs locales y mecánicos (fugas de timer, cleanup faltante)&lt;/td&gt;
&lt;td&gt;Distinguir un sink vivo de código muerto&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nombrar un sink de XSS real con precisión (el acierto del JSON-LD)&lt;/td&gt;
&lt;td&gt;Ver el saneado del lado del servidor a través de la frontera de red&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cero instalación, cero configuración, un comando&lt;/td&gt;
&lt;td&gt;Producir un número estable y compuertable (conteo no determinista)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rankear un Top 3 que casi siempre vale la pena leer&lt;/td&gt;
&lt;td&gt;Su propio puntaje (&lt;code&gt;39/100&lt;/code&gt;) como métrica con significado&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Emitir guía lista para agente + enlaces de docs por regla&lt;/td&gt;
&lt;td&gt;Cualquier cosa que requiera flujo de datos o intención (modelo de auth, frontera de confianza)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Acotar a una ruta o &lt;code&gt;--scope changed&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Reglas a escala de migración: reales pero irrevisables en bloque&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Cómo usarla de verdad
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Acótala.&lt;/strong&gt; La salida de todo el repo son 1,200+ líneas. Corre
&lt;code&gt;--verbose --scope changed&lt;/code&gt; en un PR, o apúntala a un solo directorio.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dásela a un agente, no a un humano.&lt;/strong&gt; La salida está hecha para eso: rankeada, con archivo:línea y una receta de arreglo por regla. Haz que el agente lea cada archivo marcado y etiquete el hallazgo como verdadero positivo / falso positivo / requiere revisión, &lt;em&gt;con evidencia del archivo&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verifica cada hallazgo de seguridad a mano.&lt;/strong&gt; Dos de los míos eran reales, trece no, y la proporción era invisible desde el resumen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Arregla los de alta confianza que preservan el comportamiento; anota el resto.&lt;/strong&gt; La fuga de timer y el XSS salieron el mismo día. &lt;code&gt;exhaustive-deps&lt;/code&gt; y las reglas de escala de migración se volvieron notas de backlog, no diff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nunca condiciones el CI al conteo crudo.&lt;/strong&gt; Parpadea. Condiciona sobre &lt;em&gt;tu&lt;/em&gt; subconjunto triado y confirmado si de plano tienes que condicionar algo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Divide por familia de regla en PRs separados.&lt;/strong&gt; Los arreglos de seguridad y un barrido de a11y tienen revisores distintos y riesgo distinto. No los amontones.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Lecciones
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;El costo del triaje es el costo real.&lt;/strong&gt; Correr la herramienta tomó segundos; descartar 13 banderas de XSS falso positivo tomó más que escribir los dos arreglos reales y sus pruebas. Presupuesta para la verificación, no para el escaneo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Un linter estático no puede ver tu frontera de confianza.&lt;/strong&gt; Cada &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt; legítimo alimentado por HTML saneado en el servidor es un falso positivo permanente. Eso no es un bug de la herramienta; es el techo del análisis estático.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El mejor hallazgo estaba en código muerto.&lt;/strong&gt; El cliente de token en &lt;code&gt;localStorage&lt;/code&gt; era inalcanzable, así que no había señal de runtime, ni falla de prueba, ni comentario de revisión que lo hubiera atrapado. El match por patrón encontró lo que un análisis de alcanzabilidad habría despriorizado.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Los nombres de regla precisos construyen confianza; un puntaje
resumen la erosiona.&lt;/strong&gt; "Unescaped JSON in HTML or script sink" era
accionable. "39/100" no me dijo nada.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El no-determinismo mata el condicionamiento.&lt;/strong&gt; Un conteo que cambia entre corridas idénticas puede informar a un humano pero nunca puede tronar un build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deja que la herramienta ponga en cuarentena su propio ruido.&lt;/strong&gt; Su advertencia de "muestrea antes de barrer" fue lo más útil que imprimió: detuvo un PR de ruido de 60 archivos antes de que empezara.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>javascript</category>
      <category>react</category>
      <category>security</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Mover un agente de IA siempre encendido de un VPS de $24 a Fargate Spot</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Sun, 05 Jul 2026 00:14:19 +0000</pubDate>
      <link>https://dev.to/aws-builders/mover-un-agente-de-ia-siempre-encendido-de-un-vps-de-24-a-fargate-spot-gd2</link>
      <guid>https://dev.to/aws-builders/mover-un-agente-de-ia-siempre-encendido-de-un-vps-de-24-a-fargate-spot-gd2</guid>
      <description>&lt;p&gt;Tienes un agente de IA auto alojado, del tipo que corre 24/7, se conecta a Slack/Telegram/WhatsApp, y sale a un sandbox de Docker a correr código. Vive en un VPS que pagas esté ocupado o inactivo. Esta es una migración paso a paso a &lt;strong&gt;AWS Fargate Spot&lt;/strong&gt;: el mismo agente, ~60% menos, sin VM que parchar, y con el estado preservado.&lt;/p&gt;

&lt;p&gt;Funciona para cualquier agente siempre encendido que se pueda contenerizar (OpenClaw, un bot de Discord, un trabajador programado).&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/elchesco" rel="noopener noreferrer"&gt;
        elchesco
      &lt;/a&gt; / &lt;a href="https://github.com/elchesco/blog-agent-vps-to-fargate-spot-code" rel="noopener noreferrer"&gt;
        blog-agent-vps-to-fargate-spot-code
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Code companion — move an always-on agent from a VPS to Fargate Spot
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Code companion — move an always-on agent from a VPS to Fargate Spot&lt;/h1&gt;
&lt;/div&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;00-baseline/       inspect the VPS: how it runs, state, image, docker use
01-scaffold/       cluster Spot, log group, SG, EFS + access point, IAM roles
02-migrate-state/  presigned-S3 handoff + one-shot seeder task (extract + patch)
03-run/            gateway task def (EFS volume) + Spot service (ECS Exec on)
04-gotchas/        post-boot fixes: host paths, task-role Bedrock IAM, exec debug
05-cutover/        verify from inside, snapshot, delete the VPS (stops the bill)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Reading order&lt;/h2&gt;
&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;00-baseline/discover.sh&lt;/code&gt; — never guess the workload; inspect it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;01-scaffold/scaffold.sh&lt;/code&gt; — the AWS side, all cheap + reversible.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;02-migrate-state/&lt;/code&gt; — move the state dir onto EFS with no creds on the VPS.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;03-run/&lt;/code&gt; — run the official image on Spot, sandbox disabled.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;04-gotchas/&lt;/code&gt; — the two failures you &lt;em&gt;will&lt;/em&gt; hit right after boot.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;05-cutover/&lt;/code&gt; — verify, then delete the VPS (a stopped one still bills).&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Notes&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No NAT gateway&lt;/strong&gt;…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/elchesco/blog-agent-vps-to-fargate-spot-code" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;
 
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Antes (VPS)&lt;/th&gt;
&lt;th&gt;Después (Fargate Spot)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cómputo&lt;/td&gt;
&lt;td&gt;VM de 2 vCPU / 4 GB, siempre facturada&lt;/td&gt;
&lt;td&gt;tarea de 0.5 vCPU / 4 GB, Spot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Costo&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$24/mes&lt;/strong&gt; fijo&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;~$9/mes&lt;/strong&gt; (tarea ~$8 + EFS ~$1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Estado&lt;/td&gt;
&lt;td&gt;disco de la VM&lt;/td&gt;
&lt;td&gt;EFS (cifrado, persistente)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sandbox de código&lt;/td&gt;
&lt;td&gt;socket de Docker en el host&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;mode: off&lt;/code&gt; (la tarea &lt;em&gt;es&lt;/em&gt; el aislamiento)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operación&lt;/td&gt;
&lt;td&gt;parchar la VM&lt;/td&gt;
&lt;td&gt;imagen inmutable, redesplegar = revisión nueva&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cinco cosas, y el arreglo de cada una:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;El sandbox de Docker del agente no va a correr en Fargate:&lt;/strong&gt; no hay socket de Docker, no hay Docker dentro de Docker. Deshabilítalo; la tarea de Fargate ya es un contenedor aislado.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;La migración del estado necesita EFS + un access point&lt;/strong&gt; fijado al uid del contenedor, o el agente pierde sus sesiones en cada reinicio.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El lift-and-shift filtra rutas absolutas del host&lt;/strong&gt; (&lt;code&gt;/home/ubuntu/...&lt;/code&gt;) hacia config que el contenedor nuevo no puede escribir.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El agente usaba las credenciales implícitas de AWS del host:&lt;/strong&gt; en Fargate usa el &lt;em&gt;task role&lt;/em&gt;, que necesita los permisos de Bedrock (o S3, etc.).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spot es lo correcto para un agente siempre encendido&lt;/strong&gt; &lt;em&gt;si&lt;/em&gt; se
reconecta al reiniciar y su estado está en EFS. Normalmente sí lo hace; eso es ~65% menos.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Decide la forma antes de construir
&lt;/h2&gt;

&lt;p&gt;Tres preguntas determinan el costo y la factibilidad:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;¿Necesita tráfico de entrada?&lt;/strong&gt; Los agentes que salen &lt;em&gt;hacia afuera&lt;/em&gt; a plataformas de chat (long-poll / websocket) &lt;strong&gt;no necesitan balanceador de carga&lt;/strong&gt;: sáltate el ALB (~$16/mes) por completo. Solo agrega uno si un canal empuja webhooks a una URL pública.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;¿Cuánta RAM?&lt;/strong&gt; Los agentes de solo-chat caben en &lt;strong&gt;2 GB&lt;/strong&gt; (0.25 vCPU / 2 GB es una combinación válida de Fargate, la más barata que llega a 2 GB). Si maneja un navegador headless, presupuesta &lt;strong&gt;4 GB&lt;/strong&gt; (necesita ≥0.5 vCPU).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;¿Spot u on-demand?&lt;/strong&gt; Spot es ~65% más barato pero la tarea puede ser reclamada (aviso de ~2 min) cada varios días. Para un agente que se reconecta al arrancar con el estado en EFS, eso es invisible. On-demand solo si una desconexión momentánea rompe algo frágil.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Una nota tosca pero importante: &lt;strong&gt;on-demand a 4 GB (~$28/mes) cuesta más que un VPS de $24.&lt;/strong&gt; Los ahorros vienen de Spot. Ponle precio antes de comprometerte.&lt;/p&gt;
&lt;h2&gt;
  
  
  Paso 0: línea base, qué estás moviendo
&lt;/h2&gt;

&lt;p&gt;No adivines la carga de trabajo, inspecciónala. Métete por SSH al VPS y contesta: ¿cómo corre el agente (systemd? un contenedor?), dónde está su estado, qué imagen, qué config, engendra contenedores de Docker?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 00-baseline/discover.sh (abreviado)&lt;/span&gt;
systemctl list-units &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;service | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; myagent
docker ps &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.Names}} | {{.Image}} | {{.Status}}'&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; ~/.myagent &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; ~/.myagent            &lt;span class="c"&gt;# dir del estado + tamaño&lt;/span&gt;
docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.Config.User}}'&lt;/span&gt; &amp;lt;cid&amp;gt;  &lt;span class="c"&gt;# uid del contenedor&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dos hallazgos cambiaron mi plan:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;El agente corría &lt;strong&gt;nativo vía systemd&lt;/strong&gt;, no como contenedor, así que no había imagen de gateway que levantar. Usaría la &lt;strong&gt;imagen oficial&lt;/strong&gt; del proyecto y montaría el estado.&lt;/li&gt;
&lt;li&gt;Engendraba un contenedor &lt;code&gt;myagent-sandbox&lt;/code&gt; &lt;strong&gt;a través del socket de Docker del host&lt;/strong&gt; para ejecutar código. Esa es la incompatibilidad del paso 3.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;El estado era diminuto (~10 MB: config, memoria SQLite, sesiones de canales, workspace). Todo bajo un solo dir, así que toda la migración es "mueve este dir y apunta el contenedor a él".&lt;/p&gt;

&lt;h2&gt;
  
  
  Paso 1: armar el lado de AWS (sin secretos, todo reversible)
&lt;/h2&gt;

&lt;p&gt;Reutiliza un cluster y una VPC existentes si los tienes; una subred pública con &lt;code&gt;assignPublicIp&lt;/code&gt; da salida &lt;strong&gt;sin NAT gateway&lt;/strong&gt; (~$32/mes ahorrados). Todo aquí es barato y borrable, constrúyelo antes de tocar el agente.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 01-scaffold/scaffold.sh (abreviado, el archivo completo está en la carpeta compañera)&lt;/span&gt;
&lt;span class="c"&gt;# 1. Habilita Spot en el cluster&lt;/span&gt;
aws ecs put-cluster-capacity-providers &lt;span class="nt"&gt;--cluster&lt;/span&gt; my-cluster &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--capacity-providers&lt;/span&gt; FARGATE FARGATE_SPOT &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--default-capacity-provider-strategy&lt;/span&gt; &lt;span class="nv"&gt;capacityProvider&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;FARGATE,weight&lt;span class="o"&gt;=&lt;/span&gt;1

&lt;span class="c"&gt;# 2. Grupo de logs&lt;/span&gt;
aws logs create-log-group &lt;span class="nt"&gt;--log-group-name&lt;/span&gt; /ecs/myagent

&lt;span class="c"&gt;# 3. Security group: egreso a todo, NFS 2049 hacia sí mismo (tarea &amp;lt;-&amp;gt; EFS)&lt;/span&gt;
&lt;span class="nv"&gt;SG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws ec2 create-security-group &lt;span class="nt"&gt;--group-name&lt;/span&gt; myagent &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--description&lt;/span&gt; &lt;span class="s2"&gt;"myagent task + EFS"&lt;/span&gt; &lt;span class="nt"&gt;--vpc-id&lt;/span&gt; vpc-xxxx &lt;span class="nt"&gt;--query&lt;/span&gt; GroupId &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;
aws ec2 authorize-security-group-ingress &lt;span class="nt"&gt;--group-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--protocol&lt;/span&gt; tcp &lt;span class="nt"&gt;--port&lt;/span&gt; 2049 &lt;span class="nt"&gt;--source-group&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# 4. EFS + un mount target por subred + un access point fijado al uid 1000&lt;/span&gt;
&lt;span class="nv"&gt;FS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws efs create-file-system &lt;span class="nt"&gt;--encrypted&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; FileSystemId &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# ... espera a que esté disponible, create-mount-target en cada subred ...&lt;/span&gt;
aws efs create-access-point &lt;span class="nt"&gt;--file-system-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--posix-user&lt;/span&gt; &lt;span class="nv"&gt;Uid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1000,Gid&lt;span class="o"&gt;=&lt;/span&gt;1000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--root-directory&lt;/span&gt; &lt;span class="s1"&gt;'Path=/myagent,CreationInfo={OwnerUid=1000,OwnerGid=1000,Permissions=0755}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El &lt;strong&gt;access point&lt;/strong&gt; es la parte que la gente se salta. Fija cada operación de archivo al uid del contenedor (aquí &lt;code&gt;1000&lt;/code&gt;, revisa el tuyo en el paso 0), para que el agente de verdad pueda escribir su estado, y le da un namespace a esta app en un sub-path del filesystem. Sin él te peleas con errores de permisos de NFS.&lt;/p&gt;

&lt;p&gt;También necesitas dos roles de IAM (con trust a &lt;code&gt;ecs-tasks.amazonaws.com&lt;/code&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;execution role&lt;/strong&gt;: &lt;code&gt;AmazonECSTaskExecutionRolePolicy&lt;/code&gt; (jalar la imagen, escribir logs).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;task role&lt;/strong&gt;: lo que el &lt;em&gt;agente&lt;/em&gt; tiene permitido hacer en tiempo de ejecución. Empieza vacío, recibe Bedrock en el paso 4. Agrega las acciones de SSM ahora para que puedas entrar con &lt;code&gt;ECS Exec&lt;/code&gt; a depurar.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Paso 2: mover el estado a EFS
&lt;/h2&gt;

&lt;p&gt;EFS solo es alcanzable desde adentro de la VPC, así que no puedes hacerle &lt;code&gt;scp&lt;/code&gt;. El camino limpio: haz tar del estado en el VPS, pásalo a través de un objeto privado de S3, y corre una tarea "sembradora" de Fargate de una sola vez que monta EFS y extrae.&lt;/p&gt;

&lt;p&gt;No hacen falta credenciales de AWS en el VPS: genera una &lt;strong&gt;URL PUT&lt;br&gt;
prefirmada&lt;/strong&gt; y hazle &lt;code&gt;curl&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 02-migrate-state/presign.py  -&amp;gt;  imprime una URL PUT prefirmada&lt;/span&gt;
&lt;span class="c"&gt;# En el VPS (detén el agente primero para que SQLite/sesiones queden consistentes):&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl stop myagent
&lt;span class="nb"&gt;sudo tar&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; /home/user &lt;span class="nt"&gt;-czf&lt;/span&gt; /tmp/myagent.tgz .myagent
curl &lt;span class="nt"&gt;-T&lt;/span&gt; /tmp/myagent.tgz &lt;span class="s2"&gt;"&amp;lt;PRESIGNED_PUT_URL&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Deja el agente detenido.&lt;/strong&gt; Es tu rollback. También previene el conflicto de "la misma sesión corriendo dos veces" (una sesión de WhatsApp/Telegram no puede estar viva en dos lugares).&lt;/p&gt;

&lt;p&gt;La tarea sembradora (imagen &lt;code&gt;python:3.12-slim&lt;/code&gt;, con el access point de EFS&lt;br&gt;
montado en &lt;code&gt;/mnt&lt;/code&gt;) descarga, extrae &lt;strong&gt;quitando el dir de nivel superior&lt;/strong&gt; para que el contenido aterrice en la raíz del mount, y parcha la config de paso:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 02-migrate-state/seeder.py (abreviado)
&lt;/span&gt;&lt;span class="n"&gt;tf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tarfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fileobj&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getmembers&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.myagent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;            &lt;span class="c1"&gt;# quita el prefijo ".myagent/"
&lt;/span&gt;        &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;tf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/mnt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Como el access point fuerza el uid 1000, todo aterriza siendo propiedad del usuario del agente, sin necesidad de &lt;code&gt;chown&lt;/code&gt;. Ve &lt;code&gt;02-migrate-state/&lt;/code&gt; para la definición completa de la tarea sembradora y cómo hacerle &lt;code&gt;run-task&lt;/code&gt; y leer sus logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paso 3: correr el agente + la trampa del sandbox
&lt;/h2&gt;

&lt;p&gt;Aquí está la incompatibilidad. Un agente que corre código engendrando&lt;br&gt;
contenedores de Docker hermanos usa el &lt;code&gt;/var/run/docker.sock&lt;/code&gt; del host. &lt;strong&gt;Fargate no tiene socket de Docker ni modo privilegiado.&lt;/strong&gt; Opciones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deshabilita el sandbox de Docker:&lt;/strong&gt; corre las herramientas dentro del proceso del gateway. En Fargate ese proceso es un contenedor endurecido, efímero, sin host: la tarea &lt;em&gt;es&lt;/em&gt; la frontera del sandbox. Este es el más barato y simple.&lt;/li&gt;
&lt;li&gt;Usa un backend de sandbox &lt;strong&gt;SSH/remoto&lt;/strong&gt; apuntando a una caja aparte (agrega una máquina → costo).&lt;/li&gt;
&lt;li&gt;Quédate con el sandbox de Docker → no puedes usar Fargate; usa ECS en EC2 (una instancia Spot chica conserva el socket de Docker).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Yo parché la config para deshabilitarlo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 02-migrate-state/seeder.py — parche del sandbox
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sandbox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;off&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Luego la definición de tarea del gateway monta el access point de EFS en el home del contenedor y corre la imagen oficial:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 03-run/gateway-taskdef.json (campos clave)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"cpu"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"512"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"memory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"4096"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"volumes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"efs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"efsVolumeConfiguration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fileSystemId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fs-xxxx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"transitEncryption"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ENABLED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authorizationConfig"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"accessPointId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fsap-xxxx"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}}]&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"containerDefinitions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gateway"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"image"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ghcr.io/example/myagent:VERSION"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"environment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HOME"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/home/node"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mountPoints"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sourceVolume"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"efs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"containerPath"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/home/node/.myagent"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"linuxParameters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"initProcessEnabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;// habilita ECS Exec&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Crea el servicio en &lt;strong&gt;Spot&lt;/strong&gt;, una tarea, con ECS Exec encendido:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 03-run/create-service.sh&lt;/span&gt;
aws ecs create-service &lt;span class="nt"&gt;--cluster&lt;/span&gt; my-cluster &lt;span class="nt"&gt;--service-name&lt;/span&gt; myagent &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--task-definition&lt;/span&gt; myagent &lt;span class="nt"&gt;--desired-count&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--capacity-provider-strategy&lt;/span&gt; &lt;span class="nv"&gt;capacityProvider&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;FARGATE_SPOT,weight&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network-configuration&lt;/span&gt; &lt;span class="s1"&gt;'awsvpcConfiguration={subnets=[subnet-a,subnet-b],securityGroups=[sg-xxxx],assignPublicIp=ENABLED}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-execute-command&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Paso 4: los dos arreglos que vas a pegar justo después de que arranca
&lt;/h2&gt;

&lt;p&gt;La tarea va a llegar a &lt;code&gt;RUNNING&lt;/code&gt; e incluso conectar sus canales, luego falla en la primera acción real. Dos razones, las dos porque un proceso nativo del host ahora es un contenedor bajo una identidad distinta.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Depurar sin stdout.&lt;/strong&gt; Muchos agentes registran a archivos, no a stdout, así que CloudWatch está vacío. Usa ECS Exec. Meter comillas a través de &lt;code&gt;execute-command&lt;/code&gt; es frágil, hazle base64 a tu script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 04-gotchas/ecs-exec.sh&lt;/span&gt;
&lt;span class="nv"&gt;B64&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; diag.sh&lt;span class="si"&gt;)&lt;/span&gt;
aws ecs execute-command &lt;span class="nt"&gt;--cluster&lt;/span&gt; my-cluster &lt;span class="nt"&gt;--task&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--container&lt;/span&gt; gateway &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--interactive&lt;/span&gt; &lt;span class="nt"&gt;--command&lt;/span&gt; &lt;span class="s2"&gt;"/bin/sh -c &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;echo &lt;/span&gt;&lt;span class="nv"&gt;$B64&lt;/span&gt;&lt;span class="s2"&gt; | base64 -d | sh&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Arreglo 1: rutas absolutas del host.&lt;/strong&gt; La config escrita en el VPS deja fijo &lt;code&gt;/home/ubuntu/.myagent/...&lt;/code&gt;. El home del contenedor es &lt;code&gt;/home/node&lt;/code&gt;, y no puede hacer &lt;code&gt;mkdir /home/ubuntu&lt;/code&gt; → &lt;code&gt;EACCES&lt;/code&gt;. Reescríbela en EFS y reinicia:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 04-gotchas/fix-paths.sh — corre vía ECS Exec, luego detén la tarea para recargar&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; /home/node/.myagent
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rIl&lt;/span&gt; &lt;span class="s1"&gt;'/home/ubuntu'&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; f&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s#/home/ubuntu#/home/node#g'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Arreglo 2: la identidad ahora es el task role, no el host.&lt;/strong&gt; En el VPS el agente llamaba a Bedrock (o S3, SES...) usando credenciales de ambiente. En Fargate el SDK usa el &lt;strong&gt;task role&lt;/strong&gt;. Vas a ver:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;assumed-role/myagent-task-role ... is not authorized to perform:&lt;br&gt;
bedrock:InvokeModelWithResponseStream&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Adjunta exactamente lo que necesita. Para un agente de Bedrock (Claude/Nova), nota que &lt;strong&gt;los inference profiles enrutan a los foundation models a través de regiones&lt;/strong&gt;, así que permites los dos:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 04-gotchas/bedrock-policy.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"bedrock:InvokeModel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bedrock:InvokeModelWithResponseStream"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
             &lt;/span&gt;&lt;span class="s2"&gt;"bedrock:Converse"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bedrock:ConverseStream"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:bedrock:*:111122223333:inference-profile/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:bedrock:*::foundation-model/anthropic.*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:bedrock:*::foundation-model/amazon.nova*"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Los cambios de IAM aplican en la siguiente llamada, sin necesidad de&lt;br&gt;
reiniciar.&lt;/p&gt;
&lt;h2&gt;
  
  
  Paso 5: verifica, luego haz el cambio
&lt;/h2&gt;

&lt;p&gt;Verifica desde adentro antes de borrar nada. Métete con ECS Exec y revisa: el proceso está arriba, el estado migró, sin rutas del host perdidas, y que tiene conexiones de salida vivas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 05-cutover/verify.sh (abreviado)&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/1/cmdline | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'\0'&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt;                       &lt;span class="c"&gt;# ¿gateway corriendo?&lt;/span&gt;
&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'NR&amp;gt;1 &amp;amp;&amp;amp; $4=="01"{c++} END{print c}'&lt;/span&gt; /proc/net/tcp   &lt;span class="c"&gt;# conexiones establecidas&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rIl&lt;/span&gt; &lt;span class="s1"&gt;'/home/ubuntu'&lt;/span&gt; /home/node/.myagent            &lt;span class="c"&gt;# debería estar vacío&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Luego la prueba de verdad: mándale un mensaje al agente en su canal. Cuando responda limpio, haz el cambio:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 05-cutover/teardown.sh&lt;/span&gt;
&lt;span class="c"&gt;# 1. Toma un snapshot del VPS como fallback ANTES de borrar&lt;/span&gt;
aws lightsail create-instance-snapshot &lt;span class="nt"&gt;--instance-name&lt;/span&gt; myagent-vps &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--instance-snapshot-name&lt;/span&gt; myagent-premigration
&lt;span class="c"&gt;# 2. Borra el VPS: esto es lo que detiene la cuenta (una instancia Lightsail&lt;/span&gt;
&lt;span class="c"&gt;#    *detenida* igual cobra; solo borrarla lo hace).&lt;/span&gt;
aws lightsail delete-instance &lt;span class="nt"&gt;--instance-name&lt;/span&gt; myagent-vps &lt;span class="nt"&gt;--force-delete-add-ons&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ese último punto es el que de verdad ahorra dinero: &lt;strong&gt;detener una instancia Lightsail no reduce su costo, tienes que borrarla.&lt;/strong&gt; Conserva el snapshot (~$0.05/GB-mes) hasta que estés confiado, luego bórralo también.&lt;/p&gt;

&lt;h2&gt;
  
  
  Con qué te quedas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Un solo servicio Spot, una tarea, que se reconecta por su cuenta después de un reclamo de Spot, leyendo el estado desde EFS.&lt;/li&gt;
&lt;li&gt;~$9/mes en lugar de $24, ~60% menos, sin VM que parchar.&lt;/li&gt;
&lt;li&gt;Los redespliegues son una revisión nueva de la definición de tarea (sube el tag de la imagen); las ediciones de config son meterte con ECS Exec a la tarea, editar el dir de EFS, reiniciar.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;El patrón se generaliza: cualquier agente siempre encendido, de salida, que se pueda contenerizar y con un dir de estado chico es un buen candidato para Fargate Spot. Las trampas (incompatibilidad del sandbox de Docker, access points de EFS, rutas del host filtradas, y el cambio de identidad de host→task-role) son las mismas cada vez. Arréglalas una vez y el descuento de ~65% de Spot es tuyo.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Una capa de prompts que se califica a sí misma por resultados, hace A/B testing de sus propias reescrituras, e intercambia al ganador casi sin despliegue</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Sat, 04 Jul 2026 22:42:33 +0000</pubDate>
      <link>https://dev.to/aws-builders/una-capa-de-prompts-que-se-califica-a-si-misma-por-resultados-hace-ab-testing-de-sus-propias-131d</link>
      <guid>https://dev.to/aws-builders/una-capa-de-prompts-que-se-califica-a-si-misma-por-resultados-hace-ab-testing-de-sus-propias-131d</guid>
      <description>&lt;p&gt;Casi todo el "manejo de prompts" es una carpeta de archivos &lt;code&gt;.txt&lt;/code&gt; y una revisión de código. Esto es lo opuesto: los prompts viven en la base de datos como filas versionadas, cada agente busca el actual en el momento de la petición, la calidad se puntúa a partir de &lt;strong&gt;lo que de verdad pasó&lt;/strong&gt; (una publicacion fue aceptada, un borrador se publicó, no un LLM calificándose a sí mismo), y un ciclo diario reescribe a los perdedores, les hace A/B testing a las reescrituras, y promueve al ganador de manera automática.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/elchesco" rel="noopener noreferrer"&gt;
        elchesco
      &lt;/a&gt; / &lt;a href="https://github.com/elchesco/blog-self-improving-prompts-code" rel="noopener noreferrer"&gt;
        blog-self-improving-prompts-code
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Code companion — a self-improving prompt layer
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Code companion — a self-improving prompt layer&lt;/h1&gt;
&lt;/div&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;00-starting-point/     NOTES.md            prompts as constants — the baseline
01-versioned-rows/     models.py           PromptVersion / ABTest / PromptFeedback
                       register_prompt.py  publish a version (deactivate prior)
02-resolve-at-request/ resolve_prompt.py   drop-in for FOO_PROMPT.format(...)
                       select_version.py    A/B-aware version picker (+ usage++)
03-score-from-outcomes/ implicit_signals.py downstream outcomes -&amp;gt; success/failure
                       scorer.py           combined_score = explicit·0.4 + implicit·0.6
04-ab-routing/         start_test.py       register variant + open 90/10 test
05-pick-winner/        evaluate.py         min-samples / &amp;gt;=10pt / hard-stop -&amp;gt; promote
06-optimize-loop/      optimizer.py        rewrite underperformers -&amp;gt; PENDING suggestion
07-the-trap/           duplicate_running_abtests.py   the scalar_one_or_none crash + fix
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Suggested reading order&lt;/h2&gt;
&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;00-starting-point/NOTES.md&lt;/code&gt; — why "constant + vibes" stalls.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;01-versioned-rows/&lt;/code&gt; — the prompt becomes data.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;02-resolve-at-request/&lt;/code&gt; — free iteration (row, not deploy) + fallback.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;03-score-from-outcomes/&lt;/code&gt; — &lt;strong&gt;the crux&lt;/strong&gt;: outcomes, not opinions.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;04&lt;/code&gt;–&lt;code&gt;05&lt;/code&gt; — A/B routing + winner rules.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;06-optimize-loop/&lt;/code&gt; — rewrite the losers; the loop closes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;07-the-trap/&lt;/code&gt; — the one bug that took it all down.&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;The one idea&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;
&lt;pre class="notranslate"&gt;&lt;code&gt;combined_score = explicit·0.4 + implicit·0.6&lt;/code&gt;&lt;/pre&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/elchesco/blog-self-improving-prompts-code" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;El ciclo, una vez que un prompt está en el registro:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Etapa&lt;/th&gt;
&lt;th&gt;Qué pasa&lt;/th&gt;
&lt;th&gt;Dónde&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Resolver&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;el agente busca el prompt activo en el momento de la petición (intercambiable en caliente)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resolve_prompt&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Medir&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;explícito (👍/⭐) + &lt;strong&gt;implícito&lt;/strong&gt; (resultado) → &lt;code&gt;combined_score&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;recolector de feedback + trabajador implícito&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Optimizar&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;a diario: reescribe a los de bajo rendimiento con un LLM → una sugerencia PENDING&lt;/td&gt;
&lt;td&gt;optimizador de prompts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Probar&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;abre un A/B test 90/10, enruta el tráfico por versión&lt;/td&gt;
&lt;td&gt;gestor de A/B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Promover&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;tras ≥50 muestras, el ganador por ≥10 pts → actívalo&lt;/td&gt;
&lt;td&gt;gestor de A/B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;La única idea que lo hace funcionar: &lt;strong&gt;&lt;code&gt;combined_score = explícito·0.4 + implícito·0.6&lt;/code&gt;, donde lo implícito es un resultado de negocio real.&lt;/strong&gt; El modelo nunca califica su propia tarea.&lt;/p&gt;

&lt;h2&gt;
  
  
  El punto de partida: prompts como constantes
&lt;/h2&gt;

&lt;p&gt;La línea base que casi todos publican:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SUMMARY_PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;You are an expert reviewer. Summarise: {text}&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;summarise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SUMMARY_PROMPT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dos problemas, los dos invisibles hasta que muerden:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cambiar un prompt es un despliegue.&lt;/strong&gt; Ajustar una coma en el system prompt significa un PR, un build, un rollout. Así que nadie itera: el prompt se queda sin mantener.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"¿El nuevo es mejor?" es una corazonada.&lt;/strong&gt; Cambias la redacción, le echas un ojo a tres salidas, y publicas. No hay medición, así que no hay mejora, solo cambio.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Todo lo de abajo reemplaza "constante + corazonadas" con "fila + puntaje".&lt;/p&gt;

&lt;h2&gt;
  
  
  Capa 1: los prompts son filas versionadas
&lt;/h2&gt;

&lt;p&gt;El prompt se vuelve dato. Una fila por &lt;code&gt;(agent, prompt_type, version)&lt;/code&gt;; una está marcada como activa.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PromptVersion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;__tablename__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt_versions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;agent_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;prompt_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;prompt_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;is_active&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;          &lt;span class="c1"&gt;# exactamente 1 TRUE por (agent, type), normalmente
&lt;/span&gt;    &lt;span class="c1"&gt;# calidad acumulada (llenada por el puntuador, Capa 3)
&lt;/span&gt;    &lt;span class="n"&gt;usage_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;avg_explicit_rating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;explicit_approval_rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;implicit_success_rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;combined_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;performance_trend&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# improving | stable | declining
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Registrar una versión nueva desactiva a la activa anterior en la misma transacción, así que "publicar un prompt nuevo" es una escritura a la base de datos, no un despliegue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;register_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agent_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;activate&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;current_max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_max_version&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt_type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;activate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PromptVersion&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PromptVersion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;agent_name&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;agent_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;PromptVersion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;prompt_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;PromptVersion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_active&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;is_active&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PromptVersion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;agent_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;agent_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;prompt_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;current_max&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt_text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;prompt_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_active&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;activate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Los prompts fijos que ya existían siembran el registro como versión 1, una vez (idempotente). Esa semilla es también el &lt;strong&gt;fallback&lt;/strong&gt;, ve la siguiente capa.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capa 2: los agentes resuelven el prompt en el momento de la petición
&lt;/h2&gt;

&lt;p&gt;El intercambio que hace gratis la iteración. En lugar de&lt;br&gt;
&lt;code&gt;SUMMARY_PROMPT.format(...)&lt;/code&gt;, el agente llama a un resolvedor que lee la fila actual:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agent_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;template_vars&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fallback_text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Reemplazo directo de `FOO_PROMPT.format(**vars)`, pero el texto viene del
    registro. Cae al constante fijo si el registro no tiene fila (despliegue
    fresco, o las tablas de aprendizaje todavía no están sembradas).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;version_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nc"&gt;PromptRegistry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;agent_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;agent_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;prompt_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;template_vars&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;template_vars&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;LookupError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fallback_text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;template_vars&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dos detalles que no son obvios:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;El fallback es el prompt fijo.&lt;/strong&gt; El registro es un &lt;em&gt;override&lt;/em&gt;, nunca una dependencia dura. Un entorno de dev con las tablas de aprendizaje vacías se comporta exactamente como el código viejo basado en constantes. Puedes adoptarlo un agente a la vez.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;get_prompt&lt;/code&gt; incrementa &lt;code&gt;usage_count&lt;/code&gt; y regresa el &lt;code&gt;version_id&lt;/code&gt;.&lt;/strong&gt; Ese id se estampa en la traza de la petición, así que cuando un resultado aterriza después (Capa 3) se atribuye a la versión &lt;em&gt;exacta&lt;/em&gt; del prompt que lo produjo. Sin ese enlace, nada de la puntuación significa algo.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Después de esta capa, cambiar un prompt en prod es un &lt;code&gt;UPDATE&lt;/code&gt; (o una fila activa nueva), en vivo en la siguiente petición, sin despliegue. Lo cual levanta la pregunta de verdad: &lt;strong&gt;¿cómo sabes que la fila nueva es mejor?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Capa 3: calidad desde los resultados, no desde las opiniones
&lt;/h2&gt;

&lt;p&gt;Este es el meollo, y donde la mayoría de la "evaluación de prompts" se equivoca. Dos tipos de señal, ponderadas a propósito:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Explícita:&lt;/strong&gt; el usuario te dice. Un 👍/👎 o una estrella del 1 al 5 sobre la salida. Honesta pero &lt;strong&gt;escasa y sesgada&lt;/strong&gt; (la gente califica cuando está molesta). Vale la pena recolectarla, vale la pena ponderarla de menos.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Implícita:&lt;/strong&gt; el mundo te dice. ¿La cosa que el agente produjo de verdad &lt;em&gt;funcionó&lt;/em&gt;? Estos son eventos de negocio reales, atados de regreso a la versión del prompt vía la traza de la Capa 2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Las señales implícitas son resultados, no la autoevaluación del modelo:
#   blog_accepted        — la publicacion que el agente ayudó a proponer fue ACEPTADA
#   post_published      — el borrador que el agente escribió se publicó
#   message_converted   — el mensaje que el agente redactó convirtió un lead
#   doc_reuploaded — (negativo) el usuario re-subió → la primera pasada falló
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Un worker recorre los resultados recientes y registra cada uno como un éxito/fracaso contra la versión que lo generó. Luego el puntuador acumula todo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# combined_score = explícito·0.4 + implícito·0.6
&lt;/span&gt;&lt;span class="n"&gt;explicit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;avg_rating&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;approval_rate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;
&lt;span class="n"&gt;implicit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;implicit_success_rate&lt;/span&gt;            &lt;span class="c1"&gt;# 0–100, la verdad de combined = explicit * 0.4 + implicit * 0.6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lo implícito pesa más que lo explícito (0.6 vs 0.4) a propósito.&lt;/strong&gt; Un prompt que la gente &lt;em&gt;dice&lt;/em&gt; que le gusta pero cuyas propuestas de blog nunca se aceptan es un peor prompt que uno tosco que sí consigue aceptaciones. Los resultados son la verdad de fondo; las calificaciones son una pista.&lt;/p&gt;

&lt;p&gt;La misma pasada calcula una &lt;strong&gt;tendencia&lt;/strong&gt; comparando el &lt;code&gt;combined_score&lt;/code&gt; de hoy con el puntaje del mismo prompt de hace una semana (&lt;code&gt;improving | stable | declining&lt;/code&gt;). La tendencia, no solo el puntaje absoluto, es lo que marca un prompt que se está pudriendo calladito mientras el mundo cambia a su alrededor.&lt;/p&gt;

&lt;p&gt;El principio que vale la pena tatuarse en la pared: &lt;strong&gt;nunca dejes que el modelo califique su propia salida.&lt;/strong&gt; "Pídele a GPT que califique esta respuesta del 1 al 10" mide fluidez, no utilidad. Ata el puntaje a algo que pase &lt;em&gt;después&lt;/em&gt; de que la respuesta sale del sistema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capa 4: enrutamiento A/B por versión
&lt;/h2&gt;

&lt;p&gt;Un prompt candidato nuevo no se activa: se registra como una &lt;strong&gt;variante&lt;/strong&gt; y un A/B test enruta una rebanada del tráfico hacia él:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# start_test: registra la variante (NO activa), abre un test 90/10
&lt;/span&gt;&lt;span class="n"&gt;variant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register_prompt&lt;/span&gt;&lt;span class="p"&gt;(...,&lt;/span&gt; &lt;span class="n"&gt;activate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ABTest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...,&lt;/span&gt; &lt;span class="n"&gt;prompt_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...,&lt;/span&gt;
              &lt;span class="n"&gt;control_version_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;control&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;variant_version_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="n"&gt;control_traffic_pct&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;variant_traffic_pct&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="n"&gt;min_samples&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RUNNING&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La resolución (Capa 2) revisa primero si hay un test RUNNING y tira los dados:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;test&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;running_test_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt_type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;chosen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;variant_version_id&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;variant_traffic_pct&lt;/span&gt; \
             &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;control_version_id&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;version_by_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chosen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;active_version&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt_type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# sin test → la fila activa
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;90/10, no 50/50: una variante mala solo grava el 10% del tráfico mientras se prueba a sí misma o muere. El control sigue sirviendo el otro 90%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capa 5: declarar un ganador (o no)
&lt;/h2&gt;

&lt;p&gt;La evaluación corre en un calendario. Las reglas son aburridas a propósito: lo aburrido es lo que te evita promover ruido:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage_count&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;min_samples&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="c1"&gt;# &amp;lt; 50 usos
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_enough_samples&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;                    &lt;span class="c1"&gt;# sigue esperando
&lt;/span&gt;
&lt;span class="n"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;combined_score&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;control&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;combined_score&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                                &lt;span class="c1"&gt;# WINNER_DIFF_PTS
&lt;/span&gt;    &lt;span class="n"&gt;winner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;variant&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;control&lt;/span&gt;
    &lt;span class="nf"&gt;activate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;winner&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;COMPLETED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;    &lt;span class="c1"&gt;# promover — intercambio en caliente
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;variant_win&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;control_win&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage_count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                     &lt;span class="c1"&gt;# HARD_STOP_SAMPLES
&lt;/span&gt;    &lt;span class="nf"&gt;activate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;control&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;COMPLETED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# sin ventaja clara → quédate con el control
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inconclusive&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;still_running&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;                             &lt;span class="c1"&gt;# todavía no hay suficiente separación
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tres umbrales, cada uno se gana su lugar:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;min_samples = 50&lt;/code&gt;:&lt;/strong&gt; por debajo de esto, un "gane" es puro azar. No mires todavía.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;|diff| ≥ 10 puntos&lt;/code&gt;:&lt;/strong&gt; una ventaja de 2 puntos es ruido. Exige una brecha real.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;hard_stop = 500&lt;/code&gt;:&lt;/strong&gt; si 500 usos no los pueden separar, son equivalentes; quédate con el titular y deja de desperdiciar tráfico. Sin un tope duro, los tests inconclusos corren por siempre.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;La promoción es nada más &lt;code&gt;activate(winner)&lt;/code&gt;: el mismo intercambio en&lt;br&gt;
caliente de una escritura de la Capa 1. El prompt nuevo está en vivo en la siguiente petición.&lt;/p&gt;
&lt;h2&gt;
  
  
  Capa 6: cerrar el ciclo, reescribir a los perdedores
&lt;/h2&gt;

&lt;p&gt;Hasta ahora un humano todavía tiene que &lt;em&gt;escribir&lt;/em&gt; la variante. La última pieza automatiza eso. Un optimizador diario encuentra a los de bajo rendimiento:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# de bajo rendimiento si CUALQUIERA:
#   usage_count &amp;gt;= 30 AND combined_score &amp;lt; 40          (suficientes datos, mal puntaje)
#   performance_trend == "declining" AND combined_score &amp;lt; 60   (pudriéndose)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…le pasa el prompt débil + sus puntajes a un LLM ("aquí hay un prompt y cómo le está yendo; reescríbelo para mejorar los resultados"), y archiva la reescritura como una &lt;strong&gt;sugerencia PENDING&lt;/strong&gt;. El gestor de A/B (Capa 4) toma las sugerencias PENDING y abre tests. El ciclo ahora corre de punta a punta:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sembrar → resolver → medir (resultados) → optimizar (reescribir) → A/B → promover → resolver …
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Un humano todavía aprueba lo que entra a un test: el optimizador sugiere, no auto despliega a prod. Pero la escritura, la puntuación, y la promoción son automáticas.&lt;/p&gt;

&lt;h2&gt;
  
  
  La trampa: un bug tumbó cada query
&lt;/h2&gt;

&lt;p&gt;Una función de autoservicio empezó a fallar con:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Multiple rows were found when one or none was required
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El selector de versión del resolvedor usaba &lt;code&gt;scalar_one_or_none()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;test&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ABTest&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ABTest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RUNNING&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                         &lt;span class="n"&gt;ABTest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;agent_name&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                         &lt;span class="n"&gt;ABTest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ptype&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;scalar_one_or_none&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;# ← lanza MultipleResultsFound con 2 filas
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nada en el esquema fuerza un &lt;em&gt;único&lt;/em&gt; test RUNNING por prompt, y se habían abierto dos con 13 segundos de diferencia. De ahí en adelante, &lt;strong&gt;cada&lt;/strong&gt; petición para ese prompt pegaba contra &lt;code&gt;scalar_one_or_none()&lt;/code&gt;, encontraba dos filas, y lanzaba excepción. Peor, el resolvedor solo atrapaba &lt;code&gt;LookupError&lt;/code&gt;, así que la excepción se pasó de largo el fallback y salió hasta el usuario.&lt;/p&gt;

&lt;p&gt;Dos arreglos, los dos necesarios:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 1) Tolera duplicados en lugar de tronar: elige el más nuevo, registra la anomalía.
&lt;/span&gt;&lt;span class="n"&gt;tests&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ABTest&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(...).&lt;/span&gt;&lt;span class="nf"&gt;order_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ABTest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;scalars&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tests&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;multiple_running_abtests&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ptype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tests&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;test&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tests&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tests&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- 2) De duplica el dato: cancela el test RUNNING más viejo.&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;prompt_ab_tests&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'CANCELLED'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;older&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La lección se generaliza: &lt;strong&gt;&lt;code&gt;scalar_one_or_none()&lt;/code&gt; es un crash latente en cualquier lado donde "solo debería haber uno" no es una restricción de la base de datos.&lt;/strong&gt; Si el invariante no lo fuerza un índice único, la ruta de lectura tiene que tolerar la violación, no darla por descartada.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lo que NO ayudó
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Calidad autocalificada.&lt;/strong&gt; Pedirle al modelo que puntúe su propia salida medía fluidez, nunca utilidad. Reemplazado por completo por los resultados de aguas abajo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Splits A/B 50/50.&lt;/strong&gt; Gravar la mitad del tráfico con una variante sin probar. 90/10 prueba una variante igual de bien con un décimo del radio de impacto.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El puntaje absoluto solo.&lt;/strong&gt; Un prompt en 70 que va &lt;em&gt;declinando&lt;/em&gt; es un problema más grande que uno estable en 60. La tendencia atrapó la pudrición que el puntaje escondía.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Suponer un solo test RUNNING.&lt;/strong&gt; Sin restricción única → &lt;code&gt;scalar_one_or_none&lt;/code&gt; era una bomba de tiempo.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lecciones
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Puntúa sobre resultados, no sobre opiniones.&lt;/strong&gt; Todo el sistema se para sobre las señales implícitas: eventos reales atados a la versión del prompt. Todo lo demás es decoración.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Haz gratis el cambiar un prompt.&lt;/strong&gt; Un registro + resolución en el momento de la petición convierte "cambiar un prompt" de un despliegue a una fila. La iteración gratis es la precondición para mejorar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El override tiene que degradar al constante.&lt;/strong&gt; Un fallback fijo
significa que adoptas de manera incremental y nunca endureces una
dependencia del ciclo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Los umbrales aburridos le ganan a los ingeniosos.&lt;/strong&gt; El mínimo de
muestras, un requisito de brecha real, y un tope duro son lo que te evita promover ruido.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Radio de impacto chico para los cambios sin probar.&lt;/strong&gt; 90/10, no 50/50.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Si no es una restricción de la base de datos, no es un invariante.&lt;/strong&gt; Las rutas de lectura tienen que tolerar la segunda fila "imposible".&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>automation</category>
      <category>llm</category>
    </item>
  </channel>
</rss>
