From 088d8a509dbcdc91e607817517d069d8f6de9dd7 Mon Sep 17 00:00:00 2001 From: ogt Date: Wed, 15 Jul 2026 08:29:05 +0800 Subject: [PATCH] feat(notifications): expose canonical Telegram routing --- apps/api/src/api/v1/agents.py | 35 +++ apps/api/src/services/notification_matrix.py | 87 ++++++ ...telegram_canonical_routing_readback_api.py | 58 ++++ ...est_telegram_canonical_routing_registry.py | 37 +++ apps/web/messages/en.json | 39 ++- apps/web/messages/zh-TW.json | 27 +- .../src/app/[locale]/notifications/page.tsx | 251 +++++++++++++++++- .../TELEGRAM-CANONICAL-ROUTING-INVENTORY.md | 13 +- 8 files changed, 522 insertions(+), 25 deletions(-) create mode 100644 apps/api/tests/test_telegram_canonical_routing_readback_api.py diff --git a/apps/api/src/api/v1/agents.py b/apps/api/src/api/v1/agents.py index bf7051369..7f5187f71 100644 --- a/apps/api/src/api/v1/agents.py +++ b/apps/api/src/api/v1/agents.py @@ -454,6 +454,9 @@ from src.services.host_runaway_aiops_loop_readiness import ( from src.services.javascript_package_inventory import ( load_latest_javascript_package_inventory, ) +from src.services.notification_matrix import ( + build_canonical_telegram_routing_runtime_readback, +) from src.services.observability_contract_matrix import ( load_latest_observability_contract_matrix, ) @@ -4977,6 +4980,38 @@ async def get_telegram_alert_ai_automation_matrix() -> dict[str, Any]: ) from exc +@router.get( + "/telegram-canonical-routing-runtime-readback", + response_model=dict[str, Any], + summary="取得 Telegram 產品告警 canonical routing runtime readback", + description=( + "以 production gateway 使用的 fail-closed resolver 逐條解析 11 個產品與 20 條 " + "symbolic route,分開顯示歷史 source snapshot 與目前 runtime enforcement。" + "此端點不讀 Telegram ID/token、不呼叫 Bot API、不送訊息、不改 runtime route。" + ), +) +async def get_telegram_canonical_routing_runtime_readback() -> dict[str, Any]: + """Return the no-secret, no-send canonical Telegram runtime projection.""" + try: + return await asyncio.to_thread( + build_canonical_telegram_routing_runtime_readback + ) + except FileNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) from exc + except (json.JSONDecodeError, ValueError) as exc: + logger.error( + "telegram_canonical_routing_runtime_readback_invalid", + error=str(exc), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Telegram canonical routing runtime readback 無效", + ) from exc + + @router.get( "/agent-gitea-pr-draft-lane", response_model=dict[str, Any], diff --git a/apps/api/src/services/notification_matrix.py b/apps/api/src/services/notification_matrix.py index b6ca1455b..6f0c5c98f 100644 --- a/apps/api/src/services/notification_matrix.py +++ b/apps/api/src/services/notification_matrix.py @@ -13,6 +13,7 @@ import json import re from collections.abc import Mapping from dataclasses import dataclass +from datetime import UTC, datetime from enum import Enum from pathlib import Path from typing import Any @@ -337,6 +338,92 @@ def load_canonical_telegram_routing_registry( return payload +def build_canonical_telegram_routing_runtime_readback( + security_dir: Path | None = None, +) -> dict[str, Any]: + """Project the committed registry through the live fail-closed resolver. + + The source snapshot deliberately records the no-send boundaries of the run + that created it. Once the gateway guard is deployed, that historical + ``source_status`` must not be presented as the current runtime state. This + readback resolves every route with the same code path used before Telegram + provider calls and returns symbolic aliases only. + """ + + registry = load_canonical_telegram_routing_registry(security_dir) + resolved_routes: list[dict[str, Any]] = [] + for route in registry["routes"]: + decision = resolve_canonical_telegram_route( + route["product_id"], + route["signal_family"], + route["severity"][0], + requested_destination=route["allowed_destination"], + registry=registry, + ) + resolved_routes.append( + { + **route, + "decision": decision["decision"], + "decision_reason": decision["reason"], + } + ) + + allowed_route_count = sum( + route["decision"] == "allow" for route in resolved_routes + ) + blocked_route_count = len(resolved_routes) - allowed_route_count + unknown_probe = resolve_canonical_telegram_route( + "unknown-product", + "unknown-signal", + "P1", + registry=registry, + ) + + return { + "schema_version": "telegram_canonical_routing_runtime_readback_v1", + "status": "runtime_policy_enforced_product_delivery_receipts_partial", + "source_status": registry["status"], + "source_mode": registry["mode"], + "source_generated_at": registry["generated_at"], + "runtime_generated_at": datetime.now(UTC).isoformat(), + "runtime_enforcement": { + "canonical_resolver_active": True, + "gateway_pre_send_gate_active": True, + "trusted_ingress_context_required": True, + "unknown_route_decision": unknown_probe["decision"], + "unknown_route_reason": unknown_probe["reason"], + "raw_numeric_destination_count": registry["rollups"][ + "raw_numeric_destination_count" + ], + "destination_identity_exposed": False, + "all_product_delivery_receipts_ready": blocked_route_count == 0, + }, + "rollups": { + **registry["rollups"], + "runtime_allowed_route_count": allowed_route_count, + "runtime_blocked_route_count": blocked_route_count, + }, + "policy": registry["policy"], + "destination_roles": registry["destination_roles"], + "products": registry["products"], + "routes": resolved_routes, + "operation_boundaries": { + "read_only": True, + "telegram_send_performed": False, + "bot_api_call_performed": False, + "runtime_route_changed": False, + "secret_value_read": False, + "raw_identifier_included": False, + }, + "source_refs": { + "registry": "docs/security/telegram-canonical-routing-registry.snapshot.json", + "resolver": "apps/api/src/services/notification_matrix.py", + "gateway_gate": "apps/api/src/services/telegram_gateway.py", + "verifier": "apps/api/tests/test_telegram_canonical_routing_registry.py", + }, + } + + def resolve_canonical_telegram_route( product_id: str, signal_family: str, diff --git a/apps/api/tests/test_telegram_canonical_routing_readback_api.py b/apps/api/tests/test_telegram_canonical_routing_readback_api.py new file mode 100644 index 000000000..e7aa48326 --- /dev/null +++ b/apps/api/tests/test_telegram_canonical_routing_readback_api.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.api.v1 import agents +from src.api.v1.agents import router + + +def _client() -> TestClient: + app = FastAPI() + app.include_router(router, prefix="/api/v1") + return TestClient(app) + + +def test_canonical_routing_runtime_readback_endpoint(monkeypatch) -> None: + payload = { + "schema_version": "telegram_canonical_routing_runtime_readback_v1", + "status": "runtime_policy_enforced_product_delivery_receipts_partial", + "operation_boundaries": { + "telegram_send_performed": False, + "secret_value_read": False, + }, + } + monkeypatch.setattr( + agents, + "build_canonical_telegram_routing_runtime_readback", + lambda: payload, + ) + + response = _client().get( + "/api/v1/agents/telegram-canonical-routing-runtime-readback" + ) + + assert response.status_code == 200 + assert response.json() == payload + + +def test_canonical_routing_runtime_readback_rejects_invalid_source( + monkeypatch, +) -> None: + def invalid_source() -> dict: + raise ValueError("invalid registry") + + monkeypatch.setattr( + agents, + "build_canonical_telegram_routing_runtime_readback", + invalid_source, + ) + + response = _client().get( + "/api/v1/agents/telegram-canonical-routing-runtime-readback" + ) + + assert response.status_code == 500 + assert response.json() == { + "detail": "Telegram canonical routing runtime readback 無效" + } diff --git a/apps/api/tests/test_telegram_canonical_routing_registry.py b/apps/api/tests/test_telegram_canonical_routing_registry.py index 88649644b..766bc0fd3 100644 --- a/apps/api/tests/test_telegram_canonical_routing_registry.py +++ b/apps/api/tests/test_telegram_canonical_routing_registry.py @@ -9,6 +9,7 @@ import pytest import yaml from src.services.notification_matrix import ( + build_canonical_telegram_routing_runtime_readback, get_trusted_alert_canonical_route_context, load_canonical_telegram_routing_registry, resolve_canonical_telegram_route, @@ -30,6 +31,42 @@ EXPECTED_PRODUCTS = { REPO_ROOT = Path(__file__).resolve().parents[3] +def test_runtime_readback_separates_historical_source_state_from_live_gate() -> None: + readback = build_canonical_telegram_routing_runtime_readback() + + assert readback["status"] == ( + "runtime_policy_enforced_product_delivery_receipts_partial" + ) + assert readback["source_status"] == "source_policy_ready_runtime_not_applied" + assert readback["source_generated_at"] == "2026-07-14T15:50:04+08:00" + assert readback["runtime_generated_at"].endswith("+00:00") + assert readback["runtime_enforcement"] == { + "canonical_resolver_active": True, + "gateway_pre_send_gate_active": True, + "trusted_ingress_context_required": True, + "unknown_route_decision": "block", + "unknown_route_reason": "unknown_product_signal_or_severity", + "raw_numeric_destination_count": 0, + "destination_identity_exposed": False, + "all_product_delivery_receipts_ready": False, + } + assert readback["rollups"]["runtime_allowed_route_count"] == 4 + assert readback["rollups"]["runtime_blocked_route_count"] == 16 + assert len(readback["products"]) == 11 + assert len(readback["routes"]) == 20 + assert readback["operation_boundaries"] == { + "read_only": True, + "telegram_send_performed": False, + "bot_api_call_performed": False, + "runtime_route_changed": False, + "secret_value_read": False, + "raw_identifier_included": False, + } + serialized = json.dumps(readback, ensure_ascii=False) + assert '"chat_id"' not in serialized + assert '"bot_token"' not in serialized + + def test_namespaced_product_ingress_route_is_explicit_and_policy_blocked() -> None: context = get_trusted_alert_canonical_route_context( { diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index f2317fed1..7c002aaaa 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -2735,13 +2735,38 @@ "hostK3sWorker": "K3s Worker" }, "notifications": { - "title": "通知", - "subtitle": "通知頻道設定", - "channel": "頻道", - "type": "類型", - "status": "狀態", - "noChannels": "目前無通知頻道", - "fetchError": "無法取得通知頻道" + "eyebrow": "Alerting", + "title": "Notifications", + "subtitle": "Notification channel settings", + "channel": "Channel", + "type": "Type", + "status": "Status", + "activeChannels": "Active channels", + "errors": "Errors", + "noChannels": "No notification channels", + "fetchError": "Unable to load notification channels", + "routing": { + "title": "Telegram monitoring route registry", + "subtitle": "Fail-closed routing by product, signal, severity, group, and bot. Unknown or unverified routes never send.", + "gateActive": "Runtime gate active", + "unavailable": "The runtime routing readback is unavailable. Guessed routes are hidden and never fall back to the shared group.", + "metrics": { + "products": "Products", + "routes": "Canonical routes", + "allowed": "Allowed", + "blocked": "Blocked / disabled" + }, + "sharedTitle": "Only shared monitoring destination: AwoooI SRE war room", + "sharedDetail": "Only AWOOOI shared infrastructure, security, DR, and P0/P1 incident lifecycle signals may be sent by the TsenYang bot. Raw P2/P3, bulk product warnings, marketing, success heartbeats, and unverified recommendations are blocked.", + "rolesSummary": "Group / bot responsibility boundaries ({count})", + "monitoringOwner": "Monitoring owner", + "nonMonitoringOwner": "Not monitoring owner", + "routesSummary": "{products} products / {routes} alert routes", + "allow": "Allow", + "noSend": "No send", + "footer": "runtime={runtime} · source={source} · readback sends no Telegram message, reads no ID/token, and changes no route", + "generatedAt": "Runtime readback: {time} (Asia/Taipei)" + } }, "reports": { "eyebrow": "AI Agent 報表與告警接管", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 6c4ef99c2..9c5a5e367 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -2735,13 +2735,38 @@ "hostK3sWorker": "K3s Worker" }, "notifications": { + "eyebrow": "告警通知", "title": "通知", "subtitle": "通知頻道設定", "channel": "頻道", "type": "類型", "status": "狀態", + "activeChannels": "啟用頻道", + "errors": "異常", "noChannels": "目前無通知頻道", - "fetchError": "無法取得通知頻道" + "fetchError": "無法取得通知頻道", + "routing": { + "title": "Telegram 監控告警路由總帳", + "subtitle": "依產品、訊號、嚴重度、群組與 BOT 做 fail-closed 分流;未知或未驗證路由一律不送。", + "gateActive": "Runtime gate 已啟用", + "unavailable": "正式 routing readback 暫時不可用;目前不顯示猜測路由,也不 fallback 到共用群組。", + "metrics": { + "products": "產品", + "routes": "Canonical routes", + "allowed": "允許", + "blocked": "阻擋 / 停用" + }, + "sharedTitle": "唯一共用監控出口:AwoooI SRE 戰情室", + "sharedDetail": "僅 AWOOOI shared infrastructure、security、DR、P0/P1 incident lifecycle 可由 TsenYang BOT 送出;raw P2/P3、產品 bulk warning、行銷、成功 heartbeat 與未驗證建議全部阻擋。", + "rolesSummary": "群組 / BOT 責任邊界({count})", + "monitoringOwner": "監控 owner", + "nonMonitoringOwner": "非監控 owner", + "routesSummary": "{products} 產品 / {routes} 條告警路由明細", + "allow": "允許", + "noSend": "不送", + "footer": "runtime={runtime} · source={source} · readback 不送 Telegram、不讀 ID/token、不改 route", + "generatedAt": "Runtime readback:{time}(Asia/Taipei)" + } }, "reports": { "eyebrow": "AI Agent 報表與告警接管", diff --git a/apps/web/src/app/[locale]/notifications/page.tsx b/apps/web/src/app/[locale]/notifications/page.tsx index 7da06f80d..d0ead7983 100644 --- a/apps/web/src/app/[locale]/notifications/page.tsx +++ b/apps/web/src/app/[locale]/notifications/page.tsx @@ -14,9 +14,12 @@ import { Bell, BellOff, CheckCircle2, + LockKeyhole, MessageSquare, RefreshCw, + Route, Send, + ShieldCheck, Slack, XCircle, } from 'lucide-react' @@ -30,6 +33,59 @@ interface Channel { status: string } +interface TelegramRouteProduct { + product_id: string + display_name: string + owner: string + source_audit_status: string + egress_status: string +} + +interface TelegramRouteItem { + route_id: string + product_id: string + signal_family: string + severity: string[] + bot_alias: string + chat_alias: string + receipt_backend: string + egress_status: string + decision: 'allow' | 'block' + decision_reason: string +} + +interface TelegramDestinationRole { + visible_label: string + chat_alias: string + bot_alias: string + bot_owner: string + monitoring_owner: boolean + role: string + monitoring_egress_policy: string +} + +interface TelegramRoutingReadback { + status: string + source_status: string + source_generated_at: string + runtime_generated_at: string + runtime_enforcement: { + canonical_resolver_active: boolean + gateway_pre_send_gate_active: boolean + unknown_route_decision: string + all_product_delivery_receipts_ready: boolean + } + rollups: { + product_count: number + route_count: number + runtime_allowed_route_count: number + runtime_blocked_route_count: number + } + destination_roles: TelegramDestinationRole[] + products: TelegramRouteProduct[] + routes: TelegramRouteItem[] +} + const STATUS_CFG: Record = { active: { badge: 'bg-[#f0faf2] border-[#8fc29a] text-[#17602a]', dot: 'bg-[#22C55E]', dotPulse: 'animate-pulse', icon: CheckCircle2, label: 'Active' }, enabled: { badge: 'bg-[#f0faf2] border-[#8fc29a] text-[#17602a]', dot: 'bg-[#22C55E]', icon: CheckCircle2, label: 'Enabled' }, @@ -51,17 +107,33 @@ export default function NotificationsPage({ params }: { params: { locale: string const tc = useTranslations('common') const [channels, setChannels] = useState([]) const [loading, setLoading] = useState(true) + const [routing, setRouting] = useState(null) + const [routingLoading, setRoutingLoading] = useState(true) + const [routingError, setRoutingError] = useState(false) const fetchData = () => { setLoading(true) - fetch(`${API_BASE}/api/v1/notifications/channels`) - .then(r => { - if (!r.ok) throw new Error('not found') - return r.json() - }) - .then(data => { setChannels(Array.isArray(data) ? data : (data?.channels ?? [])) }) - .catch(() => { setChannels([]) }) - .finally(() => setLoading(false)) + setRoutingLoading(true) + setRoutingError(false) + void Promise.allSettled([ + fetch(`${API_BASE}/api/v1/notifications/channels`).then(async response => { + if (!response.ok) throw new Error('channels unavailable') + const data = await response.json() + setChannels(Array.isArray(data) ? data : (data?.channels ?? [])) + }), + fetch(`${API_BASE}/api/v1/agents/telegram-canonical-routing-runtime-readback`).then(async response => { + if (!response.ok) throw new Error('routing unavailable') + setRouting(await response.json() as TelegramRoutingReadback) + }), + ]).then(results => { + if (results[0].status === 'rejected') setChannels([]) + if (results[1].status === 'rejected') { + setRouting(null) + setRoutingError(true) + } + setLoading(false) + setRoutingLoading(false) + }) } useEffect(() => { fetchData() }, []) // eslint-disable-line react-hooks/exhaustive-deps @@ -72,13 +144,13 @@ export default function NotificationsPage({ params }: { params: { locale: string return (
-
+
{/* Header */}

- Alerting + {t('eyebrow')}

{t('title')}

{t('subtitle')}

@@ -93,12 +165,167 @@ export default function NotificationsPage({ params }: { params: { locale: string
+ {/* Canonical Telegram routing cockpit */} +
+
+
+
+
+

+ {t('routing.subtitle')} +

+
+ {routing?.runtime_enforcement.gateway_pre_send_gate_active && ( + + + )} +
+ + {routingLoading && ( +
+ {[0, 1, 2, 3].map(item =>
)} +
+ )} + + {!routingLoading && routingError && ( +
+
+ )} + + {!routingLoading && routing && ( +
+
+ {[ + { key: 'products', label: t('routing.metrics.products'), value: routing.rollups.product_count, tone: 'text-[#141413]' }, + { key: 'routes', label: t('routing.metrics.routes'), value: routing.rollups.route_count, tone: 'text-[#141413]' }, + { key: 'allowed', label: t('routing.metrics.allowed'), value: routing.rollups.runtime_allowed_route_count, tone: 'text-[#17602a]' }, + { key: 'blocked', label: t('routing.metrics.blocked'), value: routing.rollups.runtime_blocked_route_count, tone: 'text-[#9f2f25]' }, + ].map(({ key, label, value, tone }) => ( +
+

{label}

+

{value}

+
+ ))} +
+ +
+

{t('routing.sharedTitle')}

+

+ {t('routing.sharedDetail')} +

+
+ +
+ + {t('routing.rolesSummary', { count: routing.destination_roles.length })} + +
+ {routing.destination_roles.map(role => ( +
+
+

{role.visible_label}

+ + {role.monitoring_owner ? t('routing.monitoringOwner') : t('routing.nonMonitoringOwner')} + +
+

+ {role.bot_alias} → {role.chat_alias} +

+

{role.role}

+
+ ))} +
+
+ +
+ + {t('routing.routesSummary', { + products: routing.products.length, + routes: routing.routes.length, + })} + +
+ {routing.products.map(product => { + const productRoutes = routing.routes.filter(route => route.product_id === product.product_id) + return ( +
+
+
+

{product.display_name}

+

{product.product_id}

+
+ + {product.egress_status} + +
+
+ {productRoutes.map(route => ( +
+
+

{route.signal_family}

+ + {route.decision === 'allow' ? t('routing.allow') : t('routing.noSend')} + +
+

+ {route.severity.join('/')} · {route.bot_alias} → {route.chat_alias} +

+

{route.decision_reason}

+
+ ))} +
+
+ ) + })} +
+
+ +

+ {t('routing.footer', { runtime: routing.status, source: routing.source_status })} +

+

+ {t('routing.generatedAt', { + time: new Intl.DateTimeFormat(params.locale, { + dateStyle: 'medium', + timeStyle: 'medium', + timeZone: 'Asia/Taipei', + }).format(new Date(routing.runtime_generated_at)), + })} +

+
+ )} +
+ {/* Summary */} {!loading && channels.length > 0 && (
{activeCount} - Active Channels + {t('activeChannels')}
0 ? 'text-[#9f2f25]' : 'text-[#77736a]')}> {errorCount} - Errors + {t('errors')}
)} diff --git a/docs/security/TELEGRAM-CANONICAL-ROUTING-INVENTORY.md b/docs/security/TELEGRAM-CANONICAL-ROUTING-INVENTORY.md index 22200319a..ff05da340 100644 --- a/docs/security/TELEGRAM-CANONICAL-ROUTING-INVENTORY.md +++ b/docs/security/TELEGRAM-CANONICAL-ROUTING-INVENTORY.md @@ -1,7 +1,8 @@ # Telegram 監控告警路由總帳 -> 狀態:`source_policy_ready_runtime_not_applied` -> 真相邊界:本表把「已證明現況」、「建議目的地」、「目前阻擋」、「source risk」與「receipt 強度」分開。建議不等於 live route;alias 不是 numeric chat ID;本變更沒有送 Telegram、讀 token/chat ID、改 runtime route 或部署。 +> Source snapshot 狀態:`source_policy_ready_runtime_not_applied`(保留 2026-07-14 產生當下的歷史邊界,不回寫成 runtime 綠燈) +> Runtime projection 狀態:`runtime_policy_enforced_product_delivery_receipts_partial`(由 production gateway 相同 fail-closed resolver 即時計算;4 條 allow、16 條 block) +> 真相邊界:本表把「已證明現況」、「建議目的地」、「目前阻擋」、「source risk」與「receipt 強度」分開。建議不等於 delivery receipt;alias 不是 numeric chat ID;readback 不送 Telegram、不讀 token/chat ID、不改 runtime route。是否已部署仍以 exact-SHA CD、deploy marker 與 production API/UI readback 為準。 ## 判讀規則 @@ -44,7 +45,7 @@ - Alertmanager 產品規則現在帶 `awoooi_product_id`、`awoooi_signal_family`、`awoooi_route_severity`;gateway 以這組明確 context 覆蓋 legacy `TYPE-3`,所以 blocked product 不會再落到 shared SRE。 - SignOz public receiver 尚無 source authentication,因此即使 payload 自稱 canonical labels 也固定 no-egress;Sentry(signature verified)與 HMAC generic webhook 缺完整 namespaced context 時一律 `UNKNOWN / blocked_no_egress`。Alertmanager 另要求 direct peer 與完整 forwarded chain 都是 private IP,不再信任單一可偽造的 forwarded value。 - `docker-compose`、production ConfigMap、docker-health scripts 與 7 個 Gitea workflows 的 11 個 raw destination defaults 已移除;runtime 只可由既有 secret/env alias 注入,缺值即 fail closed。白名單也不再內嵌 numeric default。 -- 本次只改 source policy,沒有讀 token、呼叫 Bot API、改 Telegram 群組、送測試訊息或偽造 delivery receipt。 +- Runtime readback 只透過與 gateway pre-send gate 相同的 resolver 投影 symbolic policy;不讀 token、呼叫 Bot API、改 Telegram 群組、送測試訊息或偽造 delivery receipt。 ## 11 產品路由清冊 @@ -89,6 +90,8 @@ - Registry:`docs/security/telegram-canonical-routing-registry.snapshot.json` - Schema:`docs/schemas/telegram_canonical_routing_registry_v1.schema.json` - Resolver:`apps/api/src/services/notification_matrix.py` -- Tests:`apps/api/tests/test_telegram_canonical_routing_registry.py` +- API readback:`GET /api/v1/agents/telegram-canonical-routing-runtime-readback` +- UI cockpit:`/{locale}/notifications` +- Tests:`apps/api/tests/test_telegram_canonical_routing_registry.py`、`apps/api/tests/test_telegram_canonical_routing_readback_api.py` -Production apply 前仍需同一 route change run 留下 source diff、check-mode、bounded apply、durable delivery receipt、post-verifier、rollback/no-write terminal 與 learning/writeback;本 source-only 交付不代表 runtime route 已更動。 +Source snapshot 的 `runtime_not_applied` 是歷史產生狀態;API 會另以目前 resolver 重算 runtime enforcement,兩者不得互相覆寫。任何 route change 仍需同一 run 留下 source diff、check-mode、bounded apply、durable delivery receipt、post-verifier、rollback/no-write terminal 與 learning/writeback;API/UI 綠燈本身不代表所有產品 delivery 已閉環。