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¶
- 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. - Unitario:
record_fallbackincrementa la lectura in-memory y manda una sola ocurrencia al sink; un sink que falla no cambia fallback ni ejecución. - 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. - Integración de dispatch: forzar una negativa del envelope para
run,stream,stream_typedy 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 ensrc/symfonic/agent/cutover/routes.py:CAPABILITY_SWITCHES. - 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 construyeBufferedMetricsSink, pero no existe evidencia de que entregue un sink al switchboard (src/symfonic/platform/observability.py:observability). - CI: conservar el artefacto de
scripts/adopter_validation/route_probe.pycomo 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 ejecutapython -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:
- 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); fallbacks_total == 0para la capability durante la ventana;kernel_route_attempts_total > 0durante la ventana; cero tráfico no es evidencia de cero fallback;- no hubo pérdida de eventos de fallback: el buffer reporta cero pérdidas o
la evidencia se invalida.
BufferedMetricsSink.loss_countsofrece un precedente de health reporting, pero aún no separa la familia fallback (src/symfonic/core/observability/metrics_store.py:BufferedMetricsSink.loss_counts); - 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.