diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 077831ef4a..d47ee6a6fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2636,7 +2636,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: OmniNode-ai/omninode_infra - ref: 39033d55147ef22a061b665345b506246d3aa543 + ref: db90c19241eb2b58779e18a1ddf7b9a0e11c9c7c path: .proof-dependencies/omninode-infra token: ${{ steps.app-token-proof.outputs.token }} persist-credentials: false diff --git a/docker/migrations/forward/100_create_gateway_link_health.sql b/docker/migrations/forward/100_create_gateway_link_health.sql new file mode 100644 index 0000000000..eaf9265fce --- /dev/null +++ b/docker/migrations/forward/100_create_gateway_link_health.sql @@ -0,0 +1,179 @@ +-- ============================================================================= +-- MIGRATION: Create gateway_link_health table + gateway_link_health_status view +-- ============================================================================= +-- Ticket: OMN-15570 (Gateway lift Phase 0, item G3) +-- Design source: docs/design/2026-08-08-gateway-node-architecture-lift.md +-- (commit 9422d30b1), §3 Phase 0 row G3; liveness thresholds cited from +-- node_bus_forwarder_effect/contract.yaml:59-63. +-- +-- PURPOSE: +-- Latest-known-state projection, one row per tenant edge (tenant_id), +-- materialized from onex.evt.omnibase-infra.gateway-heartbeat.v1 events. +-- Consumed by node_gateway_link_health_projection_compute (COMPUTE, +-- extracts fields + emits the upsert intent) and persisted by +-- node_gateway_link_health_write_effect (EFFECT, executes the upsert). +-- Mirrors the pr_state (091_pr_state.sql) latest-known-state pattern. +-- +-- ABSENCE-OF-PROGRESS DESIGN (the load-bearing property of this migration): +-- ON CONFLICT (tenant_id) DO UPDATE means a tenant edge that stops sending +-- heartbeats keeps its EXISTING row with a staling last_seen_at -- the row +-- is never deleted and never simply absent. gateway_link_health_status +-- (the view below) computes health_status live, at query time, by diffing +-- NOW() against last_seen_at against the contract's +-- max_silence_window_seconds threshold. This is deliberate: a stored, +-- write-time-computed health_status column would always read HEALTHY +-- (it is only written when a heartbeat DOES arrive), which cannot express +-- "went quiet" -- the exact alerting requirement ("alerting on ABSENCE of +-- progress ... never a missing row"). +-- +-- SCOPE DISCLOSURE (read before trusting lag_messages/lag_seconds): +-- The contract also declares lag_threshold_messages (500) and +-- lag_threshold_seconds (120), but no producer in this codebase publishes +-- lag data on the heartbeat topic today. G1 (OMN-15741, path-verifying +-- healthcheck) and G2 (OMN-15742, reconnect supervision) have both since +-- merged, but neither added lag telemetry: G2 widened +-- ModelGatewayHeartbeat with status/consecutive_failures/detail, not with +-- lag_messages/lag_seconds. lag_messages/lag_seconds are therefore always +-- NULL today, and gateway_link_health_status only evaluates the +-- silence-window threshold it can actually observe -- a NULL lag column +-- never contributes a false HEALTHY/UNHEALTHY verdict (NULL comparisons +-- are neither TRUE nor FALSE in SQL, so the lag CASE arms are inert until +-- a real producer populates these columns). +-- +-- LIVENESS THRESHOLDS (node_bus_forwarder_effect/contract.yaml:59-63, +-- gateway_forwarder.liveness block): +-- max_silence_window_seconds: 60 +-- lag_threshold_messages: 500 +-- lag_threshold_seconds: 120 +-- Hardcoded into the view rather than read from a config table: this is +-- the only tenant profile deployed today (single canary tenant, single +-- contract) and hardcoding the ratchet ties the view directly to the +-- cited contract lines for auditability. Revisit if/when multiple tenant +-- liveness profiles exist. +-- +-- OMN-15762 (4th-copy class): this is the 4th independent copy of these +-- three numbers -- contract.yaml, ModelGatewayForwarderConfig defaults, +-- deploy YAML, and now this view -- with no load-time cross-check between +-- them. There is no existing repo pattern for materializing a value into +-- an already-applied SQL view at migration time (the runtime config +-- loader's _materialize_contract_* functions resolve at process start +-- against a live YAML file; a CREATE OR REPLACE VIEW body is baked in +-- once, at migration-apply time, and this table is already live). Rather +-- than leave the drift silent, tests/unit/db/test_migration_100.py pins +-- these three literals against the contract's declared values and fails +-- the suite the moment they diverge -- see OMN-15762 for the tracked +-- de-duplication fix (a single materialized-config source all 4 copies +-- read from). +-- +-- IDEMPOTENCY: +-- - CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT EXISTS are safe to +-- re-run. +-- - CREATE OR REPLACE VIEW is safe to re-run. +-- +-- ROLLBACK: +-- docker/migrations/rollback/rollback_100_create_gateway_link_health.sql +-- ============================================================================= + +CREATE SCHEMA IF NOT EXISTS omninode_internal; + +CREATE TABLE IF NOT EXISTS omninode_internal.gateway_link_health ( + tenant_id TEXT NOT NULL, + principal_id TEXT NOT NULL, + local_transport_flavor TEXT NOT NULL, + last_seen_at TIMESTAMPTZ NOT NULL, + reported_status TEXT NOT NULL, + consecutive_failures INTEGER NOT NULL, + lag_messages BIGINT, + lag_seconds DOUBLE PRECISION, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (tenant_id) +); + +CREATE INDEX IF NOT EXISTS idx_gateway_link_health_last_seen_at + ON omninode_internal.gateway_link_health (last_seen_at); + +COMMENT ON TABLE omninode_internal.gateway_link_health IS + 'Latest-known-state projection of gateway tenant-edge heartbeats ' + '(OMN-15570, G3). One row per tenant_id, upserted on every heartbeat; ' + 'never deleted. Query gateway_link_health_status for live health, not ' + 'this table directly -- health is a function of NOW() - last_seen_at, ' + 'not a stored column.'; + +COMMENT ON COLUMN omninode_internal.gateway_link_health.last_seen_at IS + 'Producer-supplied ModelGatewayHeartbeat.emitted_at -- the freshness ' + 'stamp gateway_link_health_status diffs against NOW().'; + +COMMENT ON COLUMN omninode_internal.gateway_link_health.reported_status IS + 'The edge''s OWN verdict on itself, verbatim from ' + 'ModelGatewayHeartbeat.status (OMN-15742/G2). Stored as free TEXT, not a ' + 'CHECK-constrained enum, deliberately: gateway_link_health_status treats ' + 'any value other than ''active'' as degraded, so a status the producer ' + 'adds later (e.g. a drain state) is read as not-healthy rather than ' + 'crashing the projection or being silently scored HEALTHY.'; + +COMMENT ON COLUMN omninode_internal.gateway_link_health.consecutive_failures IS + 'ModelGatewayHeartbeat.consecutive_failures -- the evidence behind a ' + 'degraded reported_status. Recorded so an operator can tell a single ' + 'blip from a sustained one without consulting process memory. It drives ' + 'no verdict arm of its own: the producer already folds it into ' + 'reported_status, and re-deriving it here would let the two disagree.'; + +COMMENT ON COLUMN omninode_internal.gateway_link_health.lag_messages IS + 'Consumer lag in messages, when a producer supplies it. Always NULL ' + 'today -- see migration header SCOPE DISCLOSURE.'; + +COMMENT ON COLUMN omninode_internal.gateway_link_health.lag_seconds IS + 'Consumer lag in seconds, when a producer supplies it. Always NULL ' + 'today -- see migration header SCOPE DISCLOSURE.'; + +-- ============================================================================= +-- READ SURFACE: gateway_link_health_status +-- ============================================================================= +-- Live per-tenant health, computed at query time. This is the surface +-- intended for a future omnidash widget (out of scope for OMN-15570 -- see +-- PR body) and for operator/alerting queries today, e.g.: +-- SELECT tenant_id, health_status, seconds_since_last_seen +-- FROM gateway_link_health_status +-- WHERE health_status != 'HEALTHY'; +-- PRECEDENCE (fixed, deterministic -- the same row always yields the same +-- verdict, and exactly one arm can win because CASE stops at the first TRUE): +-- 1. UNHEALTHY -- stale beyond the silence window. Ranked +-- above the edge's self-report on purpose: if the edge has stopped +-- talking, its last self-report is stale too and cannot be trusted to +-- still describe reality. +-- 2. DEGRADED_SELF_REPORTED -- the edge is heartbeating on schedule and +-- says it is NOT well. Ranked above lag because it is a direct +-- first-party statement, not an inference from a derived metric. +-- 3. DEGRADED_LAG -- inferred from lag columns (inert today). +-- 4. HEALTHY -- fresh, self-reporting active, no lag breach. +CREATE OR REPLACE VIEW omninode_internal.gateway_link_health_status AS +SELECT + tenant_id, + principal_id, + local_transport_flavor, + last_seen_at, + reported_status, + consecutive_failures, + lag_messages, + lag_seconds, + updated_at, + EXTRACT(EPOCH FROM (NOW() - last_seen_at)) AS seconds_since_last_seen, + CASE + WHEN NOW() - last_seen_at > INTERVAL '60 seconds' THEN 'UNHEALTHY' + WHEN reported_status <> 'active' THEN 'DEGRADED_SELF_REPORTED' + WHEN lag_messages IS NOT NULL AND lag_messages > 500 THEN 'DEGRADED_LAG' + WHEN lag_seconds IS NOT NULL AND lag_seconds > 120 THEN 'DEGRADED_LAG' + ELSE 'HEALTHY' + END AS health_status +FROM omninode_internal.gateway_link_health; + +COMMENT ON VIEW omninode_internal.gateway_link_health_status IS + 'Read-time health evaluation over gateway_link_health. health_status ' + 'flips to UNHEALTHY once a row goes stale beyond ' + 'max_silence_window_seconds (60s, node_bus_forwarder_effect/contract.yaml:61) ' + 'without the row ever disappearing -- absence of progress is visible as ' + 'a stale row, not a missing one. An edge that IS heartbeating on time but ' + 'reports itself degraded scores DEGRADED_SELF_REPORTED, never HEALTHY. ' + 'Precedence is fixed (stale > self-reported > lag) and documented above ' + 'the view body. DEGRADED_LAG arms remain inert until a producer ' + 'populates lag_messages/lag_seconds.'; diff --git a/docker/migrations/rollback/rollback_100_create_gateway_link_health.sql b/docker/migrations/rollback/rollback_100_create_gateway_link_health.sql new file mode 100644 index 0000000000..f17178db8b --- /dev/null +++ b/docker/migrations/rollback/rollback_100_create_gateway_link_health.sql @@ -0,0 +1,10 @@ +-- OMN-15570: Rollback for 100_create_gateway_link_health.sql. +-- +-- Reverses creation of the gateway_link_health_status view, the +-- gateway_link_health latest-known-state projection table, and its +-- last_seen_at index. Manual execution only -- never auto-applied (rollback/ +-- is not mounted to docker-entrypoint-initdb.d). + +DROP VIEW IF EXISTS omninode_internal.gateway_link_health_status; +DROP INDEX IF EXISTS omninode_internal.idx_gateway_link_health_last_seen_at; +DROP TABLE IF EXISTS omninode_internal.gateway_link_health; diff --git a/docker/migrations/schema_fingerprint.sha256 b/docker/migrations/schema_fingerprint.sha256 index 816115f458..77edf0ce5f 100644 --- a/docker/migrations/schema_fingerprint.sha256 +++ b/docker/migrations/schema_fingerprint.sha256 @@ -1,6 +1,6 @@ # Schema migration fingerprint for omnibase_infra (auto-generated) # Regenerate: python scripts/check_schema_fingerprint.py stamp # Verify: python scripts/check_schema_fingerprint.py verify -sha256:9a5a9e3f3ec61f88abfeba77a0108476b8848a7d24926b18ec0ce4c3602248d8 -generated_at: 2026-08-10T23:12:56Z -migration_file_count: 84 +sha256:7e8eafcb5a0e88c727c5b331cdb3e244793acc815400e534560e3d7d6e41637a +generated_at: 2026-08-22T16:54:32Z +migration_file_count: 85 diff --git a/docker/runners/runner-image.lock.json b/docker/runners/runner-image.lock.json index 282bbe00c4..cbafda9e1e 100644 --- a/docker/runners/runner-image.lock.json +++ b/docker/runners/runner-image.lock.json @@ -1,12 +1,12 @@ { "base_image_digest": "sha256:3ba65aa20f86a0fad9df2b2c259c613df006b2e6d0bfcc8a146afb8c525a9751", "gh_version": "2.67.0", - "identity_digest": "00e1e1b5419d2c70d66e048c4cd235de", + "identity_digest": "0b730798ae1059f21e667eaf8e5653fa", "image_version": 7, "kubectl_version": "1.32.1", "python_version": "3.12", "runner_version": "2.336.0", - "shared_env_digest": "ab7726c96d67995001339ef7", + "shared_env_digest": "beeaf561515525f79488721a", "shared_env_install_args": "--frozen --all-extras --all-groups --no-install-project", "uv_version": "0.6.14" } diff --git a/docs/evidence/OMN-12548/determinism-audit.json b/docs/evidence/OMN-12548/determinism-audit.json index 52ce365778..40849f1ad6 100644 --- a/docs/evidence/OMN-12548/determinism-audit.json +++ b/docs/evidence/OMN-12548/determinism-audit.json @@ -1,103 +1,632 @@ { "epic": "OMN-12525", "gate_posture": "Report-only at S0 (design D3). Each entry is a real multi-match tie resolved by registration/insertion order \u2014 a latent production hazard ticketed under OMN-12525, NOT gate-blocking in this parity PR.", - "generated_at_utc": "2026-07-02T18:41:20.832207+00:00", - "order_sensitive_count": 4, + "generated_at_utc": "2026-08-20T13:40:04.122324+00:00", + "order_sensitive_count": 27, "order_sensitive_probes": [ { "canonical": { "dispatcher_ids": [ - "dispatcher.auto.node_github_pr_poller_effect.HandlerGitHubApiPoll.github_poll_prs_7786448a", - "dispatcher.auto.node_registration_orchestrator.HandlerRuntimeTick" + "dispatcher.auto.node_chain_orchestrator.HandlerChainLearnInitiate", + "dispatcher.auto.node_chain_retrieval_effect.HandlerChainRetrieval.chain_retrieve_489f06d3" ], "dlq_topic": null, - "message_category": "intent", - "message_type": "platform.runtime-tick", + "message_category": "command", + "message_type": "omnibase-infra.chain-learn", "status": "success" }, "permuted": { "dispatcher_ids": [ - "dispatcher.auto.node_registration_orchestrator.HandlerRuntimeTick", - "dispatcher.auto.node_github_pr_poller_effect.HandlerGitHubApiPoll.github_poll_prs_7786448a" + "dispatcher.auto.node_chain_retrieval_effect.HandlerChainRetrieval.chain_retrieve_489f06d3", + "dispatcher.auto.node_chain_orchestrator.HandlerChainLearnInitiate" ], "dlq_topic": null, - "message_category": "intent", - "message_type": "platform.runtime-tick", + "message_category": "command", + "message_type": "omnibase-infra.chain-learn", "status": "success" }, - "probe_id": "P4::dispatcher.auto.node_github_pr_poller_effect.HandlerGitHubApiPoll.github_poll_prs_7786448a::onex.intent.platform.runtime-tick.v1" + "probe_id": "P4::dispatcher.auto.node_chain_orchestrator.HandlerChainLearnInitiate::onex.cmd.omnibase-infra.chain-learn.v1" }, { "canonical": { "dispatcher_ids": [ - "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_c56fe78d", - "dispatcher.auto.node_registration_orchestrator.HandlerNodeHeartbeat" + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" ], "dlq_topic": null, "message_category": "event", - "message_type": "platform.node-heartbeat", + "message_type": "omnibase-infra.events", "status": "success" }, "permuted": { "dispatcher_ids": [ - "dispatcher.auto.node_registration_orchestrator.HandlerNodeHeartbeat", - "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_c56fe78d" + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" ], "dlq_topic": null, "message_category": "event", - "message_type": "platform.node-heartbeat", + "message_type": "omnibase-infra.events", "status": "success" }, - "probe_id": "P4::dispatcher.auto.node_registration_orchestrator.HandlerNodeHeartbeat::onex.evt.platform.node-heartbeat.v1" + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_09114b86::onex.dlq.omnibase-infra.events.v1" }, { "canonical": { "dispatcher_ids": [ - "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_c56fe78d", - "dispatcher.auto.node_registration_orchestrator.HandlerNodeIntrospected" + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" ], "dlq_topic": null, "message_category": "event", - "message_type": "platform.node-introspection", + "message_type": "omnibase-infra.events", "status": "success" }, "permuted": { "dispatcher_ids": [ - "dispatcher.auto.node_registration_orchestrator.HandlerNodeIntrospected", - "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_c56fe78d" + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" ], "dlq_topic": null, "message_category": "event", - "message_type": "platform.node-introspection", + "message_type": "omnibase-infra.events", "status": "success" }, - "probe_id": "P4::dispatcher.auto.node_registration_orchestrator.HandlerNodeIntrospected::onex.evt.platform.node-introspection.v1" + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_106cf59a::onex.dlq.omnibase-infra.events.v1" }, { "canonical": { "dispatcher_ids": [ - "dispatcher.auto.node_github_pr_poller_effect.HandlerGitHubApiPoll.github_poll_prs_7786448a", - "dispatcher.auto.node_registration_orchestrator.HandlerRuntimeTick" + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" ], "dlq_topic": null, - "message_category": "intent", - "message_type": "platform.runtime-tick", + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "permuted": { + "dispatcher_ids": [ + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_20b031bf::onex.dlq.omnibase-infra.events.v1" + }, + { + "canonical": { + "dispatcher_ids": [ + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "permuted": { + "dispatcher_ids": [ + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_3579d375::onex.dlq.omnibase-infra.events.v1" + }, + { + "canonical": { + "dispatcher_ids": [ + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "permuted": { + "dispatcher_ids": [ + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_3bd91ec3::onex.dlq.omnibase-infra.events.v1" + }, + { + "canonical": { + "dispatcher_ids": [ + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "permuted": { + "dispatcher_ids": [ + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_4180e0d9::onex.dlq.omnibase-infra.events.v1" + }, + { + "canonical": { + "dispatcher_ids": [ + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "permuted": { + "dispatcher_ids": [ + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_50a2a9ec::onex.dlq.omnibase-infra.events.v1" + }, + { + "canonical": { + "dispatcher_ids": [ + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "permuted": { + "dispatcher_ids": [ + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_520a72c1::onex.dlq.omnibase-infra.events.v1" + }, + { + "canonical": { + "dispatcher_ids": [ + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "permuted": { + "dispatcher_ids": [ + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_58404bf7::onex.dlq.omnibase-infra.events.v1" + }, + { + "canonical": { + "dispatcher_ids": [ + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "permuted": { + "dispatcher_ids": [ + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_6ef6ade1::onex.dlq.omnibase-infra.events.v1" + }, + { + "canonical": { + "dispatcher_ids": [ + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "permuted": { + "dispatcher_ids": [ + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_77066b28::onex.dlq.omnibase-infra.events.v1" + }, + { + "canonical": { + "dispatcher_ids": [ + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "permuted": { + "dispatcher_ids": [ + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_7dc2dc0c::onex.dlq.omnibase-infra.events.v1" + }, + { + "canonical": { + "dispatcher_ids": [ + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "permuted": { + "dispatcher_ids": [ + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_85d7b3d6::onex.dlq.omnibase-infra.events.v1" + }, + { + "canonical": { + "dispatcher_ids": [ + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "permuted": { + "dispatcher_ids": [ + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_8d5b772c::onex.dlq.omnibase-infra.events.v1" + }, + { + "canonical": { + "dispatcher_ids": [ + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "permuted": { + "dispatcher_ids": [ + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_982021ab::onex.dlq.omnibase-infra.events.v1" + }, + { + "canonical": { + "dispatcher_ids": [ + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", "status": "success" }, "permuted": { "dispatcher_ids": [ - "dispatcher.auto.node_registration_orchestrator.HandlerRuntimeTick", - "dispatcher.auto.node_github_pr_poller_effect.HandlerGitHubApiPoll.github_poll_prs_7786448a" + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" ], "dlq_topic": null, - "message_category": "intent", - "message_type": "platform.runtime-tick", + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_9e34c3f2::onex.dlq.omnibase-infra.events.v1" + }, + { + "canonical": { + "dispatcher_ids": [ + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "permuted": { + "dispatcher_ids": [ + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a0d95493::onex.dlq.omnibase-infra.events.v1" + }, + { + "canonical": { + "dispatcher_ids": [ + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "permuted": { + "dispatcher_ids": [ + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f::onex.dlq.omnibase-infra.events.v1" + }, + { + "canonical": { + "dispatcher_ids": [ + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "permuted": { + "dispatcher_ids": [ + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a997bbff::onex.dlq.omnibase-infra.events.v1" + }, + { + "canonical": { + "dispatcher_ids": [ + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "permuted": { + "dispatcher_ids": [ + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_b1cf841e::onex.dlq.omnibase-infra.events.v1" + }, + { + "canonical": { + "dispatcher_ids": [ + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "permuted": { + "dispatcher_ids": [ + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_ba940a93::onex.dlq.omnibase-infra.events.v1" + }, + { + "canonical": { + "dispatcher_ids": [ + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "permuted": { + "dispatcher_ids": [ + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_cb26001f::onex.dlq.omnibase-infra.events.v1" + }, + { + "canonical": { + "dispatcher_ids": [ + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "permuted": { + "dispatcher_ids": [ + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_ccb60a8d::onex.dlq.omnibase-infra.events.v1" + }, + { + "canonical": { + "dispatcher_ids": [ + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "permuted": { + "dispatcher_ids": [ + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_d9e78fa2::onex.dlq.omnibase-infra.events.v1" + }, + { + "canonical": { + "dispatcher_ids": [ + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "permuted": { + "dispatcher_ids": [ + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_dfa36ec6::onex.dlq.omnibase-infra.events.v1" + }, + { + "canonical": { + "dispatcher_ids": [ + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150", + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", + "status": "success" + }, + "permuted": { + "dispatcher_ids": [ + "dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_a132611f", + "dispatcher.auto.node_dlq_replay_effect.HandlerDlqReplay.replay_dlq_156fb150" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.events", "status": "success" }, - "probe_id": "P4::dispatcher.auto.node_registration_orchestrator.HandlerRuntimeTick::onex.intent.platform.runtime-tick.v1" + "probe_id": "P4::dispatcher.auto.node_ledger_projection_compute.HandlerLedgerProjection.ledger_project_e1f3db33::onex.dlq.omnibase-infra.events.v1" } ], - "probe_count": 305, + "probe_count": 1050, "seed": 1, "ticket": "OMN-12548" } diff --git a/pyproject.toml b/pyproject.toml index 780eecf050..bf4cfd9700 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -604,6 +604,8 @@ node_event_bus_wiring_effect = "omnibase_infra.nodes.node_event_bus_wiring_effec node_event_forward_effect = "omnibase_infra.nodes.node_event_forward_effect" node_evidence_autoclose_sweep_effect = "omnibase_infra.nodes.node_evidence_autoclose_sweep_effect" node_gateway_attach_effect = "omnibase_infra.nodes.node_gateway_attach_effect" +node_gateway_link_health_projection_compute = "omnibase_infra.nodes.node_gateway_link_health_projection_compute" +node_gateway_link_health_write_effect = "omnibase_infra.nodes.node_gateway_link_health_write_effect" node_github_pr_poller_effect = "omnibase_infra.nodes.node_github_pr_poller_effect" node_gmail_archive_cleanup_effect = "omnibase_infra.nodes.node_gmail_archive_cleanup_effect" node_gmail_intent_poller_effect = "omnibase_infra.nodes.node_gmail_intent_poller_effect" diff --git a/scripts/check_subscribe_wiring_health.py b/scripts/check_subscribe_wiring_health.py index aee506fb84..5eb5783207 100644 --- a/scripts/check_subscribe_wiring_health.py +++ b/scripts/check_subscribe_wiring_health.py @@ -140,6 +140,14 @@ "onex.cmd.omnibase-infra.build-loop-start.v1": "Triggered by cron-buildloop.sh via claude -p, not Kafka | owner: jonah | expiry: 2026-12-01", "onex.cmd.omnibase-infra.build-loop-append.v1": "Routed via intent from node_build_loop_projection_compute, not Kafka publish | owner: jonah | expiry: 2026-09-01", "onex.cmd.omnibase-infra.pr-state-upsert.v1": "Routed via intent from node_pr_state_projection_compute, not Kafka publish | owner: jonah | expiry: 2026-09-01", + "onex.cmd.omnibase-infra.gateway-link-health-upsert.v1": "Routed via intent from node_gateway_link_health_projection_compute, not Kafka publish (OMN-15570) | owner: jonah | expiry: 2026-12-01", + # Gateway heartbeat — published imperatively by ServiceGatewayForwarder.publish_heartbeat + # (node_bus_forwarder_effect/services/service_gateway_forwarder.py), not via a + # contract-declared event_bus.publish_topics entry -- node_bus_forwarder_effect's + # own contract only declares mirror_topics (this checker reads publish_topics + # only), which the 2026-08-08 gateway lift architecture assessment already flags + # as a gap in that node's own contract, not something OMN-15570 introduces. + "onex.evt.omnibase-infra.gateway-heartbeat.v1": "Published imperatively by ServiceGatewayForwarder.publish_heartbeat, not via contract publish_topics (OMN-15570 adds the first contract-declared consumer) | owner: jonah | expiry: 2026-12-01", "onex.cmd.omnibase-infra.validation-ledger-append.v1": "Routed via intent from node_validation_ledger_projection_compute, not Kafka publish | owner: jonah | expiry: 2026-12-01", # Chain learning — publisher nodes not yet implemented "onex.cmd.omnibase-infra.chain-learn.v1": "Chain learning publisher not yet wired | owner: jonah | expiry: 2026-09-01", diff --git a/scripts/ci/infra-node-allowlist.txt b/scripts/ci/infra-node-allowlist.txt index 26c6fe269c..f810e033cc 100644 --- a/scripts/ci/infra-node-allowlist.txt +++ b/scripts/ci/infra-node-allowlist.txt @@ -270,5 +270,9 @@ src/omnibase_infra/nodes/node_pr_state_projection_compute/handlers/__init__.py src/omnibase_infra/nodes/node_pr_state_projection_compute/handlers/handler_pr_state_projection.py # OMN-14375: GitHub-state projection is platform/WS-L infra (same class as node_build_loop_projection_compute/node_ledger_projection_compute above), not a business-domain workflow. src/omnibase_infra/nodes/node_pr_state_write_effect/handlers/__init__.py # OMN-14375: GitHub-state projection is platform/WS-L infra (same class as node_build_loop_write_effect/node_ledger_write_effect above), not a business-domain workflow. src/omnibase_infra/nodes/node_pr_state_write_effect/handlers/handler_pr_state_upsert.py # OMN-14375: GitHub-state projection is platform/WS-L infra (same class as node_build_loop_write_effect/node_ledger_write_effect above), not a business-domain workflow. +src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/handlers/__init__.py # OMN-15570: gateway link-health projection is platform/infra observability (same class as node_pr_state_projection_compute above), not a business-domain workflow. +src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/handlers/handler_gateway_link_health_projection.py # OMN-15570: gateway link-health projection is platform/infra observability (same class as node_pr_state_projection_compute above), not a business-domain workflow. +src/omnibase_infra/nodes/node_gateway_link_health_write_effect/handlers/__init__.py # OMN-15570: gateway link-health persistence is platform/infra observability (same class as node_pr_state_write_effect above), not a business-domain workflow. +src/omnibase_infra/nodes/node_gateway_link_health_write_effect/handlers/handler_gateway_link_health_upsert.py # OMN-15570: gateway link-health persistence is platform/infra observability (same class as node_pr_state_write_effect above), not a business-domain workflow. src/omnibase_infra/nodes/node_evidence_autoclose_sweep_effect/handlers/__init__.py # OMN-16106: infra-owned governed-Done-flip sweep. Shells out to `uv run onex skill dod_verify` verbatim, which dispatches through the omnimarket co-install into THIS venv (see omnimarket_drift_guard) — the sweep itself must run from the same infra locality, so it is not a portable omnimarket business-domain node. src/omnibase_infra/nodes/node_evidence_autoclose_sweep_effect/handlers/handler_evidence_autoclose_sweep.py # OMN-16106: infra-owned governed-Done-flip sweep. Shells out to `uv run onex skill dod_verify` verbatim, which dispatches through the omnimarket co-install into THIS venv (see omnimarket_drift_guard) — the sweep itself must run from the same infra locality, so it is not a portable omnimarket business-domain node. diff --git a/src/omnibase_infra/enums/generated/enum_omnibase_infra_topic.py b/src/omnibase_infra/enums/generated/enum_omnibase_infra_topic.py index ff9ca4d5aa..efe5db0512 100644 --- a/src/omnibase_infra/enums/generated/enum_omnibase_infra_topic.py +++ b/src/omnibase_infra/enums/generated/enum_omnibase_infra_topic.py @@ -28,6 +28,7 @@ class EnumOmnibaseInfraTopic(str, Enum): CMD_GATEWAY_ATTACH_REQUEST_V1 = "onex.cmd.omnibase-infra.gateway-attach-request.v1" # onex.cmd.omnibase-infra.gateway-attach-request.v1 CMD_GATEWAY_DETACH_REQUEST_V1 = "onex.cmd.omnibase-infra.gateway-detach-request.v1" # onex.cmd.omnibase-infra.gateway-detach-request.v1 CMD_GATEWAY_HEARTBEAT_REQUEST_V1 = "onex.cmd.omnibase-infra.gateway-heartbeat-request.v1" # onex.cmd.omnibase-infra.gateway-heartbeat-request.v1 + CMD_GATEWAY_LINK_HEALTH_UPSERT_V1 = "onex.cmd.omnibase-infra.gateway-link-health-upsert.v1" # onex.cmd.omnibase-infra.gateway-link-health-upsert.v1 CMD_LLM_COMPLETION_REQUEST_V1 = "onex.cmd.omnibase-infra.llm-completion-request.v1" # onex.cmd.omnibase-infra.llm-completion-request.v1 CMD_LLM_EMBEDDING_REQUEST_V1 = "onex.cmd.omnibase-infra.llm-embedding-request.v1" # onex.cmd.omnibase-infra.llm-embedding-request.v1 CMD_LLM_INFERENCE_REQUEST_V1 = "onex.cmd.omnibase-infra.llm-inference-request.v1" # onex.cmd.omnibase-infra.llm-inference-request.v1 @@ -69,6 +70,7 @@ class EnumOmnibaseInfraTopic(str, Enum): EVT_CONSUMER_HEALTH_V1 = "onex.evt.omnibase-infra.consumer-health.v1" # onex.evt.omnibase-infra.consumer-health.v1 EVT_DB_ERROR_V1 = "onex.evt.omnibase-infra.db-error.v1" # onex.evt.omnibase-infra.db-error.v1 EVT_EVENT_FORWARDED_V1 = "onex.evt.omnibase-infra.event-forwarded.v1" # onex.evt.omnibase-infra.event-forwarded.v1 + EVT_GATEWAY_HEARTBEAT_V1 = "onex.evt.omnibase-infra.gateway-heartbeat.v1" # onex.evt.omnibase-infra.gateway-heartbeat.v1 EVT_GATEWAY_SESSION_V1 = "onex.evt.omnibase-infra.gateway-session.v1" # onex.evt.omnibase-infra.gateway-session.v1 EVT_GMAIL_INTENT_RECEIVED_V1 = "onex.evt.omnibase-infra.gmail-intent-received.v1" # onex.evt.omnibase-infra.gmail-intent-received.v1 EVT_INFERENCE_RESPONSE_V1 = "onex.evt.omnibase-infra.inference-response.v1" # onex.evt.omnibase-infra.inference-response.v1 diff --git a/src/omnibase_infra/nodes/node_bus_forwarder_effect/services/service_gateway_forwarder.py b/src/omnibase_infra/nodes/node_bus_forwarder_effect/services/service_gateway_forwarder.py index 357dbae5ee..b434c7d07d 100644 --- a/src/omnibase_infra/nodes/node_bus_forwarder_effect/services/service_gateway_forwarder.py +++ b/src/omnibase_infra/nodes/node_bus_forwarder_effect/services/service_gateway_forwarder.py @@ -224,7 +224,24 @@ def _build_status_envelope( return envelope, canonical_topic async def publish_heartbeat(self) -> None: - """Publish one tenant-scoped liveness event onto the cloud wire topic.""" + """Publish one tenant-scoped liveness event onto the cloud wire topic + and mirror the same, untransformed envelope onto the local bus's + canonical topic. + + OMN-15570 (G3): the local mirror exists because + NodeGatewayLinkHealthProjectionCompute subscribes to + ``onex.evt.omnibase-infra.gateway-heartbeat.v1`` on the LOCAL bus + (bus-is-transport: in-cluster consumers read local canonical + topics, never the tenant-prefixed cloud wire topic) -- before this + fix, heartbeats only ever reached the cloud leg, so the projection + never saw a live event. This is the minimal doctrinally-correct + fix: it reuses the exact dual-publish shape ``publish_status`` + (OMN-15742/G2) already established for the DEGRADED transition, + rather than inventing a second mechanism, and publishes the SAME + envelope object both places so envelope_id/correlation_id stay + identical across legs instead of minting two envelopes for one + liveness tick. + """ identity = self._config.tenant_identity envelope, canonical_topic = self._build_status_envelope("active") # OMN-15740: _prepare_outbound returns (transformed_envelope, wire_topic) -- @@ -238,6 +255,12 @@ async def publish_heartbeat(self) -> None: key=str(identity.tenant_id).encode("utf-8"), value=self._encode_envelope(transformed), ) + await self._publish_with_delivery_retry( + bus=self._local_bus, + topic=canonical_topic, + key=str(identity.tenant_id).encode("utf-8"), + value=self._encode_envelope(envelope), + ) async def publish_status( self, diff --git a/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/__init__.py b/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/__init__.py new file mode 100644 index 0000000000..efe954d7a4 --- /dev/null +++ b/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/__init__.py @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 OmniNode Team +"""node_gateway_link_health_projection_compute - gateway link-health projection node. + +Declarative COMPUTE node that subscribes to +onex.evt.omnibase-infra.gateway-heartbeat.v1 (published by +node_bus_forwarder_effect) and emits ModelIntent payloads for the EFFECT +layer to persist into gateway_link_health. + +Ticket: OMN-15570 (G3, gateway lift Phase 0) +""" + +from omnibase_infra.nodes.node_gateway_link_health_projection_compute.handlers import ( + HandlerGatewayLinkHealthProjection, +) +from omnibase_infra.nodes.node_gateway_link_health_projection_compute.models import ( + ModelPayloadGatewayLinkHealthUpsert, +) +from omnibase_infra.nodes.node_gateway_link_health_projection_compute.node import ( + NodeGatewayLinkHealthProjectionCompute, +) +from omnibase_infra.nodes.node_gateway_link_health_projection_compute.registry import ( + RegistryInfraGatewayLinkHealthProjection, +) + +__all__ = [ + "HandlerGatewayLinkHealthProjection", + "ModelPayloadGatewayLinkHealthUpsert", + "NodeGatewayLinkHealthProjectionCompute", + "RegistryInfraGatewayLinkHealthProjection", +] diff --git a/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/contract.yaml b/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/contract.yaml new file mode 100644 index 0000000000..683e346725 --- /dev/null +++ b/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/contract.yaml @@ -0,0 +1,107 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 OmniNode Team +# +# ONEX Node Contract +# Node: NodeGatewayLinkHealthProjectionCompute +# +# COMPUTE node that subscribes to the gateway heartbeat event topic +# (produced by node_bus_forwarder_effect / ServiceGatewayForwarder) and +# emits ModelIntent payloads for the EFFECT layer to persist into +# gateway_link_health. Mirrors the canonical node_pr_state_projection_compute +# pattern. +# +# Related Tickets: +# - OMN-15570: Gateway lift Phase 0, item G3 (link-health projection). +# - OMN-15739 / OMN-15692: parent connectivity-brief tickets. G3 depends on +# G1 (OMN-15741, path-verifying healthcheck) and G2 (OMN-15742, reconnect +# supervision + DEGRADED bus state) per the lift plan's dependency order, +# both of which have since merged. This node consumes ONLY the heartbeat +# topic; it does not depend on G1/G2 code, only on the payload they +# produce. G2 widened that payload with status/consecutive_failures, and +# this node reads both: the edge's own verdict is persisted and outranks +# recency in gateway_link_health_status, so an edge that heartbeats on +# schedule while reporting itself degraded scores +# DEGRADED_SELF_REPORTED, never HEALTHY. Lag remains the one declared +# threshold family no producer populates. See +# ModelPayloadGatewayLinkHealthUpsert's module docstring for the exact +# field-level gap (lag_messages/lag_seconds always None today). +name: "node_gateway_link_health_projection_compute" +node_type: "COMPUTE_GENERIC" +contract_version: + major: 1 + minor: 0 + patch: 0 +node_version: "0.1.0" +description: > + Subscribes to onex.evt.omnibase-infra.gateway-heartbeat.v1 and projects each heartbeat event into a ModelPayloadGatewayLinkHealthUpsert intent for NodeGatewayLinkHealthWriteEffect to persist into the gateway_link_health per-tenant-edge liveness projection. Uses the canonical def-B typed ModelEventMessage handler entrypoint and mirrors the canonical node_pr_state_projection_compute pattern. + +event_bus: + version: + major: 1 + minor: 0 + patch: 0 + subscribe_topics: + - "onex.evt.omnibase-infra.gateway-heartbeat.v1" + consumer_purpose: "audit" + auto_offset_reset: "earliest" + consumer_group: "onex-gateway-link-health-projection-compute" +input_model: + name: "ModelEventMessage" + module: "omnibase_infra.event_bus.models.model_event_message" + description: > + Kafka event message containing raw bytes payload and headers for the gateway heartbeat event. + +output_model: + name: "ModelIntent" + module: "omnibase_core.models.reducer.model_intent" + description: > + ModelIntent wrapping ModelPayloadGatewayLinkHealthUpsert, routed to NodeGatewayLinkHealthWriteEffect for persistence. + +handler_routing: + routing_strategy: "operation_match" + handlers: + - operation: gateway_link_health.project + handler_type: "gateway_link_health_projection" + handler: + name: "HandlerGatewayLinkHealthProjection" + module: "omnibase_infra.nodes.node_gateway_link_health_projection_compute.handlers.handler_gateway_link_health_projection" + supported_operations: + - "gateway_link_health.project" +# Intent consumption: the projection emits a `gateway_link_health.upsert` +# intent per consumed heartbeat event. The kernel derives this contract's +# result applier from this table -- it names the effect node whose canonical +# handle() persists the intent (node_gateway_link_health_write_effect -> +# HandlerGatewayLinkHealthUpsert, keyed by the write-effect's +# `gateway_link_health.upsert` operation). Mirrors node_pr_state_projection_compute's +# intent_consumption table (OMN-14516). +intent_consumption: + intent_routing_table: + "gateway_link_health.upsert": "node_gateway_link_health_write_effect" +capabilities: + - name: "gateway_link_health.projection" + description: "Project the gateway heartbeat event into a write intent" + version: "0.1.0" + - name: "gateway_link_health.projection.audit" + description: "Read-only, latest-state consumption of gateway heartbeat events" +dependencies: + - name: "node_gateway_link_health_write_effect" + type: "node" + class_name: "NodeGatewayLinkHealthWriteEffect" + module: "omnibase_infra.nodes.node_gateway_link_health_write_effect" + description: "Effect node that persists projected intents into gateway_link_health" +metadata: + description: > + Subscribes to the gateway heartbeat event topic and projects each event into a ModelPayloadGatewayLinkHealthUpsert intent for the EFFECT layer to persist into the gateway_link_health per-tenant-edge liveness projection through the canonical def-B typed ModelEventMessage handler entrypoint. + + author: "OmniNode Team" + license: "MIT" + created: "2026-08-08" + updated: "2026-08-08" + ticket: "OMN-15570" + tags: + - compute + - def-b + - gateway + - link_health + - projection diff --git a/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/handlers/__init__.py b/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/handlers/__init__.py new file mode 100644 index 0000000000..1adbb22f1f --- /dev/null +++ b/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/handlers/__init__.py @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 OmniNode Team +"""Handlers for node_gateway_link_health_projection_compute.""" + +from omnibase_infra.nodes.node_gateway_link_health_projection_compute.handlers.handler_gateway_link_health_projection import ( + HandlerGatewayLinkHealthProjection, +) + +__all__ = ["HandlerGatewayLinkHealthProjection"] diff --git a/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/handlers/handler_gateway_link_health_projection.py b/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/handlers/handler_gateway_link_health_projection.py new file mode 100644 index 0000000000..c45395b7d6 --- /dev/null +++ b/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/handlers/handler_gateway_link_health_projection.py @@ -0,0 +1,274 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 OmniNode Team +"""HandlerGatewayLinkHealthProjection - heartbeat event -> ModelIntent transformer. + +Mirrors the canonical HandlerPrStateProjection / HandlerBuildLoopProjection +shape. Receives a Kafka ModelEventMessage carrying +``onex.evt.omnibase-infra.gateway-heartbeat.v1`` (``ModelGatewayHeartbeat``, +published by ``node_bus_forwarder_effect``), extracts the tenant edge +identity and freshness stamp, and emits a ModelIntent with a +ModelPayloadGatewayLinkHealthUpsert payload for +NodeGatewayLinkHealthWriteEffect to persist. + +Ticket: OMN-15570 (G3, gateway lift Phase 0) +""" + +from __future__ import annotations + +import json +import logging +from datetime import UTC, datetime +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + +from omnibase_core.enums import EnumCoreErrorCode +from omnibase_core.models.dispatch import ModelHandlerOutput +from omnibase_core.models.reducer.model_intent import ModelIntent +from omnibase_core.types import JsonType +from omnibase_infra.enums import ( + EnumHandlerType, + EnumHandlerTypeCategory, + EnumInfraTransportType, +) +from omnibase_infra.errors import ModelInfraErrorContext, RuntimeHostError +from omnibase_infra.event_bus.models.model_event_message import ModelEventMessage +from omnibase_infra.nodes.node_gateway_link_health_projection_compute.models.model_payload_gateway_link_health_upsert import ( + ModelPayloadGatewayLinkHealthUpsert, +) + +if TYPE_CHECKING: + from omnibase_core.container import ModelONEXContainer + +logger = logging.getLogger(__name__) + +HANDLER_ID_GATEWAY_LINK_HEALTH_PROJECTION: str = ( + "gateway-link-health-projection-handler" +) + + +def _require_str( + body: dict[str, JsonType], + key: str, + correlation_id: UUID | None, +) -> str: + """Return a required non-empty string field, or raise RuntimeHostError.""" + value = body.get(key) + if isinstance(value, str) and value: + return value + context = ModelInfraErrorContext.with_correlation( + correlation_id=correlation_id, + transport_type=EnumInfraTransportType.KAFKA, + operation="gateway_link_health_projection.extract_payload", + ) + raise RuntimeHostError( + f"Gateway heartbeat event missing required field '{key}'", + error_code=EnumCoreErrorCode.INVALID_INPUT, + context=context, + ) + + +def _require_non_negative_int( + body: dict[str, JsonType], + key: str, + correlation_id: UUID | None, +) -> int: + """Return a required non-negative int field, or raise RuntimeHostError. + + ``bool`` is rejected explicitly: it is an ``int`` subclass in Python, so + a producer sending ``true`` would otherwise be silently recorded as 1. + """ + value = body.get(key) + if isinstance(value, int) and not isinstance(value, bool) and value >= 0: + return value + context = ModelInfraErrorContext.with_correlation( + correlation_id=correlation_id, + transport_type=EnumInfraTransportType.KAFKA, + operation="gateway_link_health_projection.extract_payload", + ) + raise RuntimeHostError( + f"Gateway heartbeat event missing required non-negative int field '{key}'", + error_code=EnumCoreErrorCode.INVALID_INPUT, + context=context, + ) + + +class HandlerGatewayLinkHealthProjection: + """COMPUTE handler that projects gateway heartbeat events into write intents.""" + + def __init__(self, container: ModelONEXContainer) -> None: + self._container = container + self._initialized: bool = False + + @property + def handler_type(self) -> EnumHandlerType: + return EnumHandlerType.COMPUTE_HANDLER + + @property + def handler_category(self) -> EnumHandlerTypeCategory: + return EnumHandlerTypeCategory.COMPUTE + + async def initialize(self, config: dict[str, object]) -> None: + self._initialized = True + logger.info( + "%s initialized successfully", + self.__class__.__name__, + extra={"handler": self.__class__.__name__}, + ) + + async def shutdown(self) -> None: + self._initialized = False + logger.info("HandlerGatewayLinkHealthProjection shutdown complete") + + def project(self, message: ModelEventMessage) -> ModelIntent: + """Transform a gateway heartbeat event message into an upsert intent.""" + payload = self._extract_payload(message) + return ModelIntent( + intent_type=payload.intent_type, + target=f"postgres://gateway_link_health/{payload.tenant_id}", + payload=payload, + ) + + async def handle( + self, + message: ModelEventMessage, + ) -> ModelHandlerOutput[ModelIntent]: + """Canonical def-B dispatch entrypoint. + + The shared runtime adapter validates the wire payload into the + contract-declared ``ModelEventMessage`` input model and hands it here + -- the envelope boundary lives in the runtime adapter, not in this + core (definition B, OMN-14355). This projects the gateway heartbeat + event into a ``gateway_link_health.upsert`` intent for + ``NodeGatewayLinkHealthWriteEffect`` to persist. + """ + raw_message = self._coerce_event_message(message) + intent = self.project(raw_message) + return ModelHandlerOutput.for_compute( + input_envelope_id=uuid4(), + correlation_id=raw_message.headers.correlation_id, + handler_id=HANDLER_ID_GATEWAY_LINK_HEALTH_PROJECTION, + result=intent, + ) + + def _extract_payload( + self, message: ModelEventMessage + ) -> ModelPayloadGatewayLinkHealthUpsert: + """Extract heartbeat fields into a ModelPayloadGatewayLinkHealthUpsert. + + Required: tenant_id, principal_id, local_transport_flavor, a + parseable emitted_at, status, and consecutive_failures -- a heartbeat + without an identifiable tenant edge is a producer bug, not a + partial-data case. + + status and consecutive_failures are required rather than defaulted + (OMN-15742/G2 put both on every ModelGatewayHeartbeat). Defaulting a + missing status to "active" would let a malformed or non-canonical + producer be scored HEALTHY, which is the one failure mode this + projection must not have: silence is already visible as staleness, + but a wrongly-healthy verdict is invisible. Raising instead surfaces + the producer bug. + + lag_messages / lag_seconds are still always absent from + ModelGatewayHeartbeat (see the payload module docstring) and default + to None. + """ + headers = message.headers + header_correlation_id = headers.correlation_id if headers else None + + try: + decoded = json.loads(message.value.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as e: + context = ModelInfraErrorContext.with_correlation( + correlation_id=header_correlation_id, + transport_type=EnumInfraTransportType.KAFKA, + operation="gateway_link_health_projection.extract_payload", + ) + raise RuntimeHostError( + f"Cannot decode gateway heartbeat event body as JSON: {type(e).__name__}", + error_code=EnumCoreErrorCode.INVALID_INPUT, + context=context, + ) from e + + if not isinstance(decoded, dict): + context = ModelInfraErrorContext.with_correlation( + correlation_id=header_correlation_id, + transport_type=EnumInfraTransportType.KAFKA, + operation="gateway_link_health_projection.extract_payload", + ) + raise RuntimeHostError( + "Gateway heartbeat event body must decode to a JSON object, " + f"got {type(decoded).__name__}.", + error_code=EnumCoreErrorCode.INVALID_INPUT, + context=context, + ) + + body: dict[str, JsonType] = ( + decoded["payload"] if isinstance(decoded.get("payload"), dict) else decoded + ) + + tenant_id = _require_str(body, "tenant_id", header_correlation_id) + principal_id = _require_str(body, "principal_id", header_correlation_id) + local_transport_flavor = _require_str( + body, "local_transport_flavor", header_correlation_id + ) + last_seen_at = self._extract_timestamp(body) + reported_status = _require_str(body, "status", header_correlation_id) + consecutive_failures = _require_non_negative_int( + body, "consecutive_failures", header_correlation_id + ) + lag_messages_raw = body.get("lag_messages") + lag_messages = ( + lag_messages_raw + if isinstance(lag_messages_raw, int) + and not isinstance(lag_messages_raw, bool) + else None + ) + lag_seconds_raw = body.get("lag_seconds") + lag_seconds = ( + float(lag_seconds_raw) + if isinstance(lag_seconds_raw, (int, float)) + and not isinstance(lag_seconds_raw, bool) + else None + ) + + return ModelPayloadGatewayLinkHealthUpsert( + tenant_id=tenant_id, + principal_id=principal_id, + local_transport_flavor=local_transport_flavor, + last_seen_at=last_seen_at, + reported_status=reported_status, + consecutive_failures=consecutive_failures, + lag_messages=lag_messages, + lag_seconds=lag_seconds, + ) + + @staticmethod + def _coerce_event_message(raw: object) -> ModelEventMessage: + """Accept direct ModelEventMessage or an auto-wired envelope wrapper.""" + if isinstance(raw, ModelEventMessage): + return raw + payload = getattr(raw, "payload", raw) + if isinstance(raw, dict): + payload = raw.get("payload", raw) + return ModelEventMessage.model_validate(payload) + + @staticmethod + def _extract_timestamp(body: dict[str, JsonType]) -> datetime: + for k in ("emitted_at", "as_of", "timestamp", "occurred_at"): + raw = body.get(k) + if isinstance(raw, str) and raw: + try: + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + continue + return ( + parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC) + ) + return datetime.now(UTC) + + +__all__ = [ + "HandlerGatewayLinkHealthProjection", + "HANDLER_ID_GATEWAY_LINK_HEALTH_PROJECTION", +] diff --git a/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/models/__init__.py b/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/models/__init__.py new file mode 100644 index 0000000000..e7f93e89e2 --- /dev/null +++ b/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/models/__init__.py @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 OmniNode Team +"""Models for node_gateway_link_health_projection_compute.""" + +from omnibase_infra.nodes.node_gateway_link_health_projection_compute.models.model_payload_gateway_link_health_upsert import ( + ModelPayloadGatewayLinkHealthUpsert, +) + +__all__ = ["ModelPayloadGatewayLinkHealthUpsert"] diff --git a/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/models/model_payload_gateway_link_health_upsert.py b/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/models/model_payload_gateway_link_health_upsert.py new file mode 100644 index 0000000000..626e828805 --- /dev/null +++ b/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/models/model_payload_gateway_link_health_upsert.py @@ -0,0 +1,128 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 OmniNode Team +"""ModelPayloadGatewayLinkHealthUpsert - intent payload for link-health persistence. + +The COMPUTE node emits a ModelIntent carrying this payload after parsing a +``onex.evt.omnibase-infra.gateway-heartbeat.v1`` event (``ModelGatewayHeartbeat``, +published by ``node_bus_forwarder_effect``'s ``ServiceGatewayForwarder.publish_heartbeat``). +The EFFECT node consumes the intent and upserts one row into +``gateway_link_health``, keyed by ``tenant_id`` -- the row is never deleted, so +absence of a *fresh* heartbeat shows up as a stale ``last_seen_at`` on an +existing row (evaluated live by the ``gateway_link_health_status`` view), not +as a missing row. + +SCOPE DISCLOSURE (OMN-15570 / G3, gateway lift Phase 0): this payload carries +what ``ModelGatewayHeartbeat`` publishes today -- ``tenant_id``, +``principal_id``, ``emitted_at``, ``local_transport_flavor``, and the +``status`` / ``consecutive_failures`` pair that G2 (OMN-15742) added. + +The one declared threshold family still unobservable is lag. The contract's +``gateway_forwarder.liveness`` block (``node_bus_forwarder_effect/contract.yaml`` +lines 59-63) declares ``lag_threshold_messages`` / ``lag_threshold_seconds``, +but no producer in this codebase publishes lag data on the heartbeat topic -- +G1/G2 (OMN-15741/OMN-15742) were the lanes that would have, and both landed +without it. ``lag_messages`` / ``lag_seconds`` are therefore always ``None`` +here; the write-effect persists them as nullable columns and the status +view's lag arms stay inert (a ``NULL`` comparison is neither TRUE nor FALSE +in SQL, so they never contribute a verdict). + +This is a materially smaller gap than lag-plus-status was. Silence is +observable through ``last_seen_at``, and an unwell-but-talking edge is now +observable through ``status`` -- the case that previously scored HEALTHY +because nothing carried the edge's own opinion of itself. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class ModelPayloadGatewayLinkHealthUpsert(BaseModel): + """Payload for a single gateway_link_health upsert. + + Attributes: + intent_type: Discriminator literal, always + "gateway_link_health.upsert". + tenant_id: Tenant slug identifying the edge (heartbeat's + ``tenant_id``, config-bound DNS-safe slug -- the current link + identity granularity; one row per tenant edge). + principal_id: Canonical MSK principal (``t-``) that + emitted the heartbeat. + local_transport_flavor: Local leg transport flavor + ("containerized" | "lightweight") from the heartbeat. + last_seen_at: Producer-supplied ``emitted_at`` timestamp of the + heartbeat -- the freshness stamp the status view diffs against + ``NOW()``. + reported_status: The edge's own verdict on itself, verbatim from + ``ModelGatewayHeartbeat.status`` (OMN-15742/G2). Carried as a + plain ``str`` rather than a ``Literal`` so that a status the + producer adds later does not fail validation here and stall the + projection; the status view treats anything other than + ``"active"`` as degraded, so an unrecognised value is read as + not-healthy rather than silently scored healthy. + consecutive_failures: ``ModelGatewayHeartbeat.consecutive_failures`` + -- the evidence behind a degraded ``reported_status``. Persisted + as explanation, not as a verdict input. + lag_messages: Consumer lag in messages, when a producer supplies it. + Always ``None`` today -- see module docstring. + lag_seconds: Consumer lag in seconds, when a producer supplies it. + Always ``None`` today -- see module docstring. + """ + + model_config = ConfigDict(frozen=True, extra="forbid", from_attributes=True) + + intent_type: Literal["gateway_link_health.upsert"] = Field( + default="gateway_link_health.upsert", + description="Discriminator literal for intent routing.", + ) + + tenant_id: str = Field( + ..., + min_length=1, + description="Tenant slug identifying the gateway edge (link identity).", + ) + principal_id: str = Field( + ..., + min_length=1, + description="Canonical MSK principal that emitted the heartbeat.", + ) + local_transport_flavor: str = Field( + ..., + min_length=1, + description="Local leg transport flavor from the heartbeat.", + ) + last_seen_at: datetime = Field( + ..., + description="Producer-supplied heartbeat emission time.", + ) + reported_status: str = Field( + ..., + min_length=1, + description=( + "The edge's own status verbatim from the heartbeat. Anything " + "other than 'active' is treated as degraded by the status view." + ), + ) + consecutive_failures: int = Field( + ..., + ge=0, + description=( + "Consecutive failure count from the heartbeat; evidence behind a " + "degraded reported_status, not a verdict input of its own." + ), + ) + lag_messages: int | None = Field( + default=None, + description="Consumer lag in messages, when supplied. None today.", + ) + lag_seconds: float | None = Field( + default=None, + description="Consumer lag in seconds, when supplied. None today.", + ) + + +__all__ = ["ModelPayloadGatewayLinkHealthUpsert"] diff --git a/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/node.py b/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/node.py new file mode 100644 index 0000000000..767b05d8ea --- /dev/null +++ b/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/node.py @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 OmniNode Team +"""NodeGatewayLinkHealthProjectionCompute - declarative COMPUTE node. + +Subscribes to the gateway heartbeat event topic and delegates all compute +logic to HandlerGatewayLinkHealthProjection per the ONEX declarative pattern. + +Subscribed Topic (via contract.yaml): + - onex.evt.omnibase-infra.gateway-heartbeat.v1 + +Ticket: OMN-15570 (G3, gateway lift Phase 0) +""" + +from __future__ import annotations + +from omnibase_core.container import ModelONEXContainer +from omnibase_core.nodes.node_compute import NodeCompute + + +class NodeGatewayLinkHealthProjectionCompute(NodeCompute): + """Declarative COMPUTE node for gateway link-health projection. + + All behavior is defined in contract.yaml and delegated to + HandlerGatewayLinkHealthProjection. This node contains no custom logic + beyond the explicit DI constructor required by the nodes/*/node.py + guideline. + """ + + def __init__(self, container: ModelONEXContainer) -> None: + super().__init__(container) + + +__all__ = ["NodeGatewayLinkHealthProjectionCompute"] diff --git a/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/registry/__init__.py b/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/registry/__init__.py new file mode 100644 index 0000000000..ce1c7ca64a --- /dev/null +++ b/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/registry/__init__.py @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 OmniNode Team +"""Registry package for node_gateway_link_health_projection_compute.""" + +from omnibase_infra.nodes.node_gateway_link_health_projection_compute.registry.registry_infra_gateway_link_health_projection import ( + RegistryInfraGatewayLinkHealthProjection, +) + +__all__ = ["RegistryInfraGatewayLinkHealthProjection"] diff --git a/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/registry/registry_infra_gateway_link_health_projection.py b/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/registry/registry_infra_gateway_link_health_projection.py new file mode 100644 index 0000000000..bdf7f490b4 --- /dev/null +++ b/src/omnibase_infra/nodes/node_gateway_link_health_projection_compute/registry/registry_infra_gateway_link_health_projection.py @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 OmniNode Team +"""Registry for NodeGatewayLinkHealthProjectionCompute - DI bindings and exports.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from omnibase_infra.nodes.node_gateway_link_health_projection_compute.node import ( + NodeGatewayLinkHealthProjectionCompute, +) + +if TYPE_CHECKING: + from omnibase_core.container import ModelONEXContainer + + +class RegistryInfraGatewayLinkHealthProjection: + """DI registry for the gateway link-health projection compute node.""" + + @staticmethod + def get_node_class() -> type[NodeGatewayLinkHealthProjectionCompute]: + return NodeGatewayLinkHealthProjectionCompute + + @staticmethod + def create_node( + container: ModelONEXContainer, + ) -> NodeGatewayLinkHealthProjectionCompute: + return NodeGatewayLinkHealthProjectionCompute(container) + + +__all__ = [ + "NodeGatewayLinkHealthProjectionCompute", + "RegistryInfraGatewayLinkHealthProjection", +] diff --git a/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/__init__.py b/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/__init__.py new file mode 100644 index 0000000000..4bd21a9a8e --- /dev/null +++ b/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/__init__.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 OmniNode Team +"""node_gateway_link_health_write_effect - gateway_link_health persistence node. + +Declarative EFFECT node that consumes ModelPayloadGatewayLinkHealthUpsert +intents emitted by NodeGatewayLinkHealthProjectionCompute and UPSERTs them +into the omninode_internal.gateway_link_health latest-known-state table. + +Ticket: OMN-15570 (G3, gateway lift Phase 0) +""" + +from omnibase_infra.nodes.node_gateway_link_health_write_effect.handlers import ( + HandlerGatewayLinkHealthUpsert, +) +from omnibase_infra.nodes.node_gateway_link_health_write_effect.models import ( + ModelGatewayLinkHealthUpsertResult, +) +from omnibase_infra.nodes.node_gateway_link_health_write_effect.node import ( + NodeGatewayLinkHealthWriteEffect, +) +from omnibase_infra.nodes.node_gateway_link_health_write_effect.registry import ( + RegistryInfraGatewayLinkHealthWrite, +) + +__all__ = [ + "HandlerGatewayLinkHealthUpsert", + "ModelGatewayLinkHealthUpsertResult", + "NodeGatewayLinkHealthWriteEffect", + "RegistryInfraGatewayLinkHealthWrite", +] diff --git a/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/contract.yaml b/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/contract.yaml new file mode 100644 index 0000000000..13f204889f --- /dev/null +++ b/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/contract.yaml @@ -0,0 +1,121 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 OmniNode Team +# +# ONEX Node Contract +# Node: NodeGatewayLinkHealthWriteEffect +# +# EFFECT node that consumes ModelPayloadGatewayLinkHealthUpsert intents +# emitted by NodeGatewayLinkHealthProjectionCompute and UPSERTs them into +# the gateway_link_health latest-known-state table via HandlerDb. +# +# Related Tickets: +# - OMN-15570: Gateway lift Phase 0, item G3 (link-health projection). +name: "node_gateway_link_health_write_effect" +node_type: "EFFECT_GENERIC" +runtime_profiles: + - effects +contract_version: + major: 1 + minor: 0 + patch: 0 +node_version: "0.1.0" +description: > + Capability-oriented EFFECT node for gateway_link_health persistence. Consumes ModelPayloadGatewayLinkHealthUpsert intents and UPSERTs one row per tenant_id into the omninode_internal.gateway_link_health latest-known-state table via ON CONFLICT DO UPDATE, so the table always reflects the most recently observed heartbeat per tenant edge and never drops a row on silence -- the gateway_link_health_status view (docker/migrations/forward/100_create_gateway_link_health.sql) evaluates staleness against the contract-declared liveness thresholds at read time. + +# Strongly typed I/O models +input_model: + name: "ModelPayloadGatewayLinkHealthUpsert" + module: "omnibase_infra.nodes.node_gateway_link_health_projection_compute.models" + description: "Input payload describing one tenant edge's latest known heartbeat to persist." +output_model: + name: "ModelGatewayLinkHealthUpsertResult" + module: "omnibase_infra.nodes.node_gateway_link_health_write_effect.models" + description: "Outcome of one gateway_link_health upsert." +# Event bus configuration +event_bus: + subscribe_topics: + - "onex.cmd.omnibase-infra.gateway-link-health-upsert.v1" +# Handler routing +handler_routing: + routing_strategy: "operation_match" + handlers: + - operation: "gateway_link_health.upsert" + handler: + name: "HandlerGatewayLinkHealthUpsert" + module: "omnibase_infra.nodes.node_gateway_link_health_write_effect.handlers.handler_gateway_link_health_upsert" + topic: "onex.cmd.omnibase-infra.gateway-link-health-upsert.v1" + description: "Upsert latest known tenant-edge heartbeat into gateway_link_health" +capabilities: + - name: "gateway_link_health.write" + description: "Upsert a tenant edge's latest known heartbeat into gateway_link_health" + version: "0.1.0" + - name: "gateway_link_health.write.upsert" + description: "Upsert a single row into gateway_link_health, keyed by tenant_id" +node_capabilities: + postgres: true + write: true + database: true +io_operations: + - operation: "gateway_link_health.upsert" + description: "Upsert a single tenant edge's latest known heartbeat" + input_fields: + - tenant_id: "str" + - principal_id: "str" + - local_transport_flavor: "str" + - last_seen_at: "datetime" + - lag_messages: "int | None" + - lag_seconds: "float | None" + output_fields: + - success: "bool" + - tenant_id: "str" + - was_insert: "bool" + idempotent: true +dependencies: + - name: "handler_gateway_link_health_upsert" + type: "handler" + class_name: "HandlerGatewayLinkHealthUpsert" + module: "omnibase_infra.nodes.node_gateway_link_health_write_effect.handlers.handler_gateway_link_health_upsert" + description: "Upsert handler that lazily composes HandlerDb from the runtime container and writes to gateway_link_health" + - name: "handler_db" + type: "handler" + class_name: "HandlerDb" + module: "omnibase_infra.handlers.handler_db" + description: "PostgreSQL database handler (composed by the gateway_link_health upsert handler)" +error_handling: + retry_policy: + max_retries: 3 + initial_delay_ms: 100 + max_delay_ms: 5000 + exponential_base: 2 + retry_on: + - "InfraConnectionError" + - "InfraTimeoutError" + circuit_breaker: + enabled: true + failure_threshold: 5 + reset_timeout_ms: 60000 +health_check: + enabled: true + endpoint: "/health" + interval_seconds: 30 + timeout_ms: 5000 + checks: + - name: "database_backend" + type: "connection" + critical: true +metadata: + description: > + Capability-oriented EFFECT node that persists gateway heartbeat projections into the latest-known-state omninode_internal.gateway_link_health table. HandlerGatewayLinkHealthUpsert composes its database dependency internally from the runtime container, matching the OMN-14140 auto-wiring pattern. + + author: "OmniNode Team" + license: "MIT" + created: "2026-08-08" + updated: "2026-08-08" + ticket: "OMN-15570" + tags: + - effect + - gateway + - link_health + - storage + - postgresql diff --git a/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/handlers/__init__.py b/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/handlers/__init__.py new file mode 100644 index 0000000000..3323237c13 --- /dev/null +++ b/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/handlers/__init__.py @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 OmniNode Team +"""Handlers for node_gateway_link_health_write_effect.""" + +from omnibase_infra.nodes.node_gateway_link_health_write_effect.handlers.handler_gateway_link_health_upsert import ( + HandlerGatewayLinkHealthUpsert, +) + +__all__ = ["HandlerGatewayLinkHealthUpsert"] diff --git a/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/handlers/handler_gateway_link_health_upsert.py b/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/handlers/handler_gateway_link_health_upsert.py new file mode 100644 index 0000000000..63bb1368c6 --- /dev/null +++ b/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/handlers/handler_gateway_link_health_upsert.py @@ -0,0 +1,336 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 OmniNode Team +"""HandlerGatewayLinkHealthUpsert - UPSERT one link-health intent into gateway_link_health. + +gateway_link_health is a latest-known-state projection: ON CONFLICT +(tenant_id) DO UPDATE keeps exactly one row per tenant edge, refreshed on +every heartbeat. The row is never deleted, so "absence of progress" (a +tenant edge that stopped heartbeating) shows up as a stale `last_seen_at` on +an existing row, evaluated live by the `gateway_link_health_status` view -- +never as a missing row. RETURNING (xmax = 0) detects insert-vs-update, +matching HandlerPrStateUpsert's idiom. + +Composes with HandlerDb (PostgreSQL transport) for circuit-breaker +protection, error classification, and connection-pool management, mirroring +HandlerPrStateUpsert / HandlerLedgerAppend (OMN-14140's internally-composed +HandlerDb pattern). +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + +from omnibase_core.models.dispatch import ModelHandlerOutput +from omnibase_infra.enums import ( + EnumHandlerType, + EnumHandlerTypeCategory, + EnumInfraTransportType, +) +from omnibase_infra.errors import ModelInfraErrorContext, RuntimeHostError +from omnibase_infra.handlers.handler_db import HandlerDb +from omnibase_infra.nodes.node_gateway_link_health_projection_compute.models import ( + ModelPayloadGatewayLinkHealthUpsert, +) +from omnibase_infra.nodes.node_gateway_link_health_write_effect.models import ( + ModelGatewayLinkHealthUpsertResult, +) + +if TYPE_CHECKING: + from omnibase_core.container import ModelONEXContainer + +logger = logging.getLogger(__name__) + +HANDLER_ID_GATEWAY_LINK_HEALTH_UPSERT: str = "gateway-link-health-upsert-handler" + +# UPSERT keyed on tenant_id. RETURNING (xmax = 0) distinguishes an INSERT +# (was_insert=True) from an UPDATE via the ON CONFLICT branch -- same idiom +# as HandlerPrStateUpsert's _SQL_UPSERT. +_SQL_UPSERT = """ +INSERT INTO omninode_internal.gateway_link_health ( + tenant_id, + principal_id, + local_transport_flavor, + last_seen_at, + reported_status, + consecutive_failures, + lag_messages, + lag_seconds, + updated_at +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, NOW() +) +ON CONFLICT (tenant_id) DO UPDATE SET + principal_id = EXCLUDED.principal_id, + local_transport_flavor = EXCLUDED.local_transport_flavor, + last_seen_at = EXCLUDED.last_seen_at, + reported_status = EXCLUDED.reported_status, + consecutive_failures = EXCLUDED.consecutive_failures, + lag_messages = EXCLUDED.lag_messages, + lag_seconds = EXCLUDED.lag_seconds, + updated_at = NOW() +RETURNING (xmax = 0) AS was_insert +""" + + +class HandlerGatewayLinkHealthUpsert: + """EFFECT handler that UPSERTs one row into gateway_link_health.""" + + def __init__( + self, + container: ModelONEXContainer | None = None, + db_dsn: str | None = None, + ) -> None: + """Initialize the gateway_link_health upsert handler. + + Args: + container: ONEX dependency injection container. HandlerDb is + composed internally from this container (matching + OMN-14140's pattern). Defaults to ``None`` so the generic + auto-wiring resolver's zero-required-param fast path can + construct this handler directly. + db_dsn: Optional PostgreSQL DSN supplied by the runtime + auto-wiring boundary. Handlers do not read environment + directly; runtime composition owns that IO boundary. + """ + self._container = container + self._db_handler = HandlerDb(container) if container is not None else None + self._db_dsn = db_dsn.strip() if db_dsn else "" + self._initialized: bool = False + self._db_init_lock = asyncio.Lock() + + @property + def handler_type(self) -> EnumHandlerType: + return EnumHandlerType.INFRA_HANDLER + + @property + def handler_category(self) -> EnumHandlerTypeCategory: + return EnumHandlerTypeCategory.EFFECT + + async def initialize(self, config: dict[str, object]) -> None: + config_dsn = config.get("dsn") + if isinstance(config_dsn, str) and config_dsn.strip(): + self._db_dsn = config_dsn.strip() + await self._ensure_db_ready() + logger.info( + "%s initialized successfully", + self.__class__.__name__, + extra={"handler": self.__class__.__name__}, + ) + + async def shutdown(self) -> None: + if self._initialized and self._db_handler is not None: + await self._db_handler.shutdown() + self._initialized = False + logger.info("HandlerGatewayLinkHealthUpsert shutdown complete") + + async def _ensure_db_ready(self) -> None: + """Lazily connect the composed HandlerDb on first real use. + + The auto-wiring resolver constructs contract-routed handlers from + `container` alone and never calls their `initialize()` method + (OMN-14140), so this handler owns its HandlerDb connection lifecycle + instead of relying on an external initialize() call. + """ + if self._initialized: + return + async with self._db_init_lock: + if self._initialized: + return + dsn = self._db_dsn + if self._container is None or self._db_handler is None: + ctx = ModelInfraErrorContext.with_correlation( + transport_type=EnumInfraTransportType.DATABASE, + operation="gateway_link_health.upsert.connect", + ) + raise RuntimeHostError( + "Missing ONEX container for gateway_link_health persistence " + "-- provide container at construction", + context=ctx, + ) + if not dsn: + ctx = ModelInfraErrorContext.with_correlation( + transport_type=EnumInfraTransportType.DATABASE, + operation="gateway_link_health.upsert.connect", + ) + raise RuntimeHostError( + "Missing PostgreSQL DSN for gateway_link_health persistence " + "-- provide db_dsn at construction or initialize({'dsn': ...})", + context=ctx, + ) + await self._db_handler.initialize({"dsn": dsn}) + self._initialized = True + + async def upsert( + self, + payload: ModelPayloadGatewayLinkHealthUpsert, + *, + correlation_id: UUID | None = None, + ) -> ModelGatewayLinkHealthUpsertResult: + """UPSERT one row into gateway_link_health.""" + correlation_id = correlation_id or uuid4() + + await self._ensure_db_ready() + + parameters: list[object] = [ + payload.tenant_id, # $1 + payload.principal_id, # $2 + payload.local_transport_flavor, # $3 + payload.last_seen_at, # $4 + payload.reported_status, # $5 + payload.consecutive_failures, # $6 + payload.lag_messages, # $7 + payload.lag_seconds, # $8 + ] + + envelope: dict[str, object] = { + "operation": "db.query", + "payload": { + "sql": _SQL_UPSERT, + "parameters": parameters, + }, + "correlation_id": str(correlation_id), + } + + logger.debug( + "Upserting gateway_link_health row", + extra={ + "tenant_id": payload.tenant_id, + "correlation_id": str(correlation_id), + }, + ) + + if self._db_handler is None: + ctx = ModelInfraErrorContext.with_correlation( + correlation_id=correlation_id, + transport_type=EnumInfraTransportType.DATABASE, + operation="gateway_link_health.upsert", + ) + raise RuntimeHostError("Database handler is not available", context=ctx) + + db_result = await self._db_handler.execute(envelope) + + if db_result.result is None: + ctx = ModelInfraErrorContext.with_correlation( + correlation_id=correlation_id, + transport_type=EnumInfraTransportType.DATABASE, + operation="gateway_link_health.upsert", + ) + raise RuntimeHostError("Database operation returned no result", context=ctx) + + rows = db_result.result.payload.rows + if not rows: + ctx = ModelInfraErrorContext.with_correlation( + correlation_id=correlation_id, + transport_type=EnumInfraTransportType.DATABASE, + operation="gateway_link_health.upsert", + ) + raise RuntimeHostError( + "UPSERT into gateway_link_health returned no row", context=ctx + ) + + was_insert = bool(rows[0]["was_insert"]) + return ModelGatewayLinkHealthUpsertResult( + success=True, + tenant_id=payload.tenant_id, + was_insert=was_insert, + ) + + async def handle( + self, + payload: object, + ) -> ModelHandlerOutput[ModelGatewayLinkHealthUpsertResult]: + """Contract-routed operation_match entry point. + + Named ``payload`` (an ONEX canonical-shape magic parameter name, per + ``canonical_handler_shape.py``'s ``MAGIC_PARAM_NAMES``) rather than + typed directly as ``ModelPayloadGatewayLinkHealthUpsert``: this + handler is invoked two ways -- the contract-declared + ``operation_match`` dispatch path (a raw dict/object envelope) and + ``IntentEffectDispatchBridge`` (OMN-14516), which always calls + ``handle({"payload": ..., "correlation_id": ...})`` regardless of + this parameter's static type, so an ``object`` annotation reflects + the real invocation contract honestly rather than asserting a type + the runtime does not actually guarantee. + """ + payload_raw = self._extract_envelope_field(payload, "payload") + if payload_raw is None: + payload_raw = payload + typed_payload = ( + payload_raw + if isinstance(payload_raw, ModelPayloadGatewayLinkHealthUpsert) + else ModelPayloadGatewayLinkHealthUpsert.model_validate(payload_raw) + ) + + envelope_correlation_id = self._extract_envelope_field( + payload, "correlation_id" + ) + correlation_id = self._safe_correlation_id(envelope_correlation_id) + result = await self.upsert(typed_payload, correlation_id=correlation_id) + + return ModelHandlerOutput.for_compute( + input_envelope_id=uuid4(), + correlation_id=correlation_id, + handler_id=HANDLER_ID_GATEWAY_LINK_HEALTH_UPSERT, + result=result, + ) + + async def execute( + self, + envelope: dict[str, object], + ) -> ModelHandlerOutput[ModelGatewayLinkHealthUpsertResult]: + """ProtocolHandler entry point.""" + correlation_id = self._safe_correlation_id(envelope.get("correlation_id")) + input_envelope_id = uuid4() + + payload_raw = envelope.get("payload") + if not isinstance(payload_raw, dict): + ctx = ModelInfraErrorContext.with_correlation( + correlation_id=correlation_id, + transport_type=EnumInfraTransportType.DATABASE, + operation="gateway_link_health.upsert", + ) + raise RuntimeHostError( + "Missing or invalid 'payload' in envelope", + context=ctx, + ) + + payload = ModelPayloadGatewayLinkHealthUpsert.model_validate(payload_raw) + result = await self.upsert(payload, correlation_id=correlation_id) + + return ModelHandlerOutput.for_compute( + input_envelope_id=input_envelope_id, + correlation_id=correlation_id, + handler_id=HANDLER_ID_GATEWAY_LINK_HEALTH_UPSERT, + result=result, + ) + + @staticmethod + def _extract_envelope_field(envelope: object, key: str) -> object: + if isinstance(envelope, dict): + return envelope.get(key) + return getattr(envelope, key, None) + + @staticmethod + def _safe_correlation_id(raw: object) -> UUID: + """Parse a correlation ID from envelope-supplied raw input. + + Returns a fresh UUID if `raw` is missing, empty, or unparseable -- + gateway_link_health projection is best-effort read-model refresh and + must never drop a refresh over a malformed correlation_id. + """ + if not raw: + return uuid4() + try: + return UUID(str(raw)) + except (ValueError, TypeError): + return uuid4() + + +__all__ = [ + "HandlerGatewayLinkHealthUpsert", + "HANDLER_ID_GATEWAY_LINK_HEALTH_UPSERT", +] diff --git a/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/models/__init__.py b/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/models/__init__.py new file mode 100644 index 0000000000..fa7dc14df9 --- /dev/null +++ b/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/models/__init__.py @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 OmniNode Team +"""Models for node_gateway_link_health_write_effect.""" + +from omnibase_infra.nodes.node_gateway_link_health_write_effect.models.model_gateway_link_health_upsert_result import ( + ModelGatewayLinkHealthUpsertResult, +) + +__all__ = ["ModelGatewayLinkHealthUpsertResult"] diff --git a/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/models/model_gateway_link_health_upsert_result.py b/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/models/model_gateway_link_health_upsert_result.py new file mode 100644 index 0000000000..c9650ad6a2 --- /dev/null +++ b/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/models/model_gateway_link_health_upsert_result.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 OmniNode Team +"""ModelGatewayLinkHealthUpsertResult - outcome of a single gateway_link_health upsert. + +gateway_link_health is a latest-known-state projection (ON CONFLICT +(tenant_id) DO UPDATE), so `was_insert` distinguishes a first-seen tenant +edge from a refresh of an existing row -- mirrors ModelPrStateUpsertResult. +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class ModelGatewayLinkHealthUpsertResult(BaseModel): + """Outcome of one gateway_link_health upsert.""" + + model_config = ConfigDict(frozen=True, extra="forbid", from_attributes=True) + + success: bool = Field( + ..., + description="Whether the upsert completed without error.", + ) + tenant_id: str = Field( + ..., + min_length=1, + description="Tenant slug persisted to the row.", + ) + was_insert: bool = Field( + ..., + description="True if this tenant edge had no prior row (first-seen); " + "False if an existing row was refreshed.", + ) + + +__all__ = ["ModelGatewayLinkHealthUpsertResult"] diff --git a/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/node.py b/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/node.py new file mode 100644 index 0000000000..f2387520bc --- /dev/null +++ b/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/node.py @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 OmniNode Team +"""NodeGatewayLinkHealthWriteEffect - declarative EFFECT node. + +Persists ModelPayloadGatewayLinkHealthUpsert intents into the +omninode_internal.gateway_link_health latest-known-state table. All persistence logic +lives in HandlerGatewayLinkHealthUpsert per the ONEX declarative pattern. + +Ticket: OMN-15570 (G3, gateway lift Phase 0) +""" + +from __future__ import annotations + +from omnibase_core.container import ModelONEXContainer +from omnibase_core.nodes.node_effect import NodeEffect + + +class NodeGatewayLinkHealthWriteEffect(NodeEffect): + """Declarative EFFECT node for gateway_link_health persistence. + + All behavior is defined in contract.yaml and delegated to + HandlerGatewayLinkHealthUpsert. This node contains no custom logic + beyond the explicit DI constructor required by the nodes/*/node.py + guideline. + """ + + def __init__(self, container: ModelONEXContainer) -> None: + super().__init__(container) + + +__all__ = ["NodeGatewayLinkHealthWriteEffect"] diff --git a/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/registry/__init__.py b/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/registry/__init__.py new file mode 100644 index 0000000000..6b603715d7 --- /dev/null +++ b/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/registry/__init__.py @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 OmniNode Team +"""Registry package for node_gateway_link_health_write_effect.""" + +from omnibase_infra.nodes.node_gateway_link_health_write_effect.registry.registry_infra_gateway_link_health_write import ( + RegistryInfraGatewayLinkHealthWrite, +) + +__all__ = ["RegistryInfraGatewayLinkHealthWrite"] diff --git a/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/registry/registry_infra_gateway_link_health_write.py b/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/registry/registry_infra_gateway_link_health_write.py new file mode 100644 index 0000000000..10e933d3a4 --- /dev/null +++ b/src/omnibase_infra/nodes/node_gateway_link_health_write_effect/registry/registry_infra_gateway_link_health_write.py @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 OmniNode Team +"""Registry for NodeGatewayLinkHealthWriteEffect - DI bindings and exports.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from omnibase_infra.nodes.node_gateway_link_health_write_effect.node import ( + NodeGatewayLinkHealthWriteEffect, +) + +if TYPE_CHECKING: + from omnibase_core.container import ModelONEXContainer + + +class RegistryInfraGatewayLinkHealthWrite: + """DI registry for the gateway link-health write effect node.""" + + @staticmethod + def get_node_class() -> type[NodeGatewayLinkHealthWriteEffect]: + return NodeGatewayLinkHealthWriteEffect + + @staticmethod + def create_node(container: ModelONEXContainer) -> NodeGatewayLinkHealthWriteEffect: + return NodeGatewayLinkHealthWriteEffect(container) + + +__all__ = [ + "NodeGatewayLinkHealthWriteEffect", + "RegistryInfraGatewayLinkHealthWrite", +] diff --git a/src/omnibase_infra/validation/application_database_domain_enforcement.py b/src/omnibase_infra/validation/application_database_domain_enforcement.py index e8f02a3615..a7630bbb06 100644 --- a/src/omnibase_infra/validation/application_database_domain_enforcement.py +++ b/src/omnibase_infra/validation/application_database_domain_enforcement.py @@ -326,7 +326,8 @@ def _compile_relation_target(prefix: str) -> re.Pattern[str]: re.compile( r"\bfrom\s+(?P[\s\S]*?)" r"(?=\bwhere\b|\bjoin\b|\bgroup\s+by\b|\border\s+by\b|" - r"\blimit\b|\bunion\b|\breturning\b|\bfor\s+(?:update|share)\b|;|$)", + r"\blimit\b|\bunion\b|\breturning\b|\bcase\b|" + r"\bfor\s+(?:update|share)\b|;|$)", re.IGNORECASE, ), True, diff --git a/src/omnibase_infra/validation/validation_exemptions.yaml b/src/omnibase_infra/validation/validation_exemptions.yaml index f2350ab7da..03ca61cf44 100644 --- a/src/omnibase_infra/validation/validation_exemptions.yaml +++ b/src/omnibase_infra/validation/validation_exemptions.yaml @@ -3906,6 +3906,24 @@ pattern_exemptions: Three public operations (load/save/clear) over two on-disk files, plus the private readers/writers those files require and two path properties. Every method is bound to the same ~/.onex config.yaml + credentials.json pair; splitting the class would put the by-reference invariant (secret value in the 0600 file, never in config.yaml) across two objects that must then agree, which is the coupling the single owner exists to prevent. ticket: OMN-15922 + - file_pattern: 'model_payload_gateway_link_health_upsert\.py' + violation_pattern: "Field 'tenant_id' should use UUID" + reason: > + Same seam as model_gateway_heartbeat.py: this payload carries the ModelGatewayHeartbeat.tenant_id field verbatim (the validated DNS-safe tenant slug used in Kafka topic names), not the underlying UUID. + + ticket: OMN-15570 + - file_pattern: 'model_payload_gateway_link_health_upsert\.py' + violation_pattern: "Field 'principal_id' should use UUID" + reason: > + Same seam as model_gateway_heartbeat.py: this payload carries the ModelGatewayHeartbeat.principal_id field verbatim -- the canonical MSK tenant principal t-, whose t- prefix makes UUID an invalid representation. + + ticket: OMN-15570 + - file_pattern: 'model_gateway_link_health_upsert_result\.py' + violation_pattern: "Field 'tenant_id' should use UUID" + reason: > + Same seam as model_gateway_heartbeat.py: this result echoes back the tenant_id key persisted into gateway_link_health, keyed on the DNS-safe tenant slug, not the underlying UUID. + + ticket: OMN-15570 # These handle one-model-per-file violations for domain-grouped protocols architecture_exemptions: # ========================================================================== diff --git a/tests/fixtures/dispatch_parity/baseline-selection-v2.json b/tests/fixtures/dispatch_parity/baseline-selection-v2.json index 9c257ced72..442d6998df 100644 --- a/tests/fixtures/dispatch_parity/baseline-selection-v2.json +++ b/tests/fixtures/dispatch_parity/baseline-selection-v2.json @@ -461,6 +461,36 @@ "package": "omnibase_infra", "type_scoped": false }, + "dispatcher.auto.node_gateway_link_health_projection_compute.HandlerGatewayLinkHealthProjection.gateway_link_health_project_b6e85659": { + "category": "event", + "contract_name": "node_gateway_link_health_projection_compute", + "event_model_module": null, + "event_model_name": null, + "handler_id": "dispatcher.auto.node_gateway_link_health_projection_compute.HandlerGatewayLinkHealthProjection.gateway_link_health_project_b6e85659", + "handler_module": "omnibase_infra.nodes.node_gateway_link_health_projection_compute.handlers.handler_gateway_link_health_projection", + "handler_name": "HandlerGatewayLinkHealthProjection", + "message_types": [ + "omnibase-infra.gateway-heartbeat", + "onex.evt.omnibase-infra.gateway-heartbeat.v1" + ], + "package": "omnibase_infra", + "type_scoped": false + }, + "dispatcher.auto.node_gateway_link_health_write_effect.HandlerGatewayLinkHealthUpsert.gateway_link_health_upsert_e2d256a3": { + "category": "command", + "contract_name": "node_gateway_link_health_write_effect", + "event_model_module": null, + "event_model_name": null, + "handler_id": "dispatcher.auto.node_gateway_link_health_write_effect.HandlerGatewayLinkHealthUpsert.gateway_link_health_upsert_e2d256a3", + "handler_module": "omnibase_infra.nodes.node_gateway_link_health_write_effect.handlers.handler_gateway_link_health_upsert", + "handler_name": "HandlerGatewayLinkHealthUpsert", + "message_types": [ + "omnibase-infra.gateway-link-health-upsert", + "onex.cmd.omnibase-infra.gateway-link-health-upsert.v1" + ], + "package": "omnibase_infra", + "type_scoped": false + }, "dispatcher.auto.node_github_pr_poller_effect.HandlerGitHubApiPoll.github_poll_prs_7786448a": { "category": "intent", "contract_name": "node_github_pr_poller_effect", @@ -3230,15 +3260,15 @@ "corpus": { "contracts_by_package": { "omnibase_core": 15, - "omnibase_infra": 114, + "omnibase_infra": 116, "onex-change-control": 4 }, - "contracts_discovered": 133, + "contracts_discovered": 135, "discovery_errors": 0, - "distinct_subscribe_topics": 108, - "probe_count": 1046, - "registered_dispatchers": 114, - "registered_routes": 124, + "distinct_subscribe_topics": 110, + "probe_count": 1050, + "registered_dispatchers": 116, + "registered_routes": 126, "type_scoped_dispatchers": 69 }, "duplicate_winners": [], @@ -3279,7 +3309,7 @@ "omnimemory" ], "fixture_version": "v2", - "generated_at_utc": "2026-08-19T17:23:46.955055+00:00", + "generated_at_utc": "2026-08-20T13:40:01.215388+00:00", "installed_package_versions": { "omnibase-core": "0.46.8", "omnibase-infra": "0.38.7", @@ -3514,6 +3544,23 @@ }, "topic": "onex.cmd.omnibase-infra.gateway-heartbeat-request.v1" }, + "P1a::onex.cmd.omnibase-infra.gateway-link-health-upsert.v1": { + "family": "P1_topic_event_type_matrix", + "input": { + "event_type": "omnibase-infra.gateway-link-health-upsert", + "payload": "_DictPayload" + }, + "selection": { + "dispatcher_ids": [ + "dispatcher.auto.node_gateway_link_health_write_effect.HandlerGatewayLinkHealthUpsert.gateway_link_health_upsert_e2d256a3" + ], + "dlq_topic": null, + "message_category": "command", + "message_type": "omnibase-infra.gateway-link-health-upsert", + "status": "success" + }, + "topic": "onex.cmd.omnibase-infra.gateway-link-health-upsert.v1" + }, "P1a::onex.cmd.omnibase-infra.llm-completion-request.v1": { "family": "P1_topic_event_type_matrix", "input": { @@ -4274,6 +4321,23 @@ }, "topic": "onex.evt.omnibase-infra.db-error.v1" }, + "P1a::onex.evt.omnibase-infra.gateway-heartbeat.v1": { + "family": "P1_topic_event_type_matrix", + "input": { + "event_type": "omnibase-infra.gateway-heartbeat", + "payload": "_DictPayload" + }, + "selection": { + "dispatcher_ids": [ + "dispatcher.auto.node_gateway_link_health_projection_compute.HandlerGatewayLinkHealthProjection.gateway_link_health_project_b6e85659" + ], + "dlq_topic": null, + "message_category": "event", + "message_type": "omnibase-infra.gateway-heartbeat", + "status": "success" + }, + "topic": "onex.evt.omnibase-infra.gateway-heartbeat.v1" + }, "P1a::onex.evt.omnibase-infra.runner-fleet-health-verdict.v1": { "family": "P1_topic_event_type_matrix", "input": { @@ -5205,6 +5269,21 @@ }, "topic": "onex.cmd.omnibase-infra.gateway-heartbeat-request.v1" }, + "P1b::onex.cmd.omnibase-infra.gateway-link-health-upsert.v1": { + "family": "P1_topic_event_type_matrix", + "input": { + "event_type": null, + "payload": "_DictPayload" + }, + "selection": { + "dispatcher_ids": [], + "dlq_topic": "onex.dlq.omnibase-infra.commands.v1", + "message_category": "command", + "message_type": "_DictPayload", + "status": "no_dispatcher" + }, + "topic": "onex.cmd.omnibase-infra.gateway-link-health-upsert.v1" + }, "P1b::onex.cmd.omnibase-infra.llm-completion-request.v1": { "family": "P1_topic_event_type_matrix", "input": { @@ -5925,6 +6004,21 @@ }, "topic": "onex.evt.omnibase-infra.db-error.v1" }, + "P1b::onex.evt.omnibase-infra.gateway-heartbeat.v1": { + "family": "P1_topic_event_type_matrix", + "input": { + "event_type": null, + "payload": "_DictPayload" + }, + "selection": { + "dispatcher_ids": [], + "dlq_topic": "onex.dlq.omnibase-infra.events.v1", + "message_category": "event", + "message_type": "_DictPayload", + "status": "no_dispatcher" + }, + "topic": "onex.evt.omnibase-infra.gateway-heartbeat.v1" + }, "P1b::onex.evt.omnibase-infra.runner-fleet-health-verdict.v1": { "family": "P1_topic_event_type_matrix", "input": { @@ -23075,6 +23169,26 @@ "topic_pattern": "*.cmd.omnibase-infra.gateway-heartbeat-request.*", "type_scoped": false }, + { + "contract_name": "node_gateway_link_health_projection_compute", + "dispatcher_id": "dispatcher.auto.node_gateway_link_health_projection_compute.HandlerGatewayLinkHealthProjection.gateway_link_health_project_b6e85659", + "handler_id": "dispatcher.auto.node_gateway_link_health_projection_compute.HandlerGatewayLinkHealthProjection.gateway_link_health_project_b6e85659", + "message_category": "event", + "route_id": "route.auto.node_gateway_link_health_projection_compute.HandlerGatewayLinkHealthProjection.gateway_link_health_project_b6e85659.onex_evt_omnibase_infra_gateway_heartbeat_v1", + "topic": "onex.evt.omnibase-infra.gateway-heartbeat.v1", + "topic_pattern": "*.evt.omnibase-infra.gateway-heartbeat.*", + "type_scoped": false + }, + { + "contract_name": "node_gateway_link_health_write_effect", + "dispatcher_id": "dispatcher.auto.node_gateway_link_health_write_effect.HandlerGatewayLinkHealthUpsert.gateway_link_health_upsert_e2d256a3", + "handler_id": "dispatcher.auto.node_gateway_link_health_write_effect.HandlerGatewayLinkHealthUpsert.gateway_link_health_upsert_e2d256a3", + "message_category": "command", + "route_id": "route.auto.node_gateway_link_health_write_effect.HandlerGatewayLinkHealthUpsert.gateway_link_health_upsert_e2d256a3.onex_cmd_omnibase_infra_gateway_link_health_upsert_v1", + "topic": "onex.cmd.omnibase-infra.gateway-link-health-upsert.v1", + "topic_pattern": "*.cmd.omnibase-infra.gateway-link-health-upsert.*", + "type_scoped": false + }, { "contract_name": "node_github_pr_poller_effect", "dispatcher_id": "dispatcher.auto.node_github_pr_poller_effect.HandlerGitHubApiPoll.github_poll_prs_7786448a", diff --git a/tests/integration/migrations/test_100_gateway_link_health_verdicts.py b/tests/integration/migrations/test_100_gateway_link_health_verdicts.py new file mode 100644 index 0000000000..e9b1dfeee8 --- /dev/null +++ b/tests/integration/migrations/test_100_gateway_link_health_verdicts.py @@ -0,0 +1,341 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT + +"""Live-apply proof for the gateway_link_health_status verdict logic (OMN-15570). + +These tests apply ``100_create_gateway_link_health.sql`` to a throwaway +Postgres cluster and read ``health_status`` back out of the real view. They +exist because the verdict is SQL, not Python: a unit test that greps the +migration text proves the string is present, not that the CASE arms evaluate +the way the ticket requires. + +The specific regression they lock down: before OMN-15742/G2 the heartbeat had +no self-reported status, so health was derived from recency alone. Once G2 +added ``status``, an edge could heartbeat perfectly on schedule while +reporting itself degraded — and a recency-only view scored that HEALTHY. A +health surface that renders a self-reported-degraded edge as HEALTHY is worse +than no health surface, because the wrong answer is indistinguishable from +the right one. ``test_degraded_self_report_beats_recency`` is the arm that +must never regress. + +Precedence under test (stale > self-reported > lag > healthy) is asserted +directly, including the ambiguous case where two arms are simultaneously true. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import psycopg2 +import pytest + +from tests.integration.migrations.conftest import EphemeralPostgres + +pytestmark = [pytest.mark.integration, pytest.mark.postgres] + +REPO_ROOT = Path(__file__).resolve().parents[3] +FORWARD = ( + REPO_ROOT + / "docker" + / "migrations" + / "forward" + / "100_create_gateway_link_health.sql" +) +ROLLBACK = ( + REPO_ROOT + / "docker" + / "migrations" + / "rollback" + / "rollback_100_create_gateway_link_health.sql" +) + +# Matches the migration's hardcoded silence window. Anything older than this +# is stale; the drift guard in tests/unit/db/test_migration_100.py is what +# keeps that literal pinned to the contract. +SILENCE_WINDOW = timedelta(seconds=60) + + +@pytest.fixture +def applied( + ephemeral_postgres: EphemeralPostgres, +) -> Iterator[psycopg2.extensions.connection]: + """Apply the real forward migration, yield an open connection.""" + result = ephemeral_postgres.psql("-v", "ON_ERROR_STOP=1", "-f", str(FORWARD)) + assert result.returncode == 0, result.stderr + conn = ephemeral_postgres.connect() + try: + yield conn + finally: + conn.close() + + +def _upsert( + conn: psycopg2.extensions.connection, + *, + tenant_id: str, + age: timedelta, + reported_status: str = "active", + consecutive_failures: int = 0, + lag_messages: int | None = None, + lag_seconds: float | None = None, +) -> None: + """Write one row, with last_seen_at placed `age` in the past.""" + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO omninode_internal.gateway_link_health ( + tenant_id, principal_id, local_transport_flavor, last_seen_at, + reported_status, consecutive_failures, lag_messages, lag_seconds + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (tenant_id) DO UPDATE SET + last_seen_at = EXCLUDED.last_seen_at, + reported_status = EXCLUDED.reported_status, + consecutive_failures = EXCLUDED.consecutive_failures, + lag_messages = EXCLUDED.lag_messages, + lag_seconds = EXCLUDED.lag_seconds + """, + ( + tenant_id, + f"t-{tenant_id}", + "containerized", + datetime.now(UTC) - age, + reported_status, + consecutive_failures, + lag_messages, + lag_seconds, + ), + ) + conn.commit() + + +def _verdict(conn: psycopg2.extensions.connection, tenant_id: str) -> str: + with conn.cursor() as cur: + cur.execute( + "SELECT health_status FROM omninode_internal.gateway_link_health_status " + "WHERE tenant_id = %s", + (tenant_id,), + ) + row = cur.fetchone() + assert row is not None, f"no projection row for {tenant_id}" + return str(row[0]) + + +def test_fresh_active_edge_is_healthy( + applied: psycopg2.extensions.connection, +) -> None: + _upsert(applied, tenant_id="fresh", age=timedelta(seconds=5)) + + assert _verdict(applied, "fresh") == "HEALTHY" + + +def test_silent_edge_is_unhealthy( + applied: psycopg2.extensions.connection, +) -> None: + """Absence of progress is visible as a stale row, not a missing one.""" + _upsert(applied, tenant_id="silent", age=SILENCE_WINDOW + timedelta(seconds=30)) + + assert _verdict(applied, "silent") == "UNHEALTHY" + + with applied.cursor() as cur: + cur.execute("SELECT count(*) FROM omninode_internal.gateway_link_health_status") + assert cur.fetchone()[0] == 1, "the stale row must still be present" + + +def test_degraded_self_report_beats_recency( + applied: psycopg2.extensions.connection, +) -> None: + """THE regression this file exists for. + + The edge is heartbeating well inside the silence window — recency alone + would score it HEALTHY — but it reports itself degraded. It must not read + as healthy. + """ + _upsert( + applied, + tenant_id="degraded", + age=timedelta(seconds=2), + reported_status="degraded", + consecutive_failures=3, + ) + + assert _verdict(applied, "degraded") == "DEGRADED_SELF_REPORTED" + + +def test_unrecognised_status_is_not_scored_healthy( + applied: psycopg2.extensions.connection, +) -> None: + """A status the producer adds later must fail toward not-healthy. + + The column is free TEXT precisely so an unknown value cannot crash the + projection; the view's `<> 'active'` test is what stops it being read as + healthy by omission. + """ + _upsert( + applied, + tenant_id="draining", + age=timedelta(seconds=2), + reported_status="draining", + ) + + assert _verdict(applied, "draining") == "DEGRADED_SELF_REPORTED" + + +def test_stale_outranks_degraded_when_both_are_true( + applied: psycopg2.extensions.connection, +) -> None: + """Deterministic precedence for simultaneous signals. + + A stale edge's last self-report is itself stale, so staleness wins. The + point is that the answer is fixed, not that either verdict is nicer. + """ + _upsert( + applied, + tenant_id="both", + age=SILENCE_WINDOW + timedelta(seconds=30), + reported_status="degraded", + consecutive_failures=9, + ) + + assert _verdict(applied, "both") == "UNHEALTHY" + + +def test_self_report_outranks_lag_when_both_are_true( + applied: psycopg2.extensions.connection, +) -> None: + """A first-party statement outranks a metric inferred about the edge.""" + _upsert( + applied, + tenant_id="degraded-and-lagging", + age=timedelta(seconds=2), + reported_status="degraded", + lag_messages=5_000, + ) + + assert _verdict(applied, "degraded-and-lagging") == "DEGRADED_SELF_REPORTED" + + +def test_lag_breach_still_reported_for_an_active_edge( + applied: psycopg2.extensions.connection, +) -> None: + """The lag arms stay reachable — adding the status arm did not shadow them.""" + _upsert( + applied, + tenant_id="lagging", + age=timedelta(seconds=2), + lag_messages=5_000, + ) + + assert _verdict(applied, "lagging") == "DEGRADED_LAG" + + +def test_null_lag_never_produces_a_verdict( + applied: psycopg2.extensions.connection, +) -> None: + """NULL lag is neither TRUE nor FALSE in SQL — it must not decide anything.""" + _upsert(applied, tenant_id="nolag", age=timedelta(seconds=2)) + + assert _verdict(applied, "nolag") == "HEALTHY" + + +def test_recovery_flips_the_verdict_back( + applied: psycopg2.extensions.connection, +) -> None: + """Degraded is not sticky: the next healthy heartbeat clears it. + + Same row throughout — this is a latest-known-state projection, so recovery + is an UPDATE of the one row, never a second row or a delete. + """ + _upsert( + applied, + tenant_id="recovers", + age=timedelta(seconds=2), + reported_status="degraded", + consecutive_failures=4, + ) + assert _verdict(applied, "recovers") == "DEGRADED_SELF_REPORTED" + + _upsert( + applied, + tenant_id="recovers", + age=timedelta(seconds=1), + reported_status="active", + consecutive_failures=0, + ) + + assert _verdict(applied, "recovers") == "HEALTHY" + with applied.cursor() as cur: + cur.execute( + "SELECT count(*) FROM omninode_internal.gateway_link_health WHERE tenant_id = %s", + ("recovers",), + ) + assert cur.fetchone()[0] == 1, "recovery must update in place, not insert" + + +def test_silence_then_return_flips_back_from_unhealthy( + applied: psycopg2.extensions.connection, +) -> None: + """An edge that goes quiet and comes back is HEALTHY again, same row.""" + _upsert( + applied, + tenant_id="returns", + age=SILENCE_WINDOW + timedelta(seconds=30), + ) + assert _verdict(applied, "returns") == "UNHEALTHY" + + _upsert(applied, tenant_id="returns", age=timedelta(seconds=1)) + + assert _verdict(applied, "returns") == "HEALTHY" + + +def test_consecutive_failures_is_recorded_but_drives_no_verdict( + applied: psycopg2.extensions.connection, +) -> None: + """Evidence, not an input. + + The producer already folds the failure count into `status`; re-deriving a + verdict from it here would let the two disagree about the same edge. + """ + _upsert( + applied, + tenant_id="blips", + age=timedelta(seconds=2), + reported_status="active", + consecutive_failures=99, + ) + + assert _verdict(applied, "blips") == "HEALTHY" + with applied.cursor() as cur: + cur.execute( + "SELECT consecutive_failures FROM omninode_internal.gateway_link_health_status " + "WHERE tenant_id = %s", + ("blips",), + ) + assert cur.fetchone()[0] == 99, "the evidence must still be readable" + + +def test_forward_migration_is_idempotent( + ephemeral_postgres: EphemeralPostgres, + applied: psycopg2.extensions.connection, +) -> None: + """Re-applying must not fail — the runner may replay it.""" + result = ephemeral_postgres.psql("-v", "ON_ERROR_STOP=1", "-f", str(FORWARD)) + + assert result.returncode == 0, result.stderr + + +def test_rollback_removes_both_objects( + ephemeral_postgres: EphemeralPostgres, + applied: psycopg2.extensions.connection, +) -> None: + result = ephemeral_postgres.psql("-v", "ON_ERROR_STOP=1", "-f", str(ROLLBACK)) + assert result.returncode == 0, result.stderr + + with applied.cursor() as cur: + cur.execute("SELECT to_regclass('omninode_internal.gateway_link_health')") + assert cur.fetchone()[0] is None + cur.execute( + "SELECT to_regclass('omninode_internal.gateway_link_health_status')" + ) + assert cur.fetchone()[0] is None diff --git a/tests/unit/db/test_migration_100.py b/tests/unit/db/test_migration_100.py new file mode 100644 index 0000000000..7aa3050f23 --- /dev/null +++ b/tests/unit/db/test_migration_100.py @@ -0,0 +1,163 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT + +"""Unit checks for migration 100 gateway_link_health + liveness-threshold drift. + +OMN-15570 (G3) hardcodes the ``gateway_link_health_status`` view's health +thresholds (``max_silence_window_seconds`` / ``lag_threshold_messages`` / +``lag_threshold_seconds``) as SQL literals rather than reading them from the +contract at migration/config load time -- there is no existing repo pattern +for materializing a value into a static, already-applied SQL view at +migration time (unlike the runtime config loader's +``_materialize_contract_*`` functions, which run at process start against a +YAML file, a view body is baked in once at ``CREATE OR REPLACE VIEW`` time). +This is the 4th independent copy of these three numbers (contract.yaml, +``ModelGatewayForwarderConfig`` defaults, deploy YAML, and now this view) -- +tracked as gateway config triplication under OMN-15762. + +Per that ticket's disclosed scope, this test is the fail-closed guard: it +pins the SQL view's literals to the contract's declared values and fails if +they diverge, so an operator changing the contract's liveness block without +updating the migration finds out here instead of via a silent, wrong +``HEALTHY``/``UNHEALTHY`` verdict in production. +""" + +import re +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[3] +FORWARD = ( + REPO_ROOT + / "docker" + / "migrations" + / "forward" + / "100_create_gateway_link_health.sql" +) +ROLLBACK = ( + REPO_ROOT + / "docker" + / "migrations" + / "rollback" + / "rollback_100_create_gateway_link_health.sql" +) +CONTRACT = ( + REPO_ROOT + / "src" + / "omnibase_infra" + / "nodes" + / "node_bus_forwarder_effect" + / "contract.yaml" +) + + +def _contract_liveness() -> dict[str, int]: + contract = yaml.safe_load(CONTRACT.read_text(encoding="utf-8")) + liveness = contract["config"]["gateway_forwarder"]["liveness"] + return { + "max_silence_window_seconds": int(liveness["max_silence_window_seconds"]), + "lag_threshold_messages": int(liveness["lag_threshold_messages"]), + "lag_threshold_seconds": int(liveness["lag_threshold_seconds"]), + } + + +def test_100_forward_creates_table_and_view() -> None: + sql = FORWARD.read_text(encoding="utf-8").lower() + + assert "create table if not exists omninode_internal.gateway_link_health" in sql + assert "create or replace view omninode_internal.gateway_link_health_status" in sql + assert "primary key (tenant_id)" in sql + + +def test_100_rollback_drops_view_and_table() -> None: + sql = ROLLBACK.read_text(encoding="utf-8").lower() + + assert "drop view if exists omninode_internal.gateway_link_health_status" in sql + assert "drop table if exists omninode_internal.gateway_link_health" in sql + + +def test_100_view_liveness_thresholds_match_contract_omn_15762() -> None: + """Fail-closed drift guard (OMN-15762): the migration's hardcoded + thresholds must match node_bus_forwarder_effect/contract.yaml's + liveness block. This is the 4th copy of these numbers (contract, + ModelGatewayForwarderConfig defaults, deploy YAML, this view) -- see + module docstring. A mismatch here means the contract changed and the + view was not updated to match, which would silently mis-classify + gateway health. + """ + sql = FORWARD.read_text(encoding="utf-8") + contract = _contract_liveness() + + silence_match = re.search( + r"NOW\(\)\s*-\s*last_seen_at\s*>\s*INTERVAL\s*'(\d+)\s*seconds'", + sql, + ) + assert silence_match is not None, ( + "expected an `INTERVAL ' seconds'` silence-window comparison in " + "100_create_gateway_link_health.sql -- update this regex if the " + "view's SQL shape changed" + ) + assert int(silence_match.group(1)) == contract["max_silence_window_seconds"], ( + "100_create_gateway_link_health.sql's silence-window literal has " + "drifted from node_bus_forwarder_effect/contract.yaml's " + "gateway_forwarder.liveness.max_silence_window_seconds (OMN-15762 " + "4th-copy class) -- update the migration's hardcoded literal (a new " + "migration, since this one is already applied) to match the " + "contract, or update the contract to match an intentional migration " + "change" + ) + + lag_messages_match = re.search( + r"lag_messages\s+IS\s+NOT\s+NULL\s+AND\s+lag_messages\s*>\s*(\d+)", + sql, + ) + assert lag_messages_match is not None + assert int(lag_messages_match.group(1)) == contract["lag_threshold_messages"], ( + "100_create_gateway_link_health.sql's lag_threshold_messages " + "literal has drifted from the contract (OMN-15762 4th-copy class)" + ) + + lag_seconds_match = re.search( + r"lag_seconds\s+IS\s+NOT\s+NULL\s+AND\s+lag_seconds\s*>\s*(\d+)", + sql, + ) + assert lag_seconds_match is not None + assert int(lag_seconds_match.group(1)) == contract["lag_threshold_seconds"], ( + "100_create_gateway_link_health.sql's lag_threshold_seconds literal " + "has drifted from the contract (OMN-15762 4th-copy class)" + ) + + +def test_100_view_ranks_self_reported_degraded_above_lag_and_below_stale() -> None: + """Pin the CASE arm ORDER, not just its presence (OMN-15742/G2). + + The live-apply proof in + tests/integration/migrations/test_100_gateway_link_health_verdicts.py is + what actually executes these arms. This is the cheap always-runs guard for + the case where that suite is skipped because Postgres tooling is absent: + it fails if someone reorders the arms, since CASE stops at the first TRUE + and the order IS the precedence. + """ + sql = FORWARD.read_text(encoding="utf-8") + # Anchor on the CASE expression itself. Splitting on the CREATE statement + # is not enough: the header comment carries an example query containing + # 'HEALTHY', which would be found ahead of the real arms. + case_block = sql.split(" CASE\n", 1)[1].split("END AS health_status", 1)[0] + + stale = case_block.index("'UNHEALTHY'") + self_reported = case_block.index("'DEGRADED_SELF_REPORTED'") + lag = case_block.index("'DEGRADED_LAG'") + healthy = case_block.index("'HEALTHY'") + + assert stale < self_reported < lag < healthy + + +def test_100_table_and_view_carry_the_self_reported_status_columns() -> None: + sql = FORWARD.read_text(encoding="utf-8").lower() + + assert "reported_status text not null" in sql + assert "consecutive_failures integer not null" in sql + # Anything that is not 'active' is degraded -- an equality test against a + # closed set would score an unrecognised future status HEALTHY by omission. + assert "reported_status <> 'active'" in sql diff --git a/tests/unit/nodes/node_bus_forwarder_effect/test_gateway_forwarder_service.py b/tests/unit/nodes/node_bus_forwarder_effect/test_gateway_forwarder_service.py index 3556f09e7a..397820f1f0 100644 --- a/tests/unit/nodes/node_bus_forwarder_effect/test_gateway_forwarder_service.py +++ b/tests/unit/nodes/node_bus_forwarder_effect/test_gateway_forwarder_service.py @@ -253,6 +253,45 @@ async def test_heartbeat_is_tenant_bound_and_published_to_cloud_wire_topic() -> assert envelope.metadata.tags["gateway_direction"] == "local-to-cloud" +async def test_heartbeat_also_mirrors_untransformed_to_local_canonical_topic() -> None: + """OMN-15570 (G3): NodeGatewayLinkHealthProjectionCompute subscribes to + the gateway-heartbeat topic on the LOCAL bus (bus-is-transport: local + consumers read canonical topics, never the cloud wire topic). Before + this fix ``publish_heartbeat`` only reached the cloud leg, so no local + consumer ever observed a heartbeat. + """ + local_bus = _MockGatewayBus() + cloud_bus = _MockGatewayBus() + config = _config().model_copy( + update={ + "mirror_topics": ModelGatewayMirrorTopics( + inbound=(INBOUND_TOPIC,), + outbound=(OUTBOUND_TOPIC, HEARTBEAT_TOPIC), + ) + } + ) + service = ServiceGatewayForwarder( + config=config, + local_bus=local_bus, + cloud_bus=cloud_bus, + ) + + await service.publish_heartbeat() + + assert len(local_bus.published) == 1 + message = local_bus.published[0] + assert message.topic == HEARTBEAT_TOPIC + envelope = ModelEventEnvelope[dict[str, object]].model_validate_json(message.value) + assert envelope.event_type == "omnibase-infra.gateway-heartbeat" + assert envelope.payload["tenant_id"] == "acme" + assert envelope.payload["status"] == "active" + # Same liveness tick, both legs: envelope_id must match the cloud copy. + cloud_envelope = ModelEventEnvelope[dict[str, object]].model_validate_json( + cloud_bus.published[0].value + ) + assert envelope.envelope_id == cloud_envelope.envelope_id + + async def test_publish_status_degraded_goes_to_local_bus_not_cloud() -> None: """OMN-15742: DEGRADED must stay observable while the cloud leg is down. diff --git a/tests/unit/nodes/node_gateway_link_health_projection_compute/__init__.py b/tests/unit/nodes/node_gateway_link_health_projection_compute/__init__.py new file mode 100644 index 0000000000..6085072eee --- /dev/null +++ b/tests/unit/nodes/node_gateway_link_health_projection_compute/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 OmniNode Team diff --git a/tests/unit/nodes/node_gateway_link_health_projection_compute/test_handler_gateway_link_health_projection.py b/tests/unit/nodes/node_gateway_link_health_projection_compute/test_handler_gateway_link_health_projection.py new file mode 100644 index 0000000000..88cfc19cb7 --- /dev/null +++ b/tests/unit/nodes/node_gateway_link_health_projection_compute/test_handler_gateway_link_health_projection.py @@ -0,0 +1,360 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 OmniNode Team +"""Unit tests for HandlerGatewayLinkHealthProjection (OMN-15570, G3). + +Pure-logic tests for the COMPUTE handler that projects gateway heartbeat +events into ModelPayloadGatewayLinkHealthUpsert intents. No Kafka or +Postgres needed. Mirrors test_handler_pr_state_projection.py. + +Test coverage: + - Round-trips tenant_id, principal_id, local_transport_flavor, + last_seen_at from a ModelGatewayHeartbeat-shaped body + - lag_messages/lag_seconds default to None when absent (today's real + producer shape) and round-trip when present (forward-compat) + - Round-trips the edge's self-reported status and consecutive_failures + (OMN-15742/G2), including a status value the producer adds later + - Raises RuntimeHostError when tenant_id/principal_id/ + local_transport_flavor/status/consecutive_failures is missing, rather + than defaulting a missing status to "active" + - Unwraps ModelEventEnvelope-style wrappers when payload is nested + - Raises RuntimeHostError on unparseable JSON and non-object root + - Emits ModelIntent with intent_type='gateway_link_health.upsert' and a + postgres:// target + - handle() drives the real dispatch-shaped entry point (ModelHandlerOutput) + - handle() also accepts a raw DICT-shaped envelope (OMN-14139 lesson) +""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from datetime import UTC, datetime +from typing import Any +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest + +from omnibase_infra.errors import RuntimeHostError +from omnibase_infra.event_bus.models.model_event_headers import ModelEventHeaders +from omnibase_infra.event_bus.models.model_event_message import ModelEventMessage +from omnibase_infra.nodes.node_gateway_link_health_projection_compute import ( + HandlerGatewayLinkHealthProjection, + ModelPayloadGatewayLinkHealthUpsert, +) + +pytestmark = [pytest.mark.unit] + + +def _make_message(value: bytes) -> ModelEventMessage: + return ModelEventMessage( + topic="onex.evt.omnibase-infra.gateway-heartbeat.v1", + partition=0, + offset="0", + key=None, + value=value, + headers=ModelEventHeaders( + timestamp=datetime.now(UTC), + source="test-gateway-forwarder", + event_type="omnibase-infra.gateway-heartbeat", + ), + ) + + +def _from_dict(body: Mapping[str, Any]) -> ModelEventMessage: + return _make_message(json.dumps(dict(body)).encode("utf-8")) + + +@pytest.fixture +def handler() -> HandlerGatewayLinkHealthProjection: + return HandlerGatewayLinkHealthProjection(container=MagicMock()) + + +def test_project_extracts_canonical_fields( + handler: HandlerGatewayLinkHealthProjection, +) -> None: + ts = datetime.now(UTC).isoformat() + body = { + "tenant_id": "beta-gateway-canary-79afa7263852", + "principal_id": "t-abc123", + "status": "active", + "consecutive_failures": 0, + "emitted_at": ts, + "local_transport_flavor": "containerized", + } + + intent = handler.project(_from_dict(body)) + + assert intent.intent_type == "gateway_link_health.upsert" + assert ( + intent.target + == "postgres://gateway_link_health/beta-gateway-canary-79afa7263852" + ) + payload = intent.payload + assert isinstance(payload, ModelPayloadGatewayLinkHealthUpsert) + assert payload.tenant_id == "beta-gateway-canary-79afa7263852" + assert payload.principal_id == "t-abc123" + assert payload.local_transport_flavor == "containerized" + assert payload.last_seen_at == datetime.fromisoformat(ts) + # Today's real ModelGatewayHeartbeat producer never supplies lag data. + assert payload.lag_messages is None + assert payload.lag_seconds is None + + +def test_project_round_trips_lag_fields_when_present( + handler: HandlerGatewayLinkHealthProjection, +) -> None: + """Forward-compat: once G1/G2 land a lag-carrying producer, this handler + already round-trips the fields without a code change.""" + body = { + "tenant_id": "beta-gateway-canary-79afa7263852", + "principal_id": "t-abc123", + "local_transport_flavor": "lightweight", + "status": "active", + "consecutive_failures": 0, + "lag_messages": 12, + "lag_seconds": 3.5, + } + + intent = handler.project(_from_dict(body)) + + payload = intent.payload + assert isinstance(payload, ModelPayloadGatewayLinkHealthUpsert) + assert payload.lag_messages == 12 + assert payload.lag_seconds == 3.5 + + +def test_project_unwraps_envelope_payload( + handler: HandlerGatewayLinkHealthProjection, +) -> None: + inner = { + "tenant_id": "tenant-a", + "principal_id": "t-a", + "local_transport_flavor": "containerized", + "status": "active", + "consecutive_failures": 0, + } + envelope_shaped = {"envelope_id": str(uuid4()), "payload": inner} + + intent = handler.project(_from_dict(envelope_shaped)) + + assert isinstance(intent.payload, ModelPayloadGatewayLinkHealthUpsert) + assert intent.payload.tenant_id == "tenant-a" + + +def test_project_falls_back_to_now_when_emitted_at_missing( + handler: HandlerGatewayLinkHealthProjection, +) -> None: + intent = handler.project( + _from_dict( + { + "tenant_id": "tenant-b", + "principal_id": "t-b", + "local_transport_flavor": "containerized", + "status": "active", + "consecutive_failures": 0, + } + ) + ) + + payload = intent.payload + assert isinstance(payload, ModelPayloadGatewayLinkHealthUpsert) + assert payload.last_seen_at.tzinfo is not None + + +def test_project_raises_when_tenant_id_missing( + handler: HandlerGatewayLinkHealthProjection, +) -> None: + with pytest.raises(RuntimeHostError): + handler.project( + _from_dict( + {"principal_id": "t-a", "local_transport_flavor": "containerized"} + ) + ) + + +def test_project_raises_when_principal_id_missing( + handler: HandlerGatewayLinkHealthProjection, +) -> None: + with pytest.raises(RuntimeHostError): + handler.project( + _from_dict( + {"tenant_id": "tenant-a", "local_transport_flavor": "containerized"} + ) + ) + + +def test_project_raises_when_local_transport_flavor_missing( + handler: HandlerGatewayLinkHealthProjection, +) -> None: + with pytest.raises(RuntimeHostError): + handler.project(_from_dict({"tenant_id": "tenant-a", "principal_id": "t-a"})) + + +def test_project_raises_when_value_is_not_json( + handler: HandlerGatewayLinkHealthProjection, +) -> None: + with pytest.raises(RuntimeHostError): + handler.project(_make_message(b"not-json")) + + +def test_project_raises_when_value_is_not_object( + handler: HandlerGatewayLinkHealthProjection, +) -> None: + with pytest.raises(RuntimeHostError): + handler.project(_make_message(b'["array", "not", "object"]')) + + +def test_project_raises_when_value_is_empty_bytes( + handler: HandlerGatewayLinkHealthProjection, +) -> None: + with pytest.raises(RuntimeHostError): + handler.project(_make_message(b"")) + + +@pytest.mark.asyncio +async def test_handle_drives_dispatch_shaped_entry_point( + handler: HandlerGatewayLinkHealthProjection, +) -> None: + """handle() is the auto-wiring entry point the real dispatch path calls.""" + body = { + "tenant_id": "beta-gateway-canary-79afa7263852", + "principal_id": "t-abc123", + "local_transport_flavor": "containerized", + "status": "active", + "consecutive_failures": 0, + } + message = _from_dict(body) + + output = await handler.handle(message) + + assert output.result is not None + assert output.result.intent_type == "gateway_link_health.upsert" + assert output.result.payload.tenant_id == "beta-gateway-canary-79afa7263852" + + +@pytest.mark.asyncio +async def test_handle_accepts_dict_shaped_envelope( + handler: HandlerGatewayLinkHealthProjection, +) -> None: + """OMN-14139 lesson: MessageDispatchEngine delivers a DICT envelope (not + an already-typed ModelEventMessage) for a no-event_model/operation_match + dispatch path.""" + body = { + "tenant_id": "beta-gateway-canary-79afa7263852", + "principal_id": "t-abc123", + "local_transport_flavor": "lightweight", + "status": "active", + "consecutive_failures": 0, + } + raw_event_message: dict[str, Any] = { + "topic": "onex.evt.omnibase-infra.gateway-heartbeat.v1", + "partition": 0, + "offset": "0", + "key": None, + "value": json.dumps(body).encode("utf-8"), + "headers": { + "timestamp": datetime.now(UTC), + "source": "test-gateway-forwarder", + "event_type": "omnibase-infra.gateway-heartbeat", + }, + } + dict_envelope: dict[str, Any] = {"payload": raw_event_message} + + output = await handler.handle(dict_envelope) + + assert output.result is not None + payload = output.result.payload + assert isinstance(payload, ModelPayloadGatewayLinkHealthUpsert) + assert payload.tenant_id == "beta-gateway-canary-79afa7263852" + assert payload.local_transport_flavor == "lightweight" + + +# --------------------------------------------------------------------------- +# Self-reported status (OMN-15742/G2 fields) +# --------------------------------------------------------------------------- +# G2 added status/consecutive_failures to every ModelGatewayHeartbeat. These +# assert the projection carries them rather than dropping them on the floor — +# a dropped status is what let a degraded edge project as healthy. + + +def _canonical_body(**overrides: Any) -> dict[str, Any]: + body: dict[str, Any] = { + "tenant_id": "tenant-status", + "principal_id": "t-status", + "local_transport_flavor": "containerized", + "status": "active", + "consecutive_failures": 0, + } + body.update(overrides) + return body + + +def test_project_round_trips_degraded_status( + handler: HandlerGatewayLinkHealthProjection, +) -> None: + intent = handler.project( + _from_dict(_canonical_body(status="degraded", consecutive_failures=3)) + ) + + payload = intent.payload + assert isinstance(payload, ModelPayloadGatewayLinkHealthUpsert) + assert payload.reported_status == "degraded" + assert payload.consecutive_failures == 3 + + +def test_project_round_trips_unrecognised_status_verbatim( + handler: HandlerGatewayLinkHealthProjection, +) -> None: + """An unknown status must reach the projection unchanged, not be rejected. + + The status view is what decides such a value is not-healthy. Failing + validation here instead would stall the whole projection the moment the + producer grows a new state. + """ + intent = handler.project(_from_dict(_canonical_body(status="draining"))) + + payload = intent.payload + assert isinstance(payload, ModelPayloadGatewayLinkHealthUpsert) + assert payload.reported_status == "draining" + + +def test_project_raises_when_status_missing( + handler: HandlerGatewayLinkHealthProjection, +) -> None: + """Never default a missing status to 'active'. + + Defaulting would score a malformed producer HEALTHY, and a wrongly-healthy + verdict is invisible — unlike silence, which shows up as staleness. + """ + body = _canonical_body() + del body["status"] + + with pytest.raises(RuntimeHostError): + handler.project(_from_dict(body)) + + +def test_project_raises_when_consecutive_failures_missing( + handler: HandlerGatewayLinkHealthProjection, +) -> None: + body = _canonical_body() + del body["consecutive_failures"] + + with pytest.raises(RuntimeHostError): + handler.project(_from_dict(body)) + + +def test_project_raises_when_consecutive_failures_is_boolean( + handler: HandlerGatewayLinkHealthProjection, +) -> None: + """bool is an int subclass in Python — true would silently record as 1.""" + with pytest.raises(RuntimeHostError): + handler.project(_from_dict(_canonical_body(consecutive_failures=True))) + + +def test_project_raises_when_consecutive_failures_is_negative( + handler: HandlerGatewayLinkHealthProjection, +) -> None: + with pytest.raises(RuntimeHostError): + handler.project(_from_dict(_canonical_body(consecutive_failures=-1))) diff --git a/tests/unit/nodes/node_gateway_link_health_write_effect/__init__.py b/tests/unit/nodes/node_gateway_link_health_write_effect/__init__.py new file mode 100644 index 0000000000..6085072eee --- /dev/null +++ b/tests/unit/nodes/node_gateway_link_health_write_effect/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 OmniNode Team diff --git a/tests/unit/nodes/node_gateway_link_health_write_effect/handlers/__init__.py b/tests/unit/nodes/node_gateway_link_health_write_effect/handlers/__init__.py new file mode 100644 index 0000000000..6085072eee --- /dev/null +++ b/tests/unit/nodes/node_gateway_link_health_write_effect/handlers/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 OmniNode Team diff --git a/tests/unit/nodes/node_gateway_link_health_write_effect/handlers/test_handler_gateway_link_health_upsert.py b/tests/unit/nodes/node_gateway_link_health_write_effect/handlers/test_handler_gateway_link_health_upsert.py new file mode 100644 index 0000000000..3b4b6dc865 --- /dev/null +++ b/tests/unit/nodes/node_gateway_link_health_write_effect/handlers/test_handler_gateway_link_health_upsert.py @@ -0,0 +1,303 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 OmniNode Team +"""Unit tests for HandlerGatewayLinkHealthUpsert (OMN-15570, G3). + +Tests validate: +- Constructor composes HandlerDb internally from `container` alone + (OMN-14140 pattern). +- Lazy DB connection guard (missing DSN raises RuntimeHostError). +- Insert-vs-update detection via `RETURNING (xmax = 0) AS was_insert`. +- handle() drives the real dispatch-shaped entry point (dict-and-attribute + envelope shapes). + +Mirrors test_handler_pr_state_upsert.py. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest + +from omnibase_core.container import ModelONEXContainer +from omnibase_infra.enums import EnumResponseStatus +from omnibase_infra.errors import RuntimeHostError +from omnibase_infra.handlers.handler_db import HandlerDb +from omnibase_infra.handlers.models import ModelDbQueryPayload, ModelDbQueryResponse +from omnibase_infra.nodes.node_gateway_link_health_projection_compute.models import ( + ModelPayloadGatewayLinkHealthUpsert, +) +from omnibase_infra.nodes.node_gateway_link_health_write_effect.handlers.handler_gateway_link_health_upsert import ( + HandlerGatewayLinkHealthUpsert, +) + +pytestmark = [pytest.mark.unit] + + +def make_mock_container() -> MagicMock: + return MagicMock(spec=ModelONEXContainer) + + +def make_handler_with_mock_db( + initialized: bool = True, +) -> tuple[HandlerGatewayLinkHealthUpsert, AsyncMock]: + container = make_mock_container() + handler = HandlerGatewayLinkHealthUpsert(container) + db_handler = AsyncMock() + handler._db_handler = db_handler + handler._initialized = initialized + return handler, db_handler + + +def make_db_result(rows: list[dict[str, object]]) -> MagicMock: + correlation_id = uuid4() + payload = ModelDbQueryPayload(rows=rows, row_count=len(rows)) + response = ModelDbQueryResponse( + status=EnumResponseStatus.SUCCESS, + payload=payload, + correlation_id=correlation_id, + ) + result_wrapper = MagicMock() + result_wrapper.result = response + return result_wrapper + + +def make_minimal_payload( + **overrides: object, +) -> ModelPayloadGatewayLinkHealthUpsert: + defaults: dict[str, object] = { + "tenant_id": "beta-gateway-canary-79afa7263852", + "principal_id": "t-abc123", + "local_transport_flavor": "containerized", + "last_seen_at": datetime.now(UTC), + "reported_status": "active", + "consecutive_failures": 0, + } + defaults.update(overrides) + return ModelPayloadGatewayLinkHealthUpsert(**defaults) + + +class TestHandlerGatewayLinkHealthUpsertComposition: + """HandlerGatewayLinkHealthUpsert composes HandlerDb from `container` alone.""" + + def test_constructor_takes_container_and_optional_dsn(self) -> None: + container = make_mock_container() + + handler = HandlerGatewayLinkHealthUpsert(container) + + assert isinstance(handler._db_handler, HandlerDb) + assert handler._initialized is False + + +class TestHandlerGatewayLinkHealthUpsertInitialization: + """Lazy HandlerDb connection lifecycle.""" + + @pytest.mark.asyncio + async def test_upsert_raises_when_dsn_not_configured(self) -> None: + container = make_mock_container() + handler = HandlerGatewayLinkHealthUpsert(container) + + payload = make_minimal_payload() + with pytest.raises(RuntimeHostError, match="Missing PostgreSQL DSN"): + await handler.upsert(payload) + + @pytest.mark.asyncio + async def test_ensure_db_ready_is_idempotent(self) -> None: + container = make_mock_container() + handler = HandlerGatewayLinkHealthUpsert( + container, db_dsn="postgresql://test-dsn" + ) + handler._db_handler.initialize = AsyncMock() # type: ignore[method-assign] + + await handler._ensure_db_ready() + await handler._ensure_db_ready() + + handler._db_handler.initialize.assert_awaited_once() + + @pytest.mark.asyncio + async def test_shutdown_sets_initialized_false(self) -> None: + handler, db_handler = make_handler_with_mock_db(initialized=True) + assert handler._initialized is True + + await handler.shutdown() + + assert handler._initialized is False + db_handler.shutdown.assert_awaited_once() + + +class TestHandlerGatewayLinkHealthUpsertInsertVsUpdate: + """Insert-vs-update detection via RETURNING (xmax = 0) AS was_insert.""" + + @pytest.mark.asyncio + async def test_first_seen_tenant_is_insert(self) -> None: + handler, db_handler = make_handler_with_mock_db() + db_handler.execute = AsyncMock( + return_value=make_db_result(rows=[{"was_insert": True}]) + ) + + payload = make_minimal_payload() + result = await handler.upsert(payload) + + assert result.success is True + assert result.was_insert is True + assert result.tenant_id == payload.tenant_id + + @pytest.mark.asyncio + async def test_refreshed_tenant_is_update(self) -> None: + handler, db_handler = make_handler_with_mock_db() + db_handler.execute = AsyncMock( + return_value=make_db_result(rows=[{"was_insert": False}]) + ) + + payload = make_minimal_payload() + result = await handler.upsert(payload) + + assert result.success is True + assert result.was_insert is False + + @pytest.mark.asyncio + async def test_upsert_raises_when_no_row_returned(self) -> None: + handler, db_handler = make_handler_with_mock_db() + db_handler.execute = AsyncMock(return_value=make_db_result(rows=[])) + + payload = make_minimal_payload() + with pytest.raises(RuntimeHostError, match="returned no row"): + await handler.upsert(payload) + + +class TestHandlerGatewayLinkHealthUpsertLagFields: + """lag_messages/lag_seconds reach the UPSERT SQL parameters even though + no real producer populates them today (forward-compat).""" + + @pytest.mark.asyncio + async def test_upsert_sends_lag_fields_as_sql_parameters(self) -> None: + handler, db_handler = make_handler_with_mock_db() + db_handler.execute = AsyncMock( + return_value=make_db_result(rows=[{"was_insert": True}]) + ) + payload = make_minimal_payload(lag_messages=12, lag_seconds=3.5) + + await handler.upsert(payload) + + db_handler.execute.assert_awaited_once() + (envelope,), _ = db_handler.execute.call_args + parameters = envelope["payload"]["parameters"] + assert parameters[6] == 12 + assert parameters[7] == 3.5 + + @pytest.mark.asyncio + async def test_upsert_defaults_lag_fields_to_none(self) -> None: + handler, db_handler = make_handler_with_mock_db() + db_handler.execute = AsyncMock( + return_value=make_db_result(rows=[{"was_insert": True}]) + ) + payload = make_minimal_payload() + assert payload.lag_messages is None + assert payload.lag_seconds is None + + await handler.upsert(payload) + + (envelope,), _ = db_handler.execute.call_args + parameters = envelope["payload"]["parameters"] + assert parameters[6] is None + assert parameters[7] is None + + +class TestHandlerGatewayLinkHealthUpsertHandle: + """handle() is the auto-wiring entry point the real dispatch path calls.""" + + @pytest.mark.asyncio + async def test_handle_accepts_dict_shaped_envelope(self) -> None: + handler, db_handler = make_handler_with_mock_db() + db_handler.execute = AsyncMock( + return_value=make_db_result(rows=[{"was_insert": True}]) + ) + payload = make_minimal_payload() + envelope = { + "payload": payload.model_dump(mode="json"), + "correlation_id": str(uuid4()), + } + + output = await handler.handle(envelope) + + assert output.result is not None + assert output.result.success is True + assert output.result.tenant_id == payload.tenant_id + + @pytest.mark.asyncio + async def test_handle_accepts_typed_payload_directly(self) -> None: + handler, db_handler = make_handler_with_mock_db() + db_handler.execute = AsyncMock( + return_value=make_db_result(rows=[{"was_insert": False}]) + ) + payload = make_minimal_payload() + + output = await handler.handle({"payload": payload}) + + assert output.result is not None + assert output.result.was_insert is False + + @pytest.mark.asyncio + async def test_handle_falls_back_to_fresh_correlation_id(self) -> None: + """A missing/malformed correlation_id degrades to a fresh UUID rather + than raising -- link-health refresh is best-effort and must never + drop a row over a bad correlation_id.""" + handler, db_handler = make_handler_with_mock_db() + db_handler.execute = AsyncMock( + return_value=make_db_result(rows=[{"was_insert": True}]) + ) + payload = make_minimal_payload() + + output = await handler.handle( + {"payload": payload, "correlation_id": "not-a-uuid"} + ) + + assert output.correlation_id is not None + + +class TestHandlerGatewayLinkHealthUpsertSelfReportedStatus: + """The self-reported status must reach the row, not stop at the payload. + + The status view derives DEGRADED_SELF_REPORTED from the persisted column, + so a write path that silently dropped these two fields would leave every + degraded edge reading HEALTHY — the exact failure the G2 fields exist to + prevent. + """ + + @pytest.mark.asyncio + async def test_upsert_sends_status_fields_as_sql_parameters(self) -> None: + handler, db_handler = make_handler_with_mock_db() + db_handler.execute = AsyncMock( + return_value=make_db_result(rows=[{"was_insert": True}]) + ) + payload = make_minimal_payload( + reported_status="degraded", consecutive_failures=4 + ) + + await handler.upsert(payload) + + (envelope,), _ = db_handler.execute.call_args + parameters = envelope["payload"]["parameters"] + assert parameters[4] == "degraded" + assert parameters[5] == 4 + + @pytest.mark.asyncio + async def test_upsert_statement_writes_both_status_columns(self) -> None: + handler, db_handler = make_handler_with_mock_db() + db_handler.execute = AsyncMock( + return_value=make_db_result(rows=[{"was_insert": True}]) + ) + + await handler.upsert(make_minimal_payload()) + + (envelope,), _ = db_handler.execute.call_args + sql = envelope["payload"]["sql"] + assert "reported_status" in sql + assert "consecutive_failures" in sql + # Latest-known-state: a refresh must overwrite the previous verdict, + # otherwise a recovered edge would stay degraded forever. + assert "reported_status = EXCLUDED.reported_status" in sql + assert "consecutive_failures = EXCLUDED.consecutive_failures" in sql diff --git a/tests/unit/validation/test_application_database_sql_enforcement_regressions.py b/tests/unit/validation/test_application_database_sql_enforcement_regressions.py index 166ae3c622..9bbf85d758 100644 --- a/tests/unit/validation/test_application_database_sql_enforcement_regressions.py +++ b/tests/unit/validation/test_application_database_sql_enforcement_regressions.py @@ -113,6 +113,26 @@ def test_select_into_emits_an_exact_created_table_identity() -> None: ) == (("tenant", "events_copy", "table"),) +def test_view_case_expression_with_extract_from_is_not_a_relation_target() -> None: + """A SELECT-list EXTRACT(... FROM ...) must not make CASE look like a table.""" + sql = """ + CREATE OR REPLACE VIEW omninode_internal.gateway_link_health_status AS + SELECT + EXTRACT(EPOCH FROM (NOW() - last_seen_at)) AS seconds_since_last_seen, + CASE + WHEN NOW() - last_seen_at > INTERVAL '60 seconds' THEN 'UNHEALTHY' + ELSE 'HEALTHY' + END AS health_status + FROM omninode_internal.gateway_link_health; + """ + + violations = lint_application_database_sql(sql, _TOPOLOGY) + + assert "application relation target 'case' must be schema-qualified" not in ( + "\n".join(violations) + ) + + @pytest.mark.parametrize( ("statement", "expected_locations"), [