Skip to main content

Evaluación de proveedores de web search / fetch para Driftless

Base: staging — es donde vive el Radar, el warehouse GTM y los cuatro Source Packs. main no tiene apps/api/src/radar/. Estado: el núcleo provider-neutral está implementado y verde; los adapters y el benchmark no, y no pueden estarlo sin capturas reales contra cuentas de pago.

Estado por tarjeta

Radar + chat: 705 tests en verde, 0 fallos. Todo lo implementado es provider-neutral: nada de esto cambia según quién gane el benchmark, que es exactamente lo que permite decidir el proveedor al final en vez de al principio. Fecha de los precios: 2026-08-01, verificables en las fuentes del final. Proyecto: independiente. No toca la arquitectura DeepSeek/Luna ni el gateway de inferencia. Artefactos:

0. Frontera del proyecto (lo primero, porque es lo que más fácil se rompe)

Este proyecto no comparte código, despliegue ni cadencia con la arquitectura de inferencia:
  • No toca libs/model-gateway ni apps/api/src/agent-runs/model-gateway.service.ts. Un proveedor de web search no es un modelo. No entra al catálogo de modelos, no usa provider-credential.service, no aparece en model-list, no consume el presupuesto de tokens.
  • No bloquea el gateway de inferencia. Toda llamada web es asíncrona respecto del stream del chat y tiene AbortSignal. Si el proveedor web cae, el chat responde con lo que hay en el almacén y lo dice; nunca se queda esperando.
  • Vive en apps/api/src/radar/, bajo el mismo patrón port/adapter que ya existe para DiscoveryProviderPort y EndpointVerifierPort, y bajo el mismo candado (radar-architecture.spec.ts).
  • El contrato es provider-neutral por construcción. El propio candado ya exige que ports/discovery-provider.port.ts no mencione a parallel ni a exa; el nuevo port hereda esa regla.

Nota sobre las referencias del encargo

docs/architecture/commercial-intelligence-chat-plan.md no existe en ninguna rama. El resto del encargo sí: apps/api/src/radar/** vive en staging (80 commits por delante de main), y este documento está anclado ahí, más el contexto Driftless del área InteligenciaComercial (commercial-intelligence-system-dictionary, fabrica-de-fuentes-arquitectura, gtm-claim-vocabulary-allowlist, gtm-dynamic-freshness-scheduling, opportunity-flow-research-contracts, radar-comercial-parallel-roadmap). Una diferencia que importa para el golden set: COFEPRIS no está en staging — vive en work/cofepris-warehouse, tres commits por delante. Los Source Packs registrados en staging son denue, iieg-jalisco, dirind y company-site. Consecuencia de diseño, no de redacción: la tabla de autoridad por claim_type (§5.5) se construye desde los packs realmente registrados, no desde una lista fija. Si no lo hiciera, los diez casos forbidden del golden set que citan COFEPRIS estarían protegiendo un registro que en staging todavía no existe — y “protegido por un pack ausente” es indistinguible de “no protegido”.

0.1 Qué ya existe en staging y qué añade este diseño

Esta sección es la que decide el tamaño real del trabajo. La parte cara ya está construida.

Ya existe (y el diseño lo reutiliza tal cual)

El hallazgo

ParallelAdapter en staging llama solo a /v1beta/findall/* y /v1/monitors. Las dos primitivas de $0.001 —Search y Extract— no se usan en ninguna parte del repo. Es decir: hoy el sistema solo sabe comprarle a Parallel su producto caro (FindAll: 2.00+2.00 + 0.15/match en core). El comentario de noteWarehouseCoverage lo dice con la factura en la mano — “a $6 Parallel run for 59 companies”. Las primitivas baratas ya están contratadas, cubiertas por la misma PARALLEL_API_KEY desplegada, y sin usar.

Lo que este diseño añade

Cómo encaja con el aviso de cobertura que ya existe

noteWarehouseCoverage() es informativo a propósito: su comentario advierte que nunca debe bloquear ni descontar la cotización, porque un tropiezo del warehouse no puede romper el flujo de dinero que tiene al lado. Ese diseño se respeta. El router no lo convierte en compuerta. Vive una capa abajo, en adquisición, y lo que hace es darle al aviso una tercera opción que hoy no existe. Hoy la cotización es binaria: pagas la búsqueda nueva, o nada. Con las primitivas baratas hay un escalón intermedio:
Ya tengo 340 empresas de Jalisco en el almacén. · Rellenar huecos de las que ya tengo — ~0 créditos · Búsqueda nueva y exhaustiva — 25 créditos
Ese escalón intermedio es el producto de este proyecto. El ahorro de centavos por request es secundario; lo que cambia es la conversación.

0.2 Alcance: qué NO hace este proyecto

Escrito explícitamente porque tres ideas adyacentes salieron durante el diseño, son buenas, y ninguna entra. Dejarlas anotadas aquí evita que reaparezcan como “mejoras” a mitad de implementación. Lo que sí entra: el port, sus adapters, el router de huecos, la ingesta gobernada y la evaluación. Nada más.

La compuerta de costo, versión mínima

La pregunta “¿cómo hago que Parallel solo se use cuando toca?” se resuelve dentro del router, sin motor de políticas y sin tocar nada compartido. El router arma el cinturón; el modelo no elige escalar: Dos propiedades que esto compra gratis:
  1. La escalada se demuestra, no se afirma. El “escalón anterior falló” no es lo que el agente dice: es una consulta a gtm_provider_attempts, que ya registra outcome por intento. El agente no puede convencerse a sí mismo de que ya intentó.
  2. Compone hacia adelante en vez de refactorizarse. Las herramientas se declaran con el costClass correcto desde el día uno. El día que se encienda tool-policy.ts —su propio comentario ya promete que los reads irán “bounded by the tool’s outputBudget/costClass”— heredan el comportamiento sin tocarlas.
Y a prueba de inyección por construcción: una página comprometida no puede llamar a una herramienta que no está en el cinturón. No depende de disciplina de prompt.

1. Recomendación comparativa

1.1 El veredicto

1.2 Esta recomendación es provisional y falsable

No se asume que Parallel sea la respuesta. Es la entrada por defecto al benchmark, elegida por precio verificado y por costo de integración cero. El shadow benchmark (§11) la degrada automáticamente si ocurre cualquiera de estas tres cosas:
  1. citation_coverage < 0.95 sobre el golden set — es decir, sus excerpts no producen spans re-derivables contra el artifact almacenado. Sería fatal: toGatewayResult() descarta las claims sin citación, así que ese gasto se tira entero en la serialización.
  2. freshness_accuracy < 0.85publish_date equivocado es peor que ausente.
  3. costo_por_respuesta_util > 1.5× el par Serper+Jina en las categorías espanol-mexicano y ambigua. Ahí es donde un índice global suele ser delgado y donde Google es fuerte.
Si se cumple (1) o (3), el par Serper + Jina Reader pasa a principal. Si se cumple (2), Parallel sigue como buscador pero la fecha se deriva del documento que nosotros traemos por fetch(), no de lo que el proveedor declara.

1.3 Por qué el precio por request no decide

Serper es 3× más barato por request que Parallel turbo. Y aun así no es el principal recomendado. La razón está en el §7 (costo por respuesta útil), pero se resume en una frase: Serper devuelve snippets sin fecha ni span, así que cada resultado necesita una segunda llamada para convertirse en evidencia, y una tercera para fecharla. Parallel devuelve las tres cosas en una. Al precio absoluto de ambos —céntimos por cuenta— la diferencia de precio es ruido frente a la diferencia de pasos y de superficie de fallo. Y hay un costo que la tabla de precios nunca muestra: un proveedor nuevo cuesta una revisión legal, un secreto en producción, un adapter, un candado, un runbook y una entrada en el registro de subprocesadores. Parallel ya pagó todo eso.

1.4 Descartados, y por qué

1.5 El dato que justifica todo el diseño

En los últimos doce meses, en esta categoría exacta:
  • Microsoft retiró las Bing Search APIs por completo (2025-08-11).
  • Google cerró Custom Search JSON API a clientes nuevos y anunció su retiro (2027-01-01).
  • Brave eliminó su capa gratuita (febrero 2026).
  • Exa subió el precio de búsqueda un 40 % (marzo 2026: 55 → 7 / 1k).
Cuatro cambios unilaterales y disruptivos de proveedor en un año. El riesgo de proveedor en web search no es hipotético: está demostrado. Ese es el argumento real a favor del port, de tarifar cada pack y de tener siempre un fallback vivo — no la elegancia arquitectónica.

2. Matriz de capacidades

Precios en USD, verificados al 2026-08-01. n/d = el proveedor no lo publica, que en este documento cuenta como desconocido, nunca como ilimitado.

2.1 Búsqueda

2.2 Fetch / extract

2.3 Gobernanza: licencia, privacidad, región

Esta tabla es la que decide si los bytes pueden persistirse en gtm_artifacts — no es un anexo legal, es un campo del contrato (WebContentLicense). Regla de arranque: todo adapter nace declarando { mayStoreArtifact: false, mayDisplayExcerpt: false, mayRedistribute: false }. Cada true requiere una cita del contrato en license_note. Un true por defecto es cómo se persiste algo que no se podía persistir.

3. Arquitectura

Nada de esto es infraestructura nueva. Los tres stores (ArtifactStoreService, ObservationStoreService, EvidenceClaimStoreService), el ledger de obstrucciones, el de intentos de proveedor, el de créditos y el lifecycle de Source Packs ya existen y ya tienen tests de integración. Lo único genuinamente nuevo es el port, sus adapters y el router. Ese es el argumento de integración más fuerte del diseño: la parte cara ya está construida.

3.1 El contrato

Contrato completo y comentado: web-search/web-search-provider.port.ts. La forma, en seis líneas:
Seis decisiones que el contrato codifica, y por qué cada una:
  1. estimate() es síncrono y puro. Corre antes de que el humano vea un precio. Si pudiera llamar a la red podría gastar; si pudiera cobrar, cobraría por una cotización. Es la misma razón por la que DiscoveryProviderPort.draftFromBrief está documentado como compilación, no como búsqueda. Devuelve usdExpected y usdWorstCase, porque cotizar el caso esperado y comerse la cola es exactamente cómo el piso de margen del 60 % deja de sostenerse sin que nadie lo note.
  2. fetch() y extract() son verbos distintos. fetch() = tenemos los bytes, los hasheamos, el artifact es de primera mano y el span se re-deriva del blob almacenado. extract() = el proveedor tuvo los bytes y nos da su lectura; el artifact es provider_response y el span apunta al markdown del proveedor. Fusionarlos permitiría que una respuesta de proveedor se hiciera pasar por una página que leímos — el tipo exacto de pudrición de procedencia que el Evidence Ledger existe para impedir.
  3. capabilities().spanKinds. Un adapter que solo devuelve prosa declara [], y el router deja de enviarle huecos de evidencia: la claim resultante se caería igualmente en toGatewayResult(). Mejor no gastar.
  4. UntrustedText es un tipo marcado. Todo string que escribió un tercero entra marcado; la única salida es renderUntrusted(). No es teatro: la tríada letal aquí es real (el Radar lee criterios privados del workspace, ingiere páginas arbitrarias y puede escribir records), y un tipo marcado es la única defensa que no compila si se olvida.
  5. partial + missing[] obligatorios. Nunca truncar en silencio. La misma doctrina de WAREHOUSE_MAX_LIMIT (que lanza en vez de recortar) y de GatewayResult.truncated.
  6. FAILURE_CLASS_OF es una tabla de datos. Cada fallo cae en exactamente una de las 12 clases cerradas de obstrucción. Como dato y no como switch dentro de un catch, porque la revisión matutina agrega por clase y un modo de fallo que no se puede archivar es un modo de fallo que nadie cuenta.

3.2 Lo que el port deliberadamente no hace

El proveedor no asigna source_family. La familia se decide por el host final tras redirecciones, en el dominio (HostClassification). Dos proveedores que devuelven eleconomista.com.mx devuelven la misma familia y el mismo origen; contar eso como corroboración fabricaría confianza a partir de una decisión de routing. Es exactamente el error que corroboration.ts ya evita para iieg-jalisco vs denue — y la sindicación lo hace peor: un teletipo de agencia reproducido por cuatro medios es un origen.

4. Routing recomendado

Discovery exhaustiva es otro producto, no el siguiente escalón. Requiere CreditsService.quote() + aprobación humana explícita. La razón es económica y está en los números: un hueco cuesta ~0.006;unFindAllcorecuestahasta0.006; un FindAll `core` cuesta hasta 6.50. Un router que pueda escalar solo de uno a otro convierte una consulta de 0.006enunade0.006 en una de 6.50 sin que nadie lo autorice — un factor de mil.

4.1 Stop conditions

Se reutiliza GtmStopReason tal cual está. Sin vocabulario nuevo: Ese mapeo ya existe en ResearchRunService.STATUS_FOR_REASON, con su política de reembolso asociada. No se toca.

4.2 Kill switch

Tres niveles, del más rápido al más lento:
  1. Lifecycle del Source PacktransitionLifecycle(pack, 'degraded'). El router deja de seleccionarlo. Sin deploy, sin reinicio. La transición ya está bloqueada por la máquina de estados existente.
  2. CircuitBreaker por adapter (ya existe en adapters/resilience.ts): 5 fallos abren el circuito 30 s. Un proveedor caído falla rápido y visible en vez de que cada run queme su presupuesto de reintentos en paralelo.
  3. Variable de entorno WEB_SEARCH_ENABLED=false — apaga toda la capa. El chat sigue respondiendo desde el almacén y lo dice.

5. Integración: Artifact → Observation → Evidence Claim

5.1 El mapeo, campo por campo

5.2 modalidad y confidence_method — las dos que se confunden

live_verified no se alcanza nunca desde un índice de búsqueda. Un índice dice lo que el proveedor vio alguna vez, no lo que la página dice ahora. Confundir esas dos cosas es la definición operativa de Live Query vs Live Observation en el diccionario del sistema.

5.3 Frescura — cuatro fechas, cuatro significados

Esta es la parte que la evidencia web rompe más seguido, y GtmClaimInput ya tiene los cuatro campos: Poner retrievedAt en observed_at convierte un boletín de 2019 leído hoy en “observado hoy”. Es un error de una línea con consecuencias de meses, y por eso el port marca publishedAt como string | null con el comentario explícito de que null debe sobrevivir.

5.4 TTL por tipo de claim

Alineado con el criterio ya recogido en gtm-dynamic-freshness-scheduling: la cadencia la manda la tasa de cambio observada, no un calendario fijo. Valores de arranque, a recalibrar con datos:

5.5 La web no suplanta a un registro oficial — dos mecanismos

Mecanismo 1 — tabla de autoridad por claim_type. Cada claim_type declara qué familia lo posee. Una claim derivada de web sobre un claim_type de familia autoritativa:
  • nunca se escribe como reemplazo;
  • se escribe como candidata a contradicción (contradictsClaimIds apuntando a la claim oficial), con attribution_verdict: 'unchecked';
  • no puede subir confidence_method por encima de provider_reported.
Ejemplo real del golden set (gs-094): LinkedIn declara plantilla global y desactualizada; DENUE declara rango de personal ocupado de la unidad económica. Contradicen. La salida correcta son dos claims con la contradicción registrada — no una que pisa a la otra. EvidenceClaimStore es append-only justamente por esto: no tiene update() ni patch(), solo insert(). Mecanismo 2 — el allowlist de vocabulario que ya existe. KNOWN_COMPANY_CLAIM_TYPES en chat-tools.ts es un conjunto curado (scian_activity, staffing_range, location) deliberadamente desacoplado de lo que los packs cosechan en crudo. Un claim_type derivado de web no aparece en el chat hasta que se le escriba una rama de describeClaim() y se le admita explícitamente. La puerta ya está puesta; solo hay que no abrirla por descuido.

5.6 Que el texto web no ejecute instrucciones

Cuatro capas. Ninguna basta sola; la literatura de 2026 es clara en que la mejor defensa publicada aún deja pasar del orden de una de cada diez inyecciones optimizadas. Por eso la capa 4 es la que realmente importa.
  1. Tipo marcado. UntrustedText no puede concatenarse a un prompt sin pasar por renderUntrusted(), que envuelve en un bloque delimitado con preámbulo explícito de “esto es dato, no instrucción”. Olvidarlo no compila.
  2. El modelo nunca ve markdown crudo. Ve líneas renderizadas al estilo del describeClaim() que la herramienta del almacén ya usa: nombre de empresa + descripción en lenguaje de negocio. Nunca el claim_type, el JSON del valor, ni el cuerpo de la página.
  3. Detector + rechazo. Un documento que dispare el detector (comentarios HTML imperativos, texto oculto por CSS, capas de texto en PDF, campos instructions en JSON-LD, o cadenas que imiten al sistema) se descarta, y cualquier claim ya derivada de él se marca attribution_verdict: 'reject'. Se usa el veredicto que ya existe en vez de inventar una 13.ª clase de obstrucción: la taxonomía está cerrada con un CHECK en la base y ampliarla es una migración más una actualización del topic del que se tomó. Si el volumen llega a justificar un reporte agregado, esa migración es una tarjeta aparte (WS-12b), no un efecto colateral.
  4. La capacidad, no la detección. Una claim derivada de web no puede disparar una escritura. No actualiza un record, no aprueba nada, no dispara outbound. Solo un humano, o una claim fundada en el almacén, puede. Esa es la frontera de autorización que el código del agente no puede cruzar — y es la única capa que sigue en pie cuando la detección falla.

6. Modelo de costos

6.0 El punto de partida real

El comentario de noteWarehouseCoverage() guarda la factura que originó todo esto: una corrida de 6para59empresasunos6 para 59 empresas** — unos **0.10 por empresa, y el resultado es enumeración, no evidencia citable. Un hueco resuelto con las primitivas baratas cuesta **0.006porempresaysıˊproduceartifact,spanyfecha.NoeselmismotrabajoFindAllenumeraununiversoquenotienes,elhuecoenriqueceunoquesıˊyporesoambossiguenexistiendo.Perocuandoeluniversoyaestaˊenelalmaceˊn,pagar0.006 por empresa** y sí produce artifact, span y fecha. No es el mismo trabajo —FindAll enumera un universo que no tienes, el hueco enriquece uno que sí— y por eso ambos siguen existiendo. Pero cuando el universo **ya está en el almacén**, pagar 0.10 por empresa para volver a enumerarla es comprar dos veces lo mismo.

6.1 Costo interno por consulta

Un hueco típico de una cuenta: 2 búsquedas + 4 extracciones.

6.2 Impacto sobre el catálogo de créditos

radar-pricing.ts fija MARGIN_FLOOR = 0.6 y CREDIT_USD = 0.99. La pregunta que importa: ¿la búsqueda web mueve el piso de margen? Operación standard: 25 créditos = 24.75deingreso,expectedCogsUsd=24.75 de ingreso, `expectedCogsUsd` = 7.00, margen actual 71.7 %. Conclusión económica, y es la más importante del documento: en la capa barata, la elección de proveedor no mueve el precio del producto. La diferencia entre el más caro y el más barato de la tabla es de 1.2 puntos de margen sobre una holgura de 11.7. Optimizar de 0.006a0.006 a 0.001 por cuenta ahorra 0.12enunaoperacioˊnde0.12 en una operación de 24.75. Por lo tanto la elección debe hacerse por calidad, frescura y fidelidad de citación — no por precio, siempre que se mantenga en la capa barata. Y el control económico que sí importa es el que impide que la capa barata escale sola a FindAll pro (10+10 + 1/match): ahí sí se juega el margen entero. Ese control es quote + aprobación, no una optimización de céntimos.

6.3 GtmCostPolicy por pack

parseCostPolicy() lanza si un pack no tiene política. Eso convierte “tarifar antes de correr” en una garantía mecánica y gratuita: un proveedor nuevo no puede ejecutarse hasta que alguien escriba su precio. Un Source Pack por (proveedor, operación) — no uno por proveedor — para que computeSourcePackCogs() siga siendo honesto cuando búsqueda y extracción tienen unidades distintas:
egress_bytes y compute_seconds se miden, no se estiman — computeSourcePackCogs() los factura del uso real. El costo web es casi todo provider_usd; verlo descompuesto es lo que permite responder “¿subió el proveedor o subimos nosotros el volumen?“.

6.4 Ledger de uso — sin tabla nueva

gtm_provider_attempts ya tiene exactamente las columnas necesarias: capability, provider_class, rank, outcome, latency_ms, cost_usd, detail. Se usa con capability: 'web_search' | 'web_fetch' | 'web_extract' y provider_class = WebSearchProviderPort.id. Y ProviderAttemptService.successRates() responde entonces, gratis, la pregunta que decide la cascada: cost_per_accepted_usd por proveedor y capacidad. Es la misma mecánica que ya reordena la cascada de contactos. Dos detalles que ya están bien resueltos ahí y que aquí importan igual:
  • El denominador es invoked, no attempts: un escalón saltado por falta de clave nunca tuvo oportunidad de acertar, y contarlo en su contra puntuaría al proveedor por nuestra configuración.
  • cost_usd es null cuando el adapter no puede tarifarse, y 0 cuando es genuinamente gratis. Colapsarlos haría inútil la métrica.
El registro es fire-and-forget con la promesa rastreada: una caída de telemetría degrada la visibilidad, nunca la consulta del cliente.

6.5 Proyección a Stripe

La búsqueda web no es un SKU. No hay pack nuevo, ni precio nuevo, ni línea nueva en el checkout. Es COGS dentro de operaciones que ya se cobran:
  • Entra en expectedCogsUsd de las operaciones existentes → §6.2.
  • La compra de créditos sigue igual: BillingServicecheckout.session.completedCreditsService.purchase(idempotencyKey = payment event id) con orIgnore(), porque la entrega duplicada de Stripe es la norma, no un caso borde.
  • El único cambio en la proyección es la recalibración semanal: los COGS medidos incluyen ahora la línea web, y MARGIN_FLOOR se verifica en el spec — violarlo rompe el build en vez de erosionar el negocio en silencio.

6.6 Idempotencia y reintentos

Las llamadas web no se debitan individualmente del ledger. Son COGS dentro de una operación que ya se cobró con debitAndRun. Esto no es un ahorro contable: es lo que impide que un reintento por 429 le cueste créditos al cliente. Donde una llamada web sea iniciada por el usuario de forma independiente, va por debitAndRun con:
El día en la llave es deliberado: la misma pregunta mañana es una pregunta distinta —el punto entero de la frescura— pero repetida hoy por un reintento no debe cobrarse dos veces. Reintentos: withRetry con backoff exponencial y full jitter (ya existe), solo para 429 y 5xx. Un 4xx distinto de 429 es nuestro bug y se relanza de inmediato: reintentarlo quema presupuesto y retrasa el error real. Un reintento reutiliza siempre la misma llave de idempotencia.

7. Matriz de pruebas

Las nueve clases de fixture, las diez métricas y sus umbrales duros están en web-search/fixtures-and-metrics.md. Resumen de lo que se prueba y dónde:

8. Tarjetas Driftless

Proyecto creado en Driftless: Web Search Provider Evaluation (b875dad7-50ef-4550-a2d2-b327d47907ca), con las 17 tarjetas cargadas, sus dependencias, y validate + acceptance por tarjeta. Base: staging (§0). La compuerta de costo (§0.2) no es una tarjeta aparte: vive dentro de WS-09, que es donde se arma el cinturón.

El orden de construcción

Esto es lo que yo construiría, y por qué en este orden:
  1. WS-01 + WS-04 — las primitivas baratas detrás del port. Es la pieza más pequeña y la de mayor retorno: misma clave, mismo vendor, cero revisión legal, y desbloquea todo lo demás. Hoy el sistema literalmente no sabe hacer una búsqueda de $0.001.
  2. WS-09 — el router de huecos y el cinturón. Es lo que convierte “DENUE primero” de mensaje en regla, lo que crea el escalón intermedio de la cotización, y donde vive la compuerta de costo de §0.2. Aquí está el cambio de producto, no en el ahorro.
  3. WS-08 + WS-10 + WS-11 + WS-12 — la ingesta gobernada. Sin esto el trabajo web no entra al almacén y lo vuelves a pagar cada vez; con esto, el almacén compone. WS-11 y WS-12 no son opcionales: son la condición para dejar entrar contenido web a un almacén en el que un cliente se apoya.
  4. WS-03 + WS-05/06 + WS-07 + WS-15/16 — la evaluación formal. Confirma o desmiente al proveedor por defecto. Va después a propósito: el port ya hace que cambiar cueste una línea, así que la evaluación puede correr sin bloquear el valor.
Ruta crítica de la evaluación: WS-01 → WS-03 → WS-04/05/06 (paralelizables) → WS-07 → WS-15 → WS-16. La rama de gobernanza (WS-08 → WS-09 → WS-10 → WS-11/12) corre en paralelo desde el principio y bloquea a WS-16: no se promueve nada a active sin la frontera de autoridad y la de inyección en su sitio.

9. Rollout

9.1 CI — fixtures, cero llamadas vivas

No es una convención, es un candado (WS-03): los specs de adapter corren con la red denegada, y cualquier intento de salir falla el test. Además, un escaneo estático prohíbe que WEB_SEARCH_*_API_KEY se lea fuera de adapters/, igual que radar-architecture.spec ya prohíbe PARALLEL_API_KEY en el dominio. Motivo: un benchmark que a veces sale a la red produce números que dependen del día. Y un CI que llama a un proveedor de pago tiene una factura que nadie presupuestó y una dependencia de disponibilidad externa para mergear.

9.2 Shadow benchmark

Todos los candidatos en lifecycle: 'shadow'corren y nadie consume su salida. Mínimo 3 días de tráfico real, con tope de presupuesto duro. Se mide: las 10 métricas, más la reconciliación de costo contra la factura del proveedor. Divergencia > 5 % entre lo reportado por el adapter y lo facturado es bloqueante: todo el modelo de créditos descansa en que el COGS medido sea el real.

9.3 Canary

transitionLifecycle ya impide saltarse etapas: experimental → shadow → active es la única ruta, con bloqueo pesimista para que dos transiciones concurrentes no puedan colar un salto ilegal. El canary es, literalmente, promover un pack a active y dejar los demás en shadow. Alcance del canary: un workspace, un Opportunity Flow, presupuesto acotado. Se promueve al resto cuando cost_per_accepted_usd y citation_coverage se sostienen 7 días.

9.4 Fallback

La cascada se ordena por cost_per_accepted_usd observado, no por precio de lista — política en datos, no en código, igual que la cascada de contactos. Un proveedor cuyo circuito abre sale de la fila hasta que cierre.

9.5 Rollback

En orden de velocidad: El nivel 4 es la razón por la que EvidenceClaimStore no tiene update(). Un rollback que borra evidencia mala destruye también la prueba de que se sirvió — que es exactamente lo que hace falta para responderle a un cliente.

10. Riesgos

10.1 Legales

10.2 Técnicos

10.3 Económicos


10.4 Dos cosas que cambiaron mientras se escribía esto

Ambas se descubrieron revisando el diseño contra el mundo, no contra el código, y las dos invalidan supuestos del documento. Se dejan aquí con fecha porque volverán a envejecer.

La web abierta se cerró (verificado 2026-08-01)

Cloudflare bloquea crawlers de IA por defecto, con tres categorías separables —Search, Agent, Training— disponibles para todos los clientes, incluida la capa gratuita, desde el 1 de julio de 2026. Pay-Per-Crawl (un muro 402) evolucionó a Pay-Per-Use, que paga al publicador cuando la IA usa el contenido en una respuesta, no cuando el bot descarga la página. Y hay una fecha a seis semanas: el 15 de septiembre de 2026 empieza el bloqueo por defecto de crawlers mixed-use en cualquier página con anuncios — que son exactamente eleconomista.com.mx, elfinanciero.com.mx, milenio.com. La familia directory de la que depende toda la evidencia de EVENTOS del golden set. Tres consecuencias, en orden de incomodidad:
  1. El escalón cero se identifica honestamente, y eso es justo lo que se bloquea. DriftlessRadarBot/1.0 en el user-agent es una declaración spoofable; en este régimen no compra acceso, lo niega. El rung gratis que sostiene el modelo de costo se degrada solo, sin que ningún cambio nuestro lo explique — y sin que nada lo mida (§WS-20).
  2. robots.txt dejó de ser donde se decide el acceso. Para cerca de la mitad del tráfico de IA en 2026 es una señal que se ignora, y la aplicación se movió a la capa de red. Lo seguimos honrando —es lo correcto y es nuestra política— pero honrarlo ya no basta para entrar.
  3. Invierte parcialmente el §3.1. Ahí se argumenta que fetch() es superior en procedencia a extract() porque sostenemos los bytes. Sigue siendo cierto — y si nuestro fetch honesto recibe 403 en una porción creciente de la web, la ruta superior en procedencia es la que no funciona. Eso fortalece el caso de comprar fetch a un proveedor con relaciones de crawl propias, no solo por precio.
El arreglo tiene estándar, y encaja con la filosofía que el manifiesto ya declara. Web Bot Auth: HTTP Message Signatures (RFC 9421), Ed25519, header Signature-Agent, y un directorio JWKS en /.well-known/http-message-signatures-directory. Respaldado por Cloudflare, Amazon, Akamai y OpenAI; spec W3C cerrada en mayo de 2026; grupo IETF constituido en 2026. Cloudflare tiene una categoría Verified AI Agent y una acción Challenge Agent que pide firma en vez de CAPTCHA. El manifiesto del escalón cero ya dice “un scraper que miente sobre quién es no puede decir que practica cortesía”. Web Bot Auth es esa misma honestidad, criptográfica en vez de declarativa: deja de ser una afirmación y pasa a ser verificable. Y el repo ya sirve /.well-known/agent-skills/index.json, así que publicar el JWKS extiende un patrón que existe. Lo que la firma NO compra: permiso para ignorar robots.txt. Prueba QUIÉN somos; no autoriza qué podemos tomar. La LFPDPPP que este documento citaba es la de 2010. Dejó de existir el 21 de marzo de 2025, cuando entró en vigor una ley nueva publicada en el DOF el 20 de marzo de 2025, producto de la reforma constitucional de diciembre de 2024. Las decisiones de producto no cambian —seguimos sin construir direcciones, sin presentar un buzón general como personal, y redactando datos personales de las fixtures— pero la cita legal que las justifica sí cambia, y una justificación que apunta a una ley derogada no justifica nada.

11. Cómo se decide, en una frase

El benchmark ordena por costo por respuesta útil (§4.7 del documento de métricas), no por precio por request; descalifica sin promediar a quien falle cualquiera de las cuatro puertas duras (citación ≥ 0.95, abstención = 1.00, contención de inyección = 1.00, partial honesto); y desempata por precision@10, luego recall, luego freshness_accuracy. Si Parallel gana, no cambiamos de proveedor y la ganancia es el port, el router y la gobernanza. Si pierde, cambiamos de proveedor con una línea — que es exactamente lo que este proyecto existe para hacer posible.

Fuentes de precio (verificadas 2026-08-01)