Skip to content

Phase 2 PR1: Regional state-change alerts from diff engine#2966

Merged
koala73 merged 2 commits into
mainfrom
feat/regional-intelligence-alerts
Apr 11, 2026
Merged

Phase 2 PR1: Regional state-change alerts from diff engine#2966
koala73 merged 2 commits into
mainfrom
feat/regional-intelligence-alerts

Conversation

@koala73

@koala73 koala73 commented Apr 11, 2026

Copy link
Copy Markdown
Owner

Summary

Phase 2 PR1 of the Regional Intelligence Model. Wires the existing `diffRegionalSnapshot` output into the existing `wm:events:queue` so the notification relay on Railway routes state changes to Telegram / email / Slack / Convex for Pro subscribers. Server-only; no UI changes.

What landed

New module: `scripts/regional-snapshot/alert-emitter.mjs`

Single public entry point:

```js
emitRegionalAlerts(region, snapshot, diff, { publishEvent? })
-> { enqueued: number, events: object[] }
```

Emits on 4 event types:

Event Source Default severity
`regional_regime_shift` `diff.regime_changed` `high` (`critical` when target is `escalation_ladder` or `fragmentation_risk`)
`regional_trigger_activation` one per entry in `diff.trigger_activations` `high`
`regional_corridor_break` one per entry in `diff.corridor_breaks` `critical` (always)
`regional_buffer_failure` one per entry in `diff.buffer_failures` `high`

`scenario_jumps` and `leverage_shifts` are intentionally NOT emitted — probability fluctuations are noisy and not actionable as push alerts.

Dedup

6h TTL on `wm:notif:scan-dedup:{eventType}:{hash}`, matching the cron cadence and mirroring `ais-relay.cjs` `publishNotificationEvent`'s path-based Upstash REST calls.

Best-effort contract

`emitRegionalAlerts` NEVER throws. Three layers of guarding:

  1. Null/undefined arguments short-circuit to `{ enqueued: 0, events: [] }`
  2. Each publisher call is individually `try`/`catch`'d so one failure can't block other events in the same region
  3. The seed writer wraps the call in its own `try`/`catch` so alert emission can never block snapshot persist

This matches how `ais-relay.cjs` handles `rss_alert` events today.

`seed-regional-snapshots.mjs` hook

After `persistSnapshot` succeeds in `main()`, the diff already computed in step 15 is passed to `emitRegionalAlerts`. Logs per-region alert counts:

`[mena] alerts: 3/3 enqueued`

Zero impact on the persist path when the diff is empty (steady state between state changes).

Testability via dependency-injected publisher

`opts.publishEvent` is the single injection point. Default is the Upstash REST path-based publisher that walks SET NX → LPUSH. Unit tests inject a mock that captures events in an array, so the full diff → events → dedup pipeline runs offline without touching Redis.

Testing

25 new unit tests (`tests/regional-snapshot-alerts.test.mjs`), all running offline via the injected publisher:

  • `buildAlertEvents` (13): empty diff → [], regime shift high vs critical, 'none' fallback when previous regime is empty, one event per trigger/break/failure, `scenario_jumps` + `leverage_shifts` excluded, stable output order, defensive null/undefined, region.label carried into titles

  • `buildDedupKey` + `simpleHash` (6): key shape matches ais-relay convention, deterministic output, collision-free across titles and event types, missing-title safety

  • `emitRegionalAlerts` (6): no-op on empty diff, per-event enqueue, publisher-returns-false only counts successes, publisher-throws is swallowed + loop continues, null/undefined guard, end-to-end realistic escalation scenario enqueues all 6 event types with correct severities

  • `npm run test:data`: 4377/4377 pass

  • `npm run typecheck` + `typecheck:api`: clean

  • `biome lint` on touched files: clean

Dependency on notification-relay

The relay already handles generic events with a `[SEVERITY] title` fallback formatter. No relay-side changes are required — the new event types flow through the same default path as `rss_alert`. If we later want custom templates per regional event type, that's a follow-up to `scripts/notification-relay.cjs`.

Post-Deploy Monitoring & Validation

  • What to monitor/search
    • Railway `derived-signals` bundle logs for `[alerts]` lines per region. `queued` lines indicate new events; `dedup skip` indicates repeats within the 6h window.
    • Railway `notification-relay` logs for `Queued`/`Processing event` lines with the new event types (`regional_regime_shift`, etc.).
    • Upstash monitor: `wm:events:queue` list length should stay near 0 under steady state (relay drains promptly).
  • Validation checks
    • After merge + first cron cycle: `redis-cli LLEN wm:events:queue` should be ≤ small N.
    • `redis-cli KEYS 'wm:notif:scan-dedup:regional_*'` should show dedup keys from any emitted events.
    • Pro subscriber with a Telegram channel subscribed to `regional_*` event types should receive a message on the first state change.
  • Expected healthy behavior
    • Steady state (no diffs): zero `queued` log lines, zero `regional_*` dedup keys accumulating.
    • On a genuine state change: one event per change flows through the relay within ~30 seconds of the cron run.
  • Failure signal(s) / rollback trigger
    • `[alerts] LPUSH failed` or `publish failed` warn lines for >2 consecutive cycles → Upstash connectivity issue, investigate.
    • Alert events enqueued but not delivered after 5 minutes → relay drain issue, check `notification-relay` logs.
    • Snapshot persist rate regresses → alert emitter throwing despite the try/catch, investigate immediately (should be impossible).
  • Validation window & owner
    • 12 hours post-merge (covers 2 full cron cycles). Owner: @koala73.

PR sequence

@vercel

vercel Bot commented Apr 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Apr 11, 2026 8:23pm

Request Review

@greptile-apps

greptile-apps Bot commented Apr 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR wires the existing diffRegionalSnapshot output into wm:events:queue so state-change events reach the notification relay for Pro subscribers. The architecture is clean — pure builders, injected publisher, three-layer best-effort guard, and 25 offline unit tests — but defaultPublishEvent diverges from the ais-relay.cjs reference in one reliability-critical way: it does not roll back the 6-hour dedup key when LPUSH fails, meaning a transient Upstash write error silently suppresses the alert for the entire cron window.

  • P1 (alert-emitter.mjs lines 244–249): Missing DEL rollback on LPUSH failure — a transient error causes the alert to be silently deduplicated for the full 6h TTL, matching neither the documented behaviour nor the ais-relay.cjs reference (upstashDel(dedupKey).catch(() => {})).

Confidence Score: 4/5

Safe to merge after addressing the LPUSH rollback gap — the P1 reliability issue won't cause data loss or crashes, but will silently drop state-change alerts for 6h on any transient Upstash write error.

One P1 finding: missing dedup key rollback on LPUSH failure diverges from the ais-relay.cjs reference and can cause alerts to be silently suppressed for a full cron cycle on transient errors. The rest of the implementation is sound — pure builders, correct best-effort layering, good test coverage, and clean integration into the seed loop. The P2 hash comment inaccuracy does not affect correctness today.

scripts/regional-snapshot/alert-emitter.mjs — the defaultPublishEvent function needs a DEL rollback on LPUSH failure (lines 244–249).

Important Files Changed

Filename Overview
scripts/regional-snapshot/alert-emitter.mjs New module wiring diff output to wm:events:queue; missing dedup key rollback on LPUSH failure diverges from the ais-relay.cjs reference and silently suppresses alerts for the full 6h TTL window on transient errors.
scripts/seed-regional-snapshots.mjs Alert emission hook added inside the persisted branch; correctly wrapped in a try/catch so it can never block the persist path.
tests/regional-snapshot-alerts.test.mjs 25 unit tests covering pure builders, dedup key shape, publisher injection, and an end-to-end escalation scenario; all path-logic exercised offline.

Sequence Diagram

sequenceDiagram
    participant Cron as Railway Cron (6h)
    participant Seed as seed-regional-snapshots.mjs
    participant Diff as diffRegionalSnapshot()
    participant Emitter as alert-emitter.mjs
    participant Redis as Upstash Redis
    participant Relay as notification-relay.cjs
    participant Sub as Pro Subscriber

    Cron->>Seed: trigger
    Seed->>Seed: computeSnapshot(region)
    Seed->>Diff: diffRegionalSnapshot(prev, tentative)
    Diff-->>Seed: SnapshotDiff
    Seed->>Seed: persistSnapshot(snapshot)
    Note over Seed: Only if result.persisted=true
    Seed->>Emitter: emitRegionalAlerts(region, snapshot, diff)
    Emitter->>Emitter: buildAlertEvents() → events[]
    loop per event
        Emitter->>Redis: SET NX wm:notif:scan-dedup:{type}:{hash} EX 21600
        alt new event (SET NX → OK)
            Emitter->>Redis: LPUSH wm:events:queue {eventType, payload, severity, publishedAt}
            Redis-->>Emitter: list length (ok=true)
        else dedup hit
            Redis-->>Emitter: null (ok=false → skip)
        end
    end
    Emitter-->>Seed: {enqueued, events}
    Redis->>Relay: BRPOP wm:events:queue
    Relay->>Sub: Telegram / email / Slack / Convex
Loading

Reviews (1): Last reviewed commit: "feat(intelligence): state-change alerts ..." | Re-trigger Greptile

Comment on lines +244 to +249
if (ok) {
const title = String(event.payload?.title ?? '');
console.log(`[alerts] queued ${event.severity} ${event.eventType}: ${title.slice(0, 60)}`);
} else {
console.warn(`[alerts] LPUSH failed for ${event.eventType}`);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing dedup key rollback on LPUSH failure

When upstashLpush returns false (transient Upstash error), the SET NX dedup key has already been written with a 6-hour TTL. Because there's no rollback, every subsequent cron cycle within that window will treat the event as a dedup hit and skip it — the alert is silently lost for the full 6h window. The reference implementation in ais-relay.cjs explicitly rolls back with upstashDel(dedupKey).catch(() => {}) for exactly this reason (line 397 of ais-relay.cjs).

Suggested change
if (ok) {
const title = String(event.payload?.title ?? '');
console.log(`[alerts] queued ${event.severity} ${event.eventType}: ${title.slice(0, 60)}`);
} else {
console.warn(`[alerts] LPUSH failed for ${event.eventType}`);
}
const ok = await upstashLpush(url, token, 'wm:events:queue', msg);
if (ok) {
const title = String(event.payload?.title ?? '');
console.log(`[alerts] queued ${event.severity} ${event.eventType}: ${title.slice(0, 60)}`);
} else {
console.warn(`[alerts] LPUSH failed for ${event.eventType} — rolling back dedup key`);
fetch(`${url}/del/${encodeURIComponent(dedupKey)}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(5_000),
}).catch(() => {});
}

Comment on lines +173 to +179
export function simpleHash(str) {
let h = 0;
for (let i = 0; i < str.length; i += 1) {
h = ((h << 5) - h + str.charCodeAt(i)) | 0;
}
return (h >>> 0).toString(36);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Hash output diverges from notifySimpleHash despite the compatibility comment

The comment says this matches notifySimpleHash in ais-relay.cjs, but the two functions produce different values for the same input whenever the accumulated hash h is negative: ais-relay.cjs uses Math.abs(h).toString(36) (magnitude of signed int), while this function uses (h >>> 0).toString(36) (unsigned 32-bit reinterpretation). For example, h = -1 yields '1' vs 'zzzzzzzz'. Since these are different event-type namespaces there's no dedup collision today, but the comment is misleading and the algorithm label "FNV-1a-ish" is also incorrect — the actual algorithm is djb2 (31*h + c).

Suggested change
export function simpleHash(str) {
let h = 0;
for (let i = 0; i < str.length; i += 1) {
h = ((h << 5) - h + str.charCodeAt(i)) | 0;
}
return (h >>> 0).toString(36);
}
export function simpleHash(str) {
let h = 0;
for (let i = 0; i < str.length; i += 1) {
h = (Math.imul(31, h) + str.charCodeAt(i)) | 0;
}
return Math.abs(h).toString(36);
}

koala73 added a commit that referenced this pull request Apr 11, 2026
…iew)

P1 review finding on PR #2966. The default publisher used to SET NX the
dedup key and then return false on LPUSH failure without removing it.
Any transient Upstash or network hiccup therefore suppressed the alert
for the full 6h dedup window even though nothing was enqueued — real
state-change alerts could silently vanish for a quarter of a day.

Fix: mirror the rollback path in scripts/ais-relay.cjs
publishNotificationEvent() — when LPUSH fails after SET NX succeeded,
DEL the dedup key so the next cron cycle can retry cleanly.

## Refactor for testability

Extracted publishEventWithOps(event, ops) as the pure orchestrator with
an injected Redis-ops interface ({ setNx, lpush, del }). Returns an
outcome object { enqueued, dedupHit, rolledBack } so tests can assert
exactly which branch fired without log scraping. Default publisher is
now a thin wrapper that binds real Upstash REST calls and delegates.

## Tests — 6 new regression tests

tests/regional-snapshot-alerts.test.mjs:
  - happy path: SET NX + LPUSH both succeed, nothing rolled back
  - dedup hit: dedupHit=true without touching the queue
  - LPUSH failure: rolledBack=true, DEL called, dedup key gone
  - retry-after-rollback: next call enqueues normally (the core bug)
  - LPUSH failure + DEL failure: still reports rolledBack=true (best-effort)
  - exceptions from setNx/lpush are swallowed, return non-enqueued outcome

## Verification

- node --test tests/regional-snapshot-alerts.test.mjs: 31/31 pass
- npm run test:data: 4383/4383 pass (was 4377; +6 regression tests)
- npm run typecheck: clean
- biome lint on touched files: clean
koala73 added 2 commits April 12, 2026 00:18
Phase 2 PR1 of the Regional Intelligence Model. Wires the existing
diffRegionalSnapshot output into the existing wm:events:queue so the
notification-relay on Railway can route state changes to Telegram / email /
Slack / Convex for Pro subscribers. No UI changes.

Single public entry point:

  emitRegionalAlerts(region, snapshot, diff, { publishEvent? })
    -> { enqueued: number, events: object[] }

Emits on 4 event types:

  regional_regime_shift       — diff.regime_changed set
  regional_trigger_activation — one per entry in diff.trigger_activations
  regional_corridor_break     — one per entry in diff.corridor_breaks
  regional_buffer_failure     — one per entry in diff.buffer_failures

scenario_jumps and leverage_shifts are intentionally NOT emitted —
probability fluctuations are noisy and not actionable as push alerts.

Severity mapping:
  - critical when regime shifts to escalation_ladder or fragmentation_risk
  - critical for every corridor_break
  - high for other regime shifts, trigger activations, buffer failures
  - nothing below high is emitted

Dedup: 6h TTL on wm:notif:scan-dedup:{eventType}:{hash}, matching the cron
cadence and mirroring ais-relay.cjs publishNotificationEvent's path-based
Upstash REST calls.

emitRegionalAlerts NEVER throws. Three layers of guarding:
  1. Null/undefined arguments short-circuit to { enqueued: 0, events: [] }
  2. Each publisher call is individually try/catch'd so one failure can't
     block other events in the same region
  3. The seed writer wraps the call in its own try/catch so alert emission
     can never block snapshot persist

This matches how ais-relay.cjs handles rss_alert events today.

After persistSnapshot succeeds in main(), the diff already computed in
step 15 is passed to emitRegionalAlerts. Logs per-region alert counts:

  [mena] alerts: 3/3 enqueued

Zero impact on the persist path when the diff is empty (steady state
between state changes).

opts.publishEvent is the single injection point. Default is the Upstash
REST path-based publisher that walks SET NX -> LPUSH. Unit tests inject
a mock that captures events in an array, so the full diff -> events ->
dedup pipeline runs offline without touching Redis.

tests/regional-snapshot-alerts.test.mjs covers:

  buildAlertEvents (13):
    - empty diff -> []
    - regime shift high vs critical (escalation_ladder / fragmentation_risk)
    - 'none' fallback when previous regime is empty
    - one event per trigger activation / corridor break / buffer failure
    - scenario_jumps + leverage_shifts excluded
    - stable output order: regime -> triggers -> corridors -> buffers
    - null/undefined defensive returns
    - region.label carried into titles

  buildDedupKey + simpleHash (6):
    - key shape matches ais-relay convention
    - deterministic for same input
    - different titles -> different hashes
    - same title, different eventType -> different hashes
    - missing payload.title handled safely

  emitRegionalAlerts (6):
    - no-op on empty diff (publisher never called)
    - per-event enqueue through injected publisher
    - publisher returning false only counts successes
    - publisher throwing is swallowed, loop continues
    - null/undefined arguments return empty result
    - realistic escalation scenario enqueues all 6 event types correctly

- npm run test:data: 4377/4377 pass
- npm run typecheck: clean
- npm run typecheck:api: clean
- biome lint on touched files: clean

The relay already handles generic events with a `[SEVERITY] title` fallback
formatter. No relay-side changes are required — the new event types flow
through the same default path as rss_alert. If we later want custom
templates per regional event type, that's a follow-up to
scripts/notification-relay.cjs.
…iew)

P1 review finding on PR #2966. The default publisher used to SET NX the
dedup key and then return false on LPUSH failure without removing it.
Any transient Upstash or network hiccup therefore suppressed the alert
for the full 6h dedup window even though nothing was enqueued — real
state-change alerts could silently vanish for a quarter of a day.

Fix: mirror the rollback path in scripts/ais-relay.cjs
publishNotificationEvent() — when LPUSH fails after SET NX succeeded,
DEL the dedup key so the next cron cycle can retry cleanly.

## Refactor for testability

Extracted publishEventWithOps(event, ops) as the pure orchestrator with
an injected Redis-ops interface ({ setNx, lpush, del }). Returns an
outcome object { enqueued, dedupHit, rolledBack } so tests can assert
exactly which branch fired without log scraping. Default publisher is
now a thin wrapper that binds real Upstash REST calls and delegates.

## Tests — 6 new regression tests

tests/regional-snapshot-alerts.test.mjs:
  - happy path: SET NX + LPUSH both succeed, nothing rolled back
  - dedup hit: dedupHit=true without touching the queue
  - LPUSH failure: rolledBack=true, DEL called, dedup key gone
  - retry-after-rollback: next call enqueues normally (the core bug)
  - LPUSH failure + DEL failure: still reports rolledBack=true (best-effort)
  - exceptions from setNx/lpush are swallowed, return non-enqueued outcome

## Verification

- node --test tests/regional-snapshot-alerts.test.mjs: 31/31 pass
- npm run test:data: 4383/4383 pass (was 4377; +6 regression tests)
- npm run typecheck: clean
- biome lint on touched files: clean
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant