Repository navigation
feat(morpho): live governance decisions with shadow and production delivery - #453
Merged
Merged
Conversation
Evaluate every new MorphoGovernanceEvent the way the daily governance jobs would (#448, part of #437), in observe mode by default. - The indexer has no V1 revoke/accept and no V2 Revoke, so an event is a trigger: live_rules reads the vault's current state on-chain (at or after the event block) and runs the daily jobs' own checks on it, so content, severity, grouping, loss alerts and dedup match. An operation revoked or executed before the read is no longer pending. The checks read the daily cache but never write it. - live_store keeps one decision per event revision and the rendered alert requests in morpho_subscriber.db: restarts resume, reorg rewrites re-evaluate, blocks before the first run are recorded as historical. - live_governance runs as a worker consumer, woken after each ingest, so RPC reads and Telegram never block ingestion; failing vaults retry with backoff. MORPHO_LIVE_DELIVERY=shadow posts requests to the errors chat in order with retries, and falls back to observe without it. - Split render_vault_alerts from send_vault_alerts and extract diff_pending_config so both paths share one implementation. - report and health include live decisions and undelivered shadow alerts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 of 7 tasks
Drop tests that only pinned config or query text (Morpho scope labels, stream table/file names, query strings, report titles), duplicated other coverage (per-source HTTP observation, owner seeding already covered by the daily job's tests, SubmitTimelock mock wiring), and fold the shadow downgrade check into the errors-chat test. Consumer and notice tests now use synthetic vault configs and labels instead of entries in config.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Production mode (#450 groundwork): MORPHO_LIVE_DELIVERY=production sends live decisions to the Morpho channel. Per vault, under a governance_lock file lock that the daily V1/V2 jobs now take too, it evaluates, sends, and only then commits the daily jobs' cache writes, so each change is alerted once by whichever path sees it first. Shadow is now the default delivery mode (observe without an errors chat). Review fixes: - Read the daily jobs' Morpho cache namespace (MORPHO_FILENAME from the daily profile in jobs.yaml) before utils.cache loads; refuse to start the consumer if another namespace was bound. - A failed adapter realAssets() read fails the evaluation and retries instead of reading as an empty adapter (which hid loss alerts). - Exclude backed-off vaults before the batch limit, so a failing vault's backlog cannot starve other vaults. - Store source-event revisions with each request and invalidate pending shadow requests whose events a reorg removed or rewrote. - Bound the shadow header (first 5 event ids) and split the final text to Telegram's limit; send unescaped plain text straight to the errors chat. - send_telegram_message / send_alert return whether Telegram accepted the message; shadow and production treat a skipped send (missing credentials) as not delivered and retry. - Cap the backoff exponent via backoff_delay to avoid OverflowError. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A response without data, with data null, or without the entity list was
read as an empty page (`or {}` / `or []`). Reconciliation then marked
every active event in the range removed and moved the checkpoint past
it; with the Morpho live consumer that drops undecided events from
evaluation and invalidates their queued alerts.
entity_rows now requires data to be an object and the entity (or
chain_metadata) to be a list of row objects, and raises EnvioQueryError
otherwise, so the cycle fails without touching event status or the
checkpoint. An explicit [] is still a valid empty range. WebSocket
snapshots use the same check and reconnect instead of ingesting nothing.
Reported in the #437 review (comment 6394048679).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #448. Part of #437. Builds on the named event streams from #452.
What
For every newly indexed
MorphoGovernanceEvent, themorphostream now decides what the daily governance jobs would alert, records it durably, and delivers it according toMORPHO_LIVE_DELIVERY:shadow(default)[morpho-live-shadow]. Without an errors chat it only records (observe), so it never reaches the Morpho group.observeproductionIn
shadowandobserve, the dailygovernance.py/governance_v2.pyjobs keep alerting the Morpho group exactly as before.Why on-chain confirmation
The indexer (yearn-envio#71) has no V1 revoke/accept/set events and no V2
Revoke, so events alone can't tell a revoked operation from a pending one. Each event is therefore a trigger.live_rulesreads the vault's current state on-chain, at a block at or after the event; if the RPC node is behind, it retries. It then runs the daily jobs' own check functions on that state:SubmitCap,SubmitMarketRemovalSubmitTimelockSubmitexecutableAt(data)must still be set, thendiff_pending_config(loss alerts) andalert_pending_new(grouping)SetOwner,SetCuratordiff_single_roleAddAdapter,RemoveAdapterdiff_set("adapters")AcceptSo content, severity, per-vault grouping and loss classification match the daily jobs. An operation that was revoked or executed before the read isn't pending anymore, so it isn't alerted. The checks read the daily cache (an operation the daily job already alerted isn't alerted again) but never write it. V2 state comes from chain reads (owner, curator, adapters,
realAssets,totalAssets, the asset's metadata), because the Morpho API lags behind the chain.Durability and delivery
live_storeaddsmorpho_live_decisions(one row per event revision) andmorpho_live_alerts(rendered messages with mode and status) inmorpho_subscriber.db. A restart resumes undecided events. A reorg that rewrites an event bumps its revision, so the event is evaluated again.live_baseline_at) are recorded ashistoricaland never evaluated. Events indexed while the worker was down are still evaluated.live_governanceruns as a workerStreamConsumerthat is woken after each ingest, with a 5s polling fallback. RPC reads and Telegram calls never delay ingestion, and a consumer that dies withholds the watchdog ping.report --streams morphoadds a decision summary (outcomes, block-to-decision p50/p95, request statuses).healthfails when events stay undecided, or shadow alerts stay undelivered, for more than 10 minutes.render_vault_alertsis split out ofsend_vault_alerts, anddiff_pending_configis extracted from_diff_pending. Helpers the live path reuses are now public:vault_header,diff_single_role,diff_set,alert_pending_new,load_market_governance_states. The daily behavior is unchanged.Production mode and the shared lock
productionfollows the daily jobs' contract. Per vault, it holdsgovernance_lock(aflockfile inCACHE_DIR,morpho_governance_<vault>.lock) while it evaluates, sends every message, and only then commits the daily jobs' cache writes. The daily V1/V2 jobs now take the same lock around their check → send → commit, so the two processes can't both treat one change as new. Each change is alerted once, by whichever path sees it first, and the daily jobs keep catching whatever the live path missed. A failed send commits nothing; the events are re-evaluated and re-sent on retry, so you might get a duplicate but never a missed alert.Review fixes
MORPHO_FILENAME=cache-id-daily.txt, but the subscriber readcache-id.txt.daily_governance_cache.pynow applies the daily profile's value fromjobs.yamlbeforeutils.cacheloads, and the consumer refuses to start if a different namespace was bound.realAssets()read now fails the evaluation and is retried. Before, it read as an empty adapter, which could turn a potential-loss HIGH into a plain MEDIUM.invalidatedinstead of sent.send_telegram_messageandsend_alertnow return whether Telegram accepted the message. Shadow and production treat a skipped send (missing credentials) as not delivered and retry it. Existing callers ignore the return value, so nothing else changes.backoff_delay, so a long outage can't raiseOverflowError.Fail closed on malformed Envio responses (#437 comment)
A response with no
data, withdata: null, or with a missing ornullentity list used to read as an empty page (or {}/or []). Reconciliation then marked every active event in the rangeremovedand moved the checkpoint past it. Once this PR's consumer runs, that would drop undecided Morpho events from evaluation and invalidate their queued alerts. The daily jobs would still catch them, but late.entity_rowsnow requiresdatato be an object and the entity (orchain_metadata) to be a list of row objects, and raisesEnvioQueryErrorotherwise. The cycle fails with event status and the checkpoint untouched. An explicit[]is still a valid empty range. WebSocket snapshots use the same check and reconnect instead of ingesting nothing.New tests replay the reviewer's scenario (active event at block 12, checkpoint 15, overlap 5, head 20) with
{},{"data": null},{"data": {}},{"data": {"TimelockEvent": null}}, a non-list, a non-object row, and a non-object response. Each leaves the event active and the checkpoint at 15. Against the old lenient parsing, all 10 malformed-response tests fail. A real[]still completes the range.Testing
ruffandtypass. Full suite: 2081 passed, 5 failed. The failures also happen on main and come from the local setup: 4TestSafeApiQuotatests fail with a realSAFE_API_KEYin.env, andtest_sd_notify_sends_datagramfails withAF_UNIX path too longon macOS.tests/test_morpho_live_governance.py(33 tests; also covers production send → commit ordering under the lock, failure without commit, the daily V2 job taking the lock, the file lock itself, and a regression test for each review fix, including a subprocess check that the real entrypoint bindscache-id-daily.txt):Acceptleft to the daily job.realAssetssums matchtotalAssets. No operation is pending today, so the result is correctly "nothing new".removeAdapterfor its funded adapter, then ran the real consumer end to end in shadow mode, with sending captured instead of posted. It produced the daily job's exact🚨 POTENTIAL VAULT LOSSHIGH message (amount at risk, 100% of vault, executable in 7 days, revoke hint) and marked itsent. Afterrevoke,executableAtreads 0, which the rules treat as not pending.🤖 Generated with Claude Code