Skip to content

CORE-24 — Telemetría durable de fallbacks Kernel v11

Decisión propuesta

Persistir cada fallback que ocurre después de que una capability fue elegida para Kernel como un evento operativo, sin payload de usuario, a través de un puerto pequeño que la composition root entrega a CutoverSwitchboard. El conteo en memoria actual se conserva como diagnóstico por instancia; el evento durable se convierte en la fuente de evidencia de despliegue para la ventana de cero fallbacks. Esta página es diseño: no instala el puerto ni cambia rutas.

Hechos verificados

Hecho Evidencia ejecutable Consecuencia
Hay cuatro capabilities que el dispatcher consulta: invocation.run, invocation.stream, invocation.stream_typed y invocation.continuation. src/symfonic/agent/cutover/routes.py:CAPABILITY_SWITCHES capability puede ser una etiqueta de cardinalidad finita.
CutoverSwitchboard.record_fallback(capability, reason) sólo incrementa dict[str, dict[str, int]]; fallbacks() lo lee desde la misma instancia. src/symfonic/agent/cutover/switchboard.py:CutoverSwitchboard.record_fallback, CutoverSwitchboard.fallbacks El conteo se pierde al reinicio, no contiene tiempo/host/run y no es prueba de despliegue.
SymfonicAgent.run, stream y _continuation_route llaman al contador cuando una ruta Kernel admitida no puede servir el turno. src/symfonic/agent/engine.py:SymfonicAgent.run, SymfonicAgent.stream, SymfonicAgent._continuation_route El punto de emisión correcto es record_fallback; no duplicar instrumentación en cada cuerpo legacy.
Un rollback/pin que selecciona legacy no se contabiliza como fallback. src/symfonic/agent/cutover/switchboard.py:CutoverSwitchboard.route_for; tests/agent/cutover/test_continuation_cutover.py:test_the_revert_returns_the_continuation_to_the_legacy_body La métrica propuesta mide escapes desde una ruta Kernel, no tráfico configurado deliberadamente legacy.
El repositorio ya tiene un puerto durable de eventos metadata-only, buffer asíncrono y backends Postgres/Mongo. src/symfonic/core/observability/metrics_store.py:MetricsSink.record_events, BufferedMetricsSink.record_event; src/symfonic/core/observability/postgres_execution_sql.py:INSERT_EVENT_SQL; src/symfonic/core/observability/mongo_metrics_store.py:MongoMetricsStore Es el transporte más pequeño que ya tiene semántica de durabilidad.
La ejecución Kernel ya registra eventos sin payload con tenant, conversación, run y lineage. src/symfonic/core/observability/metrics_execution.py:MetricsExecutionMixin.on_kernel_event El esquema y las restricciones de privacidad existentes son el precedente.
La observabilidad Kernel se compone en ServiceBindings.event_sink; el runner lo liga antes de entregar eventos. src/symfonic/agent/backend/plan.py:BackendPlan.compile; src/symfonic/kernel/runner.py:InvocationRunner.stream; src/symfonic/kernel/observability_binding.py:bind_observability No es un punto válido para eventos de fallback: el fallback no entra al Kernel ni necesariamente tiene un plan/sink.
El script de adopters intercepta record_fallback, pero sólo escribe JSON de una ejecución de prueba. scripts/adopter_validation/route_probe.py:PROBE_SOURCE Sirve como prueba/artefacto CI, no como telemetría productiva durable.

Ciclo de vida actual y brecha

flowchart LR
  A[SymfonicAgent entrada] --> B{route_for == KERNEL?}
  B -->|no: rollback/pin| C[legacy sin fallback contado]
  B -->|sí| D{envelope admite?}
  D -->|sí| E[delegate / InvocationKernel]
  D -->|no| F[record_fallback capability, reason]
  F --> G[_fallbacks por instancia]
  G --> H[se pierde al reiniciar]

record_fallback recibe sólo capability y reason, por lo que hoy no puede atribuir una ocurrencia a instalación, ventana ni request. Tampoco hay una llamada desde CutoverSwitchboard a MetricsSink, ObservabilityBridge u OpenTelemetry (src/symfonic/agent/cutover/switchboard.py:CutoverSwitchboard). Por ello ninguna de estas afirmaciones está probada hoy: que producción persista fallbacks, que un dashboard los agregue, o que una ventana de cero fallbacks haya transcurrido.

Diseño mínimo

1. Puerto y ownership

Agregar un puerto stdlib-only, por ejemplo symfonic.services.switching.ports.FallbackTelemetrySink, con una operación síncrona/no bloqueante:

class FallbackTelemetrySink(Protocol):
    def record_fallback(self, event: KernelFallbackEvent) -> None: ...

CutoverSwitchboard.__init__ lo recibe opcionalmente desde la composition root. record_fallback() sigue actualizando _fallbacks y después entrega el evento al puerto dentro de aislamiento de errores. Un fallo de telemetría no puede alterar la decisión ni impedir el cuerpo legacy: el mismo principio se aplica al fan-out actual (src/symfonic/services/observability/bridge.py:ObservabilityBridge._emit) y al binding de observabilidad (src/symfonic/kernel/observability_binding.py:bind_observability).

Por qué no ObservabilityBridge: sólo existe para turns que tienen plan Kernel y consume KernelEvent; el escape registrado nunca produce ese stream. Por qué no importar metrics_store desde el switchboard: invertiría la dirección de la capa agent hacia infraestructura. El puerto conserva la misma forma de composición que los servicios declarados.

2. Adaptador durable reutilizable

La composition root debe construir un adaptador de FallbackTelemetrySink que proyecte cada evento a BufferedMetricsSink.record_event. Esto reutiliza el buffer no bloqueante, flush() y los backends ya disponibles (src/symfonic/core/observability/metrics_store.py:BufferedMetricsSink).

No reutilizar sin cambio el escritor de MetricsExecutionMixin: guarda una fila sólo tras conocer conversation_id, mientras el fallback puede ocurrir antes de que Kernel exista (src/symfonic/core/observability/metrics_execution.py:MetricsExecutionMixin._persist_execution_events). El adaptador debe tener una política explícita para tenant_id/conversation_id desconocidos; no debe inventarlos ni bloquear el evento.

3. Evento y etiquetas

Nombre: kernel_fallback.v1. Una ocurrencia por invocación que finalmente cae de una capability enrutable a Kernel hacia legacy.

Campo Regla Motivo
event_type literal kernel_fallback.v1 versionar contrato durable.
occurred_at UTC al emitir delimitar ventanas.
capability miembro de CAPABILITY_SWITCHES agregación finita.
reason_code código permitido, no prose prevenir cardinalidad/PII.
route_expected literal kernel distinguirlo de tráfico legacy configurado.
runtime_route literal legacy dejar explícito el resultado.
entry_point run, stream, stream_typed, resume o resume_interrupt cuando el caller lo conozca segmentar API.
release_line versión/línea publicada, sin host ni credenciales separar ventanas incompatibles.
run_id opcional, sólo si ya existe en la entrada correlación técnica; no es etiqueta de métrica.
tenant_id / conversation_id opcionales, si la composition root ya posee valores autorizados consultas tenant-scoped; nunca requisito de entrega.
reason_detail prohibido en la fila durable el reason actual puede incluir nombres/valores de configuración.

reason_code debe generarse en una función de clasificación cerrada. Códigos iniciales candidatos: outside_migrated_envelope, release_contract_not_published, pause_capability_missing, pause_transport_unreadable, pause_token_invalid, y unknown_refusal. La función debe recibir la razón actual pero guardar sólo un código; agregar un motivo requiere prueba de que no transporta texto de usuario ni configuración de cardinalidad libre. El precedente de reducir una razón a un código limitado está en src/symfonic/core/observability/metrics_execution.py:_reason_code.

No etiquetar con query, respuesta, attachments, tool arguments, scope en repr, API keys, URL de exporter, hostname, PID ni el texto bruto de reason. El contrato de observabilidad existente ya modela payload como opcional y withheld (src/symfonic/services/observability/values.py:RunStarted, TextEmitted, ToolInvoked); este evento necesita ser estrictamente metadata-only, no "redactable".

4. Agregación y lectura

La vista mínima para retiro agrega por intervalo, release_line, capability y reason_code:

fallbacks_total = count(kernel_fallback.v1)
fallbacks_by_capability_reason = count(*) GROUP BY capability, reason_code

El denominador recomendado es kernel_route_attempts_total, emitido cuando la misma capability tiene route_for(...) == KERNEL, antes de la admisión. No usar sólo invocaciones Kernel servidas como denominador: ocultaría una instalación que rechaza todo. El diseño mínimo puede publicar primero conteos absolutos; no debe declarar una tasa hasta que el evento de intento tenga el mismo backend, etiquetas y garantía de entrega.

execution_events es un candidato de almacenamiento, pero su esquema SQL actual enumera columnas y no tiene event_type, reason_code, entry_point, release_line ni permite filas sin conversation_id de manera demostrada (src/symfonic/core/observability/postgres_execution_sql.py:INSERT_EVENT_SQL). CORE-24 debe escoger una extensión compatible de esa tabla o una tabla cutover_events; no asumir que la tabla actual acepta el evento sin migración.

5. OpenTelemetry

El repositorio tiene una ruta OTel opcional y lazy (src/symfonic/observability/otel/ports.py:build_if_enabled, src/symfonic/services/observability/suite.py:observers_from_config). Puede proyectarse el mismo KernelFallbackEvent a un log/span-event con los campos permitidos, pero OTel no sustituye el registro durable: puede no estar habilitado y la ruta core debe funcionar sin importarlo. Las pruebas de lazy import existentes hacen de esa separación un requisito (tests/observability/otel/test_lazy_import.py).

Evidencia de CI y despliegue

  1. Unitario: una razón conocida se normaliza a código; una desconocida se vuelve unknown_refusal; ninguna fila contiene la razón bruta ni payload.
  2. Unitario: record_fallback incrementa la lectura in-memory y manda una sola ocurrencia al sink; un sink que falla no cambia fallback ni ejecución.
  3. Integración de persistencia: flush de BufferedMetricsSink, reinicio del proceso/adaptador y query que conserva conteo/etiquetas. Probar Postgres y Mongo si los dos siguen siendo backends soportados.
  4. Integración de dispatch: forzar una negativa del envelope para run, stream, stream_typed y continuation; confirmar cuerpo legacy y un evento durable por capability. Cubrir también rollback/pin y confirmar cero eventos. Los cuatro switches están enumerados en src/symfonic/agent/cutover/routes.py:CAPABILITY_SWITCHES.
  5. E2E de composition root: configurar el backend durable, construir el agente por la root soportada y demostrar que llega el adaptador. Este test es necesario porque observability() ya construye BufferedMetricsSink, pero no existe evidencia de que entregue un sink al switchboard (src/symfonic/platform/observability.py:observability).
  6. CI: conservar el artefacto de scripts/adopter_validation/route_probe.py como señal de rutas de ejemplos, y añadir una suite que ejecute los puntos 1–5 contra la distribución construida. El workflow de adopters ya ejecuta python -m adopter_validation (.gitea/workflows/adopter-validation.yml:Run the adopter gate), pero no es una prueba de persistencia productiva.

Definición operativa: ventana de cero fallbacks

Una capability es elegible para retiro sólo si, para cada combinación soportada de release/configuración desplegada:

  1. existe telemetría durable habilitada y saludable durante una ventana continua definida por política (propuesta inicial: 28 días y al menos 10,000 kernel_route_attempts_total; ambos valores requieren aprobación de owner/SRE);
  2. fallbacks_total == 0 para la capability durante la ventana;
  3. kernel_route_attempts_total > 0 durante la ventana; cero tráfico no es evidencia de cero fallback;
  4. no hubo pérdida de eventos de fallback: el buffer reporta cero pérdidas o la evidencia se invalida. BufferedMetricsSink.loss_counts ofrece un precedente de health reporting, pero aún no separa la familia fallback (src/symfonic/core/observability/metrics_store.py:BufferedMetricsSink.loss_counts);
  5. CI de distribución y la batería diferencial siguen verdes; la observación productiva no reemplaza paridad contractual.

Una ventana se reinicia al cambiar release line, contrato de evento, backend, política de redacción o composición del switchboard. La agregación debe mostrar explícitamente "telemetría ausente/degradada", nunca reportar cero.

UNKNOWN que CORE-24 debe resolver antes de implementación

Pregunta Por qué no está probada Experimento mínimo
¿Qué composition roots construyen SymfonicAgent en producción y pueden inyectar el sink? El switchboard sólo acepta criteria/recorder/pin hoy; platform.observability() no lo conecta. Inventario de factories/hosts y un test de root por cada una.
¿execution_events permite filas sin conversación/tenant en los backends reales? El protocolo y SQL no lo garantizan. Migración temporal + inserción/query Postgres y Mongo con esos campos vacíos.
¿Qué reason reales produce cada guard y cuáles son seguros de clasificar? record_fallback hoy acepta texto libre. Test parametrizado sobre las negativas de cada envelope/continuation; lista de códigos cerrada revisada.
¿Qué backend durable está activado en despliegues que cuentan para retiro? METRICS_STORE es opcional y none construye in-memory. Inventario de configuración de despliegue, sin secretos, y health check que confirme writer/flush.
¿Quién define ventana, volumen y SLO de pérdida? No hay umbrales de retiro en código. Decisión explícita de owner/SRE registrada en CORE-24/CORE-18.

Corte de alcance

CORE-24 implementa la señal durable y su evidencia. No cambia admisión, no migra un comportamiento, no retira engine.py, no cuenta rollback/pin como fallback y no convierte una métrica verde en autorización automática de retiro. El gate de retiro (CORE-25/CORE-18) debe consumir esta señal junto con paridad aprobada, consumidores migrados y el resto de sus condiciones.