Skip to content

Grafana-sourced shame history: uptime streaks + auto node-version#867

Merged
JoaquinBN merged 13 commits into
devfrom
feat/grafana-shame-history-streaks
Jul 2, 2026
Merged

Grafana-sourced shame history: uptime streaks + auto node-version#867
JoaquinBN merged 13 commits into
devfrom
feat/grafana-shame-history-streaks

Conversation

@rasca

@rasca rasca commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

What & why

Adds per-validator days-in-shame history and a consecutive "not shamed" uptime streak (per node and per network, Asimov + Bradbury), plus folds node-version tracking into the Grafana sync so upgrades are detected and rewarded automatically. Everything the portal needs that Grafana can provide (metrics/logs reporting, node version) now comes from Grafana; on-chain status/operator/stake/identity still come from the existing RPC sync (unchanged).

Built as 4 self-contained, independently-reviewable commits (safe → risky):

  1. Configurable version grace + extract helperTargetNodeVersion.grace_days (editable, default from new NODE_VERSION_SHAME_GRACE_DAYS); version verdict extracted to validators/version_status.py. Pure refactor + one field.
  2. Record daily observability history — new append-only ValidatorWalletObservation; the daily snapshot becomes a latched worst-of-day rollup (metrics/logs/version + sample counters + node_version); Prometheus query now reads the version label; rebuild_daily_snapshots command. No points/behaviour change.
  3. Show not-shamed uptime streaksvalidators/streaks.py; wall-of-shame returns clean_streak_days / clean_streak_broken_by per node, plus per-operator-per-network network_streaks using any-node-clean roll-up (a network-day is clean if ≥1 node was clean). Read-only over commit 2's data.
  4. Detect node upgrades from Grafana and auto-award — auto-create a TargetNodeVersion from the first stable higher release, write each operator's version, and directly award the node-upgrade contribution (4/3/2/1 early bonus). Dedups against the manual flow so no double-award. Only commit that changes points.

Decisions baked in

  • Strict clean day: shamed if shamed at any sample that day; clean requires ≥1 metrics AND ≥1 logs sample and no shame on any dimension (metrics/logs/version); on-chain active is the gate.
  • Any-node-clean operator roll-up per network.
  • Auto-target: first stable release only (pre-release/build ignored); auto-approve upgrade points.
  • History can't be backfilled (never recorded) → streaks accumulate from deploy; surfaced as since.

Notes / caveats

  • The Grafana sync cron is scheduled */5 but GitHub throttles it to ~1–3h in practice; the "≥1 sample/day" rule is chosen to suit that cadence.
  • The genlayer_node_info metric reports v-prefixed versions the profile field forbids — normalized (strip v) before storing.
  • New migrations: node_upgrade 0004 (grace_days), validators 0015 (observation model + rollup columns).

Testing

python manage.py test validators — 68 pass (56 directly exercise this feature: version-helper parity + configurable grace, observation/rollup latch, streak + any-node roll-up, version-label parse, auto-target stable-only guard, auto-award dedup/points). Pre-existing contributions test failures (discord XP, daily-uptime, featured-content) are an unrelated Python 3.14 test-seeding collision — they fail identically on a clean dev checkout.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Automatic node version detection with read-only display in profiles and validator settings.
    • Enhanced Wall of Shame observability, including clean uptime consecutive-day streaks with break reasons and per-network streak summaries.
    • Added daily observability rollups driven by Grafana sync.
    • Admin-configurable grace period for when older versions are flagged (default: 3 days).
  • Bug Fixes
    • Improved version-health verdicting and daily “latching” so metrics/logs/version statuses stay stable across repeated syncs.
  • Chores / Tests
    • Added a daily rollup rebuild command and expanded test coverage for Grafana parsing/sync, streaks, and version status/grace behavior.
    • Enforced read-only PATCH /api/v1/validators/me/ (returns 405).

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds configurable node-version grace timing, Grafana-driven version syncing and daily observability rollups, clean-streak computation, and read-only node-version handling across backend and frontend profile paths.

Changes

Node version grace period, observability history, and streaks

Layer / File(s) Summary
Grace period and version status
backend/tally/settings.py, backend/validators/version_status.py, backend/validators/tests/test_version_status.py, backend/CLAUDE.md
Adds configurable grace timing and shared version verdict computation with tests and docs.
Grafana history rollups
backend/validators/grafana_service.py, backend/validators/models.py, backend/validators/migrations/0015_validatorwalletstatussnapshot_logs_samples_and_more.py, backend/validators/management/commands/rebuild_daily_snapshots.py, backend/validators/tests/test_grafana_service.py, backend/validators/genlayer_validators_service.py, backend/CLAUDE.md
Parses Grafana version labels, records raw observations and daily rollups, and adds the rebuild command and history tests.
Node version sync and awards
backend/validators/grafana_service.py, backend/validators/node_version.py, backend/validators/tests/test_node_version_sync.py, backend/validators/tests/test_node_version_tracking.py, backend/CLAUDE.md
Auto-syncs detected node versions into targets and operators, awards node-upgrade contributions, and removes the old save-time submission path with matching tests and docs.
Clean streaks and Wall of Shame
backend/validators/streaks.py, backend/validators/serializers.py, backend/validators/views.py, backend/validators/tests/test_streaks.py, backend/validators/tests/test_api.py, backend/users/serializers.py, frontend/src/routes/ProfileEdit.svelte, backend/CLAUDE.md, frontend/CLAUDE.md, CHANGELOG.md
Computes per-wallet and per-operator clean streaks, exposes them in Wall of Shame responses, and makes profile node-version fields read-only across API and frontend.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately captures the main changes: Grafana-sourced streak/history reporting and automatic node-version tracking.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/grafana-shame-history-streaks

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/validators/views.py (1)

906-938: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Reduce redundant scanning in the per-operator network-streak rollup.

For each group, the code re-scans the entire operator_network_wallet_ids dict (built across all operators) just to filter by op_key. This is O(groups × total operator-network pairs) instead of O(total pairs). It also contributes to the "too many branches" complexity flagged for _build_validator_groups. Pre-group the wallet ids by operator key once, outside the for group in groups.values() loop.

♻️ Proposed fix
+        wallet_ids_by_operator = {}
+        for (op_key, net), wallet_ids in operator_network_wallet_ids.items():
+            wallet_ids_by_operator.setdefault(op_key, {})[net] = wallet_ids
+
         for group in groups.values():
             ...
-            network_streaks = {}
-            for (op_key, net), wallet_ids in operator_network_wallet_ids.items():
-                if op_key != group['id']:
-                    continue
-                network_streaks[net] = streaks_lib.clean_streak(
-                    wallet_ids, now, snapshot_index
-                )
+            network_streaks = {
+                net: streaks_lib.clean_streak(wallet_ids, now, snapshot_index)
+                for net, wallet_ids in wallet_ids_by_operator.get(group['id'], {}).items()
+            }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/validators/views.py` around lines 906 - 938, The per-operator
network-streak rollup in _build_validator_groups is repeatedly scanning the full
operator_network_wallet_ids mapping for every group, which is unnecessary and
adds complexity. Pre-group operator_network_wallet_ids by op_key once before the
for group in groups.values() loop, then have the network_streaks build use the
pre-indexed wallet IDs for the current group’s id instead of filtering the whole
dict each time. Keep the existing clean_streak logic, group['network_streaks']
shape, and network_order-based sorting unchanged.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/validators/grafana_service.py`:
- Around line 462-472: The dedup logic in `_award_node_upgrade` is vulnerable to
a TOCTOU race because `already_awarded` and `pending` are checked with
`.exists()` before the new contribution is persisted. Move the dedup decision
and insert into a single atomic flow in `_award_node_upgrade` (for example by
using a transaction plus a uniqueness guard or other write-time constraint) so
overlapping sync runs cannot both pass the check and award the same `dedup_key`
twice.
- Around line 392-443: The outer try/except in the node version sync path is too
broad and lets one operator failure stop the rest of the network processing.
Keep the target auto-create logic in the existing `sync` flow, but move the
per-operator update and award work inside its own isolated try/except within the
`for operator, versions in by_operator.items()` loop so one bad `parse_version`
or `_award_node_upgrade` call does not block other operators. Preserve the
current warning logging in `grafana_service.py` by logging the specific operator
and continuing the loop after a failure.
- Around line 308-375: The `_record_history` read-then-write rollup in
`ValidatorWalletStatusSnapshot` is vulnerable to lost updates when concurrent
syncs overlap. Fix it by removing the Python-side merge based on the stale
`existing` snapshot and moving the sample/status accumulation to an atomic
database update path inside `_record_history` (or equivalent per-wallet
transaction/locking), so `metrics_samples`, `logs_samples`, and the latched
status fields are merged from the current row state rather than overwritten by a
concurrent run. Ensure the `ValidatorWalletObservation` insert remains
best-effort, but the snapshot upsert must be concurrency-safe.
- Around line 417-433: The `node_version_<network>` update in
`grafana_service.py` can regress because `by_operator` only considers wallets
present in `normalized`, so a missing wallet can make `highest` lower than the
value already stored. In the wallet-processing loop that updates
`Validator.objects.filter(pk=operator.pk).update(...)`, compare the newly
computed `highest` against the current operator field and only write when the
new version is newer, not merely different. Use the existing `parse_version`
logic and the `field`/`operator` update path to keep the stored version
monotonic.

In `@backend/validators/management/commands/rebuild_daily_snapshots.py`:
- Around line 31-36: The `handle` method in `rebuild_daily_snapshots` treats
`days` as a truthy check, so `--days 0` skips the cutoff filter and behaves like
no filter. Update the conditional in `handle` to explicitly check whether
`options.get('days')` is not None, so `0` still applies the `observed_at__gte`
filter while only a missing value bypasses it.
- Around line 90-93: The summary message in rebuild_daily_snapshots currently
calls observations.count() after the queryset has already been streamed via
iterator(), causing an extra full scan; update the command’s loop to track the
number of processed observations in a local counter while iterating, then use
that counter in the final self.stdout.write message. Keep the fix localized to
the rebuild_daily_snapshots command and the observations/rollups processing
flow.
- Around line 14-19: The command is importing internal helpers from
grafana_service.py, which should be moved to a shared module instead of relying
on private implementation details. Extract _latch, _latch_version,
_METRICS_SEVERITY, and _VERSION_SEVERITY into a common shared location, update
rebuild_daily_snapshots.py to import them from there, and keep
grafana_service.py as a consumer of the shared helpers.

In `@backend/validators/models.py`:
- Around line 166-198: Add a retention/archival strategy for
ValidatorWalletObservation so the append-only table does not grow without bound.
Update the observation lifecycle in/around ValidatorWalletObservation and the
snapshot rebuild flow (especially rebuild_daily_snapshots) to either prune or
archive rows older than the chosen retention window, and make sure any new
cleanup job is scheduled consistently with the Grafana sync cadence.
- Around line 141-155: Add help_text to the new status/version fields on
ValidatorWalletStatusSnapshot and ValidatorWalletObservation so they match the
model’s existing documentation style. Update the definitions for metrics_status,
logs_status, version_status, and node_version to include concise
intent-explaining help_text values, keeping the wording consistent with nearby
fields like show_in_overview and assets_under_management_usd. Use the field
names in these two model classes as the places to fix.

---

Outside diff comments:
In `@backend/validators/views.py`:
- Around line 906-938: The per-operator network-streak rollup in
_build_validator_groups is repeatedly scanning the full
operator_network_wallet_ids mapping for every group, which is unnecessary and
adds complexity. Pre-group operator_network_wallet_ids by op_key once before the
for group in groups.values() loop, then have the network_streaks build use the
pre-indexed wallet IDs for the current group’s id instead of filtering the whole
dict each time. Keep the existing clean_streak logic, group['network_streaks']
shape, and network_order-based sorting unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e3aa7893-aea9-4672-afdb-2e5de44b9703

📥 Commits

Reviewing files that changed from the base of the PR and between 2feac71 and 21849f1.

📒 Files selected for processing (18)
  • CHANGELOG.md
  • backend/CLAUDE.md
  • backend/contributions/node_upgrade/admin.py
  • backend/contributions/node_upgrade/migrations/0004_targetnodeversion_grace_days.py
  • backend/contributions/node_upgrade/models.py
  • backend/tally/settings.py
  • backend/validators/grafana_service.py
  • backend/validators/management/commands/rebuild_daily_snapshots.py
  • backend/validators/migrations/0015_validatorwalletstatussnapshot_logs_samples_and_more.py
  • backend/validators/models.py
  • backend/validators/serializers.py
  • backend/validators/streaks.py
  • backend/validators/tests/test_grafana_service.py
  • backend/validators/tests/test_node_version_sync.py
  • backend/validators/tests/test_streaks.py
  • backend/validators/tests/test_version_status.py
  • backend/validators/version_status.py
  • backend/validators/views.py

Comment thread backend/validators/grafana_service.py Outdated
Comment on lines +308 to +375
@classmethod
def _record_history(cls, samples, now):
"""
Persist the raw observations for this sync run and latch them into today's
per-day rollup (worst-of-day). Never raises: history is best-effort and must
not break the live status sync.
"""
if not samples:
return
try:
today = timezone.localdate(now)

ValidatorWalletObservation.objects.bulk_create([
ValidatorWalletObservation(
wallet=s['wallet'],
observed_at=now,
onchain_status=s['onchain_status'],
metrics_status=s['metrics_status'],
logs_status=s['logs_status'],
version_status=s['version_status'],
node_version=s['node_version'],
)
for s in samples
])

wallet_ids = [s['wallet'].id for s in samples]
existing = {
snap.wallet_id: snap
for snap in ValidatorWalletStatusSnapshot.objects.filter(
wallet_id__in=wallet_ids, date=today
)
}

rollups = []
for s in samples:
wallet = s['wallet']
prev = existing.get(wallet.id)
prev_metrics = prev.metrics_status if prev else 'unknown'
prev_logs = prev.logs_status if prev else 'unknown'
prev_version = prev.version_status if prev else 'unknown'
prev_m_samples = prev.metrics_samples if prev else 0
prev_l_samples = prev.logs_samples if prev else 0

rollups.append(ValidatorWalletStatusSnapshot(
wallet=wallet,
date=today,
status=wallet.status,
metrics_status=_latch(prev_metrics, s['metrics_status'], _METRICS_SEVERITY),
logs_status=_latch(prev_logs, s['logs_status'], _METRICS_SEVERITY),
version_status=_latch(prev_version, s['version_status'], _VERSION_SEVERITY),
node_version=s['node_version'] or (prev.node_version if prev else ''),
metrics_samples=prev_m_samples + (1 if s['metrics_ok'] else 0),
logs_samples=prev_l_samples + (1 if s['logs_ok'] else 0),
))

# On insert, `status` is set from the wallet; on conflict only the
# observability columns update, so the on-chain sync's `status` is preserved.
ValidatorWalletStatusSnapshot.objects.bulk_create(
rollups,
update_conflicts=True,
unique_fields=['wallet', 'date'],
update_fields=[
'metrics_status', 'logs_status', 'version_status',
'node_version', 'metrics_samples', 'logs_samples',
],
)
except Exception as exc: # pragma: no cover - defensive
logger.warning("Failed to record validator observation history: %s", exc)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Read-then-write rollup update is racy under concurrent sync runs.

_record_history reads existing snapshots (existing = {...}) and computes metrics_samples/logs_samples/latched status in Python from that stale snapshot, then upserts via bulk_create(update_conflicts=True). If two sync runs for the same network/day overlap (retry, manual trigger overlapping cron, etc.), both read the same prev state and the second upsert clobbers the first's contribution — losing a sample count / latch update rather than merging it. Given this directly feeds the new clean-streak/days-in-shame reporting, a lost update here silently corrupts the history the whole feature is built on.

🧰 Tools
🪛 Ruff (0.15.20)

[warning] 309-309: Missing return type annotation for classmethod _record_history

Add return type annotation: None

(ANN206)


[warning] 374-374: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/validators/grafana_service.py` around lines 308 - 375, The
`_record_history` read-then-write rollup in `ValidatorWalletStatusSnapshot` is
vulnerable to lost updates when concurrent syncs overlap. Fix it by removing the
Python-side merge based on the stale `existing` snapshot and moving the
sample/status accumulation to an atomic database update path inside
`_record_history` (or equivalent per-wallet transaction/locking), so
`metrics_samples`, `logs_samples`, and the latched status fields are merged from
the current row state rather than overwritten by a concurrent run. Ensure the
`ValidatorWalletObservation` insert remains best-effort, but the snapshot upsert
must be concurrency-safe.

Comment thread backend/validators/grafana_service.py Outdated
Comment thread backend/validators/grafana_service.py Outdated
Comment thread backend/validators/grafana_service.py Outdated
Comment on lines +14 to +19
from validators.grafana_service import (
_METRICS_SEVERITY,
_VERSION_SEVERITY,
_latch,
)
from validators.models import ValidatorWalletObservation, ValidatorWalletStatusSnapshot

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n "_LOGS_SEVERITY|_METRICS_SEVERITY|logs_status.*_latch|_latch\(" backend/validators/grafana_service.py

Repository: genlayer-foundation/points

Length of output: 452


🏁 Script executed:

#!/bin/bash
sed -n '1,220p' backend/validators/management/commands/rebuild_daily_snapshots.py
printf '\n--- grafana_service ---\n'
sed -n '1,420p' backend/validators/grafana_service.py

Repository: genlayer-foundation/points

Length of output: 20408


Extract shared latch helpers Move _latch, _latch_version, and the severity maps into a shared module so this command doesn’t depend on grafana_service.py internals.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/validators/management/commands/rebuild_daily_snapshots.py` around
lines 14 - 19, The command is importing internal helpers from
grafana_service.py, which should be moved to a shared module instead of relying
on private implementation details. Extract _latch, _latch_version,
_METRICS_SEVERITY, and _VERSION_SEVERITY into a common shared location, update
rebuild_daily_snapshots.py to import them from there, and keep
grafana_service.py as a consumer of the shared helpers.

Comment thread backend/validators/management/commands/rebuild_daily_snapshots.py
Comment thread backend/validators/management/commands/rebuild_daily_snapshots.py
Comment thread backend/validators/models.py
Comment on lines +166 to +198
class ValidatorWalletObservation(BaseModel):
"""
Append-only log of a single Grafana-sync observation for a validator wallet.

One row is written per active wallet per Grafana sync run, capturing the
point-in-time observability verdict plus the on-chain status and the node
version reported to Prometheus. This is the raw source of truth from which the
daily ValidatorWalletStatusSnapshot rollup is materialised (and rebuildable).
"""
wallet = models.ForeignKey(
ValidatorWallet,
on_delete=models.CASCADE,
related_name='observations'
)
observed_at = models.DateTimeField(db_index=True)
onchain_status = models.CharField(max_length=20, choices=ValidatorWallet.STATUS_CHOICES)
metrics_status = models.CharField(max_length=10, choices=ValidatorWallet.GRAFANA_STATUS_CHOICES)
logs_status = models.CharField(max_length=10, choices=ValidatorWallet.GRAFANA_STATUS_CHOICES)
version_status = models.CharField(
max_length=10, choices=ValidatorWalletStatusSnapshot.VERSION_STATUS_CHOICES, default='unknown'
)
node_version = models.CharField(max_length=50, blank=True)

class Meta:
ordering = ['-observed_at']
indexes = [
models.Index(fields=['wallet', 'observed_at']),
]

def __str__(self):
return f"{self.wallet.address[:10]}... @ {self.observed_at:%Y-%m-%d %H:%M} ({self.metrics_status}/{self.logs_status})"


Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🔵 Trivial

Consider a retention/archival plan for ValidatorWalletObservation.

This table gets one row per active wallet per Grafana sync run (every ~5 minutes per the cron description elsewhere). With no pruning job visible in this cohort, it will grow unbounded (e.g. ~100 wallets × 12/hr × 24 × 365 ≈ 10M+ rows/year), which will eventually slow down rebuild_daily_snapshots (full table scan) and inflate storage.

🧰 Tools
🪛 ast-grep (0.44.0)

[info] 180-180: use help_text to document model columns
Context: models.CharField(max_length=20, choices=ValidatorWallet.STATUS_CHOICES)
Note: [CWE-710] Improper Adherence to Coding Standards.

(model-help-text)


[info] 181-181: use help_text to document model columns
Context: models.CharField(max_length=10, choices=ValidatorWallet.GRAFANA_STATUS_CHOICES)
Note: [CWE-710] Improper Adherence to Coding Standards.

(model-help-text)


[info] 182-182: use help_text to document model columns
Context: models.CharField(max_length=10, choices=ValidatorWallet.GRAFANA_STATUS_CHOICES)
Note: [CWE-710] Improper Adherence to Coding Standards.

(model-help-text)


[info] 183-185: use help_text to document model columns
Context: models.CharField(
max_length=10, choices=ValidatorWalletStatusSnapshot.VERSION_STATUS_CHOICES, default='unknown'
)
Note: [CWE-710] Improper Adherence to Coding Standards.

(model-help-text)


[info] 186-186: use help_text to document model columns
Context: models.CharField(max_length=50, blank=True)
Note: [CWE-710] Improper Adherence to Coding Standards.

(model-help-text)

🪛 Ruff (0.15.20)

[warning] 190-190: Mutable default value for class attribute

(RUF012)


[warning] 191-193: Mutable default value for class attribute

(RUF012)


[warning] 195-195: Missing return type annotation for special method __str__

Add return type annotation: str

(ANN204)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/validators/models.py` around lines 166 - 198, Add a
retention/archival strategy for ValidatorWalletObservation so the append-only
table does not grow without bound. Update the observation lifecycle in/around
ValidatorWalletObservation and the snapshot rebuild flow (especially
rebuild_daily_snapshots) to either prune or archive rows older than the chosen
retention window, and make sure any new cleanup job is scheduled consistently
with the Grafana sync cadence.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
CHANGELOG.md (1)

13-13: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the “upcoming uptime-streak” wording.

Line 11 already says clean-uptime streak reporting ships in this release, so calling it “upcoming” here makes the Unreleased notes contradictory. Reword this bullet to scope it to future days-in-shame reporting or to the daily-history foundation only.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` at line 13, Update the changelog entry in CHANGELOG.md to
remove the contradictory “upcoming uptime-streak” wording; keep the bullet
scoped to the daily-history foundation and/or future days-in-shame reporting
only. Edit the existing unreleased note text so it no longer implies
uptime-streak is forthcoming, while preserving the validator observability
history summary.
♻️ Duplicate comments (5)
backend/validators/grafana_service.py (4)

432-443: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not let node_version_<network> regress on partial observations.

by_operator only includes wallets that reported a valid version in this run. If an operator’s newest wallet is temporarily missing, highest is computed from an incomplete set and the != guard writes an older version back to the operator row. Compare against the currently stored field and only update when the newly observed version parses newer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/validators/grafana_service.py` around lines 432 - 443, The
node_version_<network> update in grafana_service.py can regress because
by_operator only contains wallets with a valid version in the current run, so
the max() result may be older than the value already stored on the Validator.
Update the loop around by_operator/highest and
Validator.objects.filter(...).update(...) to compare the newly observed version
against the operator’s existing field value, and only write when the new parsed
version is newer rather than merely different.

472-481: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Deduplicate the automatic award at write time.

The .exists() checks and contribution.save() are separate operations, so overlapping syncs can both pass the dedup checks and create the same approved contribution twice. This needs a single atomic decision+insert path backed by a uniqueness guard.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/validators/grafana_service.py` around lines 472 - 481, The dedup
logic in the award path is not atomic, so concurrent runs can both pass the
`.exists()` checks and create duplicate approved contributions. Update the write
path around the `Contribution`/`SubmittedContribution` checks and the
`contribution.save()` in `grafana_service.py` to use a single atomic
decision-and-insert flow, backed by a uniqueness guard on the identifying fields
such as `user`, `contribution_type`, and the `dedup_key`/notes marker.

402-453: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Isolate failures per operator during version sync.

Any exception inside the for operator, versions in by_operator.items() loop is still swallowed by the outer except, which stops version updates and awards for every remaining operator on that network for this cycle. Catch/log inside the loop and continue so one bad operator does not block the whole network.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/validators/grafana_service.py` around lines 402 - 453, The version
sync logic in grafana_service.py currently wraps the entire normalization and
operator-processing flow in one broad try/except, so a single failure in the
by_operator loop can stop updates and awards for every remaining operator. Move
the exception handling into the per-operator processing path inside the loop
that iterates over by_operator.items(), using the same logger warning style
there, and continue to the next operator so one bad record does not block the
whole network. Keep the surrounding validation/target creation flow intact and
ensure _award_node_upgrade and the direct Validator.objects.update path are
still used per operator.

343-382: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make the daily rollup update atomic.

existing is read once, then sample counts and latched statuses are recomputed in Python before bulk_create(update_conflicts=True) writes them back. Two overlapping syncs for the same wallet/day can start from the same snapshot and the later write will clobber the earlier run’s increments/status transition, silently corrupting the history that streaks and shame reporting depend on.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/validators/grafana_service.py` around lines 343 - 382, The daily
rollup in grafana_service.py is doing a read-modify-write cycle outside a
transaction, so overlapping syncs can clobber `metrics_samples`, `logs_samples`,
and latched statuses. Make the rollup path atomic around the `existing` snapshot
lookup and `ValidatorWalletStatusSnapshot.objects.bulk_create(...)`, using the
`samples` loop and `_latch`/`_latch_version` calculations under a transaction or
equivalent locking so concurrent runs serialize correctly. Ensure the upsert
logic in this daily snapshot aggregation preserves incremented counts and status
transitions when multiple workers process the same wallet/date.
backend/validators/management/commands/rebuild_daily_snapshots.py (1)

32-36: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle --days 0 explicitly.

if days: still treats 0 as falsy, so --days 0 rebuilds all history instead of applying the cutoff.

Suggested fix
-        if days:
+        if days is not None:
             cutoff = timezone.now() - timedelta(days=days)
             observations = observations.filter(observed_at__gte=cutoff)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/validators/management/commands/rebuild_daily_snapshots.py` around
lines 32 - 36, The `rebuild_daily_snapshots` command currently uses a truthy
check on `days`, so `--days 0` is treated the same as omitting the option and
rebuilds all history. Update the `days` handling in `rebuild_daily_snapshots` to
explicitly check for `None` rather than relying on truthiness, and keep applying
the `cutoff` filter on `observations` whenever `days` is provided, including
zero.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/CLAUDE.md`:
- Around line 249-251: The backend reference for `validators/views.py` is
outdated: `/api/v1/validators/` should no longer be described as CRUD for all
authenticated users. Update the `validators` API entry to reflect that
authenticated users have read-only access while `create`, `update`,
`partial_update`, and `destroy` are staff-only and return 403 for non-staff.
Keep the `/api/v1/validators/me/` note unchanged unless its behavior also
differs, and make sure the wording matches the current `Validator` viewset
permissions and mutation contract.

In `@frontend/CLAUDE.md`:
- Line 441: The documentation reference uses the wrong route component name,
which will send maintainers to the wrong file. Update the mention currently
pointing to EditProfile.svelte so it matches the actual component filename,
ProfileEdit.svelte, in the affected CLAUDE.md sections. Use the route component
name consistently anywhere this edit-profile docs reference appears.

---

Outside diff comments:
In `@CHANGELOG.md`:
- Line 13: Update the changelog entry in CHANGELOG.md to remove the
contradictory “upcoming uptime-streak” wording; keep the bullet scoped to the
daily-history foundation and/or future days-in-shame reporting only. Edit the
existing unreleased note text so it no longer implies uptime-streak is
forthcoming, while preserving the validator observability history summary.

---

Duplicate comments:
In `@backend/validators/grafana_service.py`:
- Around line 432-443: The node_version_<network> update in grafana_service.py
can regress because by_operator only contains wallets with a valid version in
the current run, so the max() result may be older than the value already stored
on the Validator. Update the loop around by_operator/highest and
Validator.objects.filter(...).update(...) to compare the newly observed version
against the operator’s existing field value, and only write when the new parsed
version is newer rather than merely different.
- Around line 472-481: The dedup logic in the award path is not atomic, so
concurrent runs can both pass the `.exists()` checks and create duplicate
approved contributions. Update the write path around the
`Contribution`/`SubmittedContribution` checks and the `contribution.save()` in
`grafana_service.py` to use a single atomic decision-and-insert flow, backed by
a uniqueness guard on the identifying fields such as `user`,
`contribution_type`, and the `dedup_key`/notes marker.
- Around line 402-453: The version sync logic in grafana_service.py currently
wraps the entire normalization and operator-processing flow in one broad
try/except, so a single failure in the by_operator loop can stop updates and
awards for every remaining operator. Move the exception handling into the
per-operator processing path inside the loop that iterates over
by_operator.items(), using the same logger warning style there, and continue to
the next operator so one bad record does not block the whole network. Keep the
surrounding validation/target creation flow intact and ensure
_award_node_upgrade and the direct Validator.objects.update path are still used
per operator.
- Around line 343-382: The daily rollup in grafana_service.py is doing a
read-modify-write cycle outside a transaction, so overlapping syncs can clobber
`metrics_samples`, `logs_samples`, and latched statuses. Make the rollup path
atomic around the `existing` snapshot lookup and
`ValidatorWalletStatusSnapshot.objects.bulk_create(...)`, using the `samples`
loop and `_latch`/`_latch_version` calculations under a transaction or
equivalent locking so concurrent runs serialize correctly. Ensure the upsert
logic in this daily snapshot aggregation preserves incremented counts and status
transitions when multiple workers process the same wallet/date.

In `@backend/validators/management/commands/rebuild_daily_snapshots.py`:
- Around line 32-36: The `rebuild_daily_snapshots` command currently uses a
truthy check on `days`, so `--days 0` is treated the same as omitting the option
and rebuilds all history. Update the `days` handling in
`rebuild_daily_snapshots` to explicitly check for `None` rather than relying on
truthiness, and keep applying the `cutoff` filter on `observations` whenever
`days` is provided, including zero.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 538e1e88-ef95-4019-8d34-3d60046f5337

📥 Commits

Reviewing files that changed from the base of the PR and between 21849f1 and ec3d7a0.

📒 Files selected for processing (10)
  • CHANGELOG.md
  • backend/CLAUDE.md
  • backend/users/serializers.py
  • backend/validators/grafana_service.py
  • backend/validators/management/commands/rebuild_daily_snapshots.py
  • backend/validators/tests/test_api.py
  • backend/validators/tests/test_grafana_service.py
  • backend/validators/views.py
  • frontend/CLAUDE.md
  • frontend/src/routes/ProfileEdit.svelte

Comment thread backend/CLAUDE.md
Comment thread frontend/CLAUDE.md
@rasca rasca force-pushed the feat/grafana-shame-history-streaks branch from ec3d7a0 to c11151b Compare July 1, 2026 21:06
The version-shame window on the Wall of Shame is no longer hardcoded to
three days. It now reads a NODE_VERSION_SHAME_GRACE_DAYS setting (default
three days, env-overridable), so the grace period can be tuned per
environment without a code change. The version verdict logic also moves out
of the wallet viewset into a shared version_status helper — with an explicit
node_version override for callers that already know the running version — so
the same rule can be reused by the Grafana-driven sync added later in this
branch.

## Claude Implementation Notes
- backend/validators/version_status.py: New compute_version_status(wallet, target, now, node_version=...) — extracted from the viewset; grace from settings.NODE_VERSION_SHAME_GRACE_DAYS via default_grace_days(); node_version param lets the Grafana sync pass the observed version (compares via NodeVersionMixin._compare_versions, no operator required)
- backend/validators/views.py: _version_context now delegates to the helper; removed hardcoded VERSION_SHAME_GRACE_DAYS constant and unused timedelta import
- backend/tally/settings.py: Add NODE_VERSION_SHAME_GRACE_DAYS (env-overridable, default 3, applied globally at evaluation time)
- backend/validators/tests/test_version_status.py: Unit tests — parity + grace configurable via the setting
- backend/CLAUDE.md, CHANGELOG.md: Document the setting and the shared helper

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/validators/views.py (1)

3-4: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Avoid rescanning operator_network_wallet_ids inside _build_validator_groups

network_streaks is rebuilt by iterating over every (op_key, net) entry for each group, which makes this quadratic in the number of groups/wallets. Precompute the per-operator/per-network streaks once and reuse them here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/validators/views.py` around lines 3 - 4, The
`_build_validator_groups` path is rescanning `operator_network_wallet_ids`
repeatedly when rebuilding `network_streaks`, making the grouping logic
unnecessarily quadratic. Refactor the code in the validator grouping flow to
precompute the per-operator/per-network streak data once, then reuse that cached
structure inside `_build_validator_groups` instead of iterating over every
`(op_key, net)` pair for each group.
♻️ Duplicate comments (5)
backend/validators/models.py (2)

141-155: 📐 Maintainability & Code Quality | 🔵 Trivial

Missing help_text on new observability fields (still unresolved).

metrics_status, logs_status, version_status, node_version (and the same fields on ValidatorWalletObservation, lines 180-187) still lack help_text, unlike other fields in this file (show_in_overview, assets_under_management_usd). This was already flagged in a prior review pass and remains unaddressed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/validators/models.py` around lines 141 - 155, Add help_text to the
new observability fields in the model definitions: metrics_status, logs_status,
version_status, and node_version in the daily summary model, and mirror the same
update on the corresponding fields in ValidatorWalletObservation. Follow the
existing style used by fields like show_in_overview and
assets_under_management_usd, and make sure the new help_text clearly describes
each field’s purpose without changing behavior.

Source: Linters/SAST tools


166-198: 🚀 Performance & Scalability | 🔵 Trivial

No retention/archival plan for the append-only ValidatorWalletObservation table.

One row is written per active wallet per ~5-minute Grafana sync, so this table grows unbounded and will eventually slow rebuild_daily_snapshots (full scan) and inflate storage. This was already raised in a prior review pass and remains unaddressed here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/validators/models.py` around lines 166 - 198, The append-only
ValidatorWalletObservation table currently has no retention or archival
strategy, so it will grow without bound and impact rebuild_daily_snapshots and
storage. Add a cleanup/retention mechanism for ValidatorWalletObservation, such
as a scheduled purge or archival job, and define a clear retention window for
old rows. Use the ValidatorWalletObservation model and its observed_at field to
identify records eligible for retention, and ensure the snapshot rebuild path
still works with the retained data.
backend/validators/grafana_service.py (3)

521-531: 🗄️ Data Integrity & Integration | 🟠 Major

Dedup check-then-create in _award_node_upgrade is still a TOCTOU race.

already_awarded/pending are checked via .exists() and only later does contribution.save() persist the row. Two overlapping sync runs reaching an operator's target version in the same window could both pass the dedup check before either writes, double-awarding the contribution (double leaderboard points). This was flagged previously and remains unresolved.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/validators/grafana_service.py` around lines 521 - 531, The
`_award_node_upgrade` dedup logic still has a check-then-create race between the
`already_awarded`/`pending` `.exists()` queries and the later
`contribution.save()`. Make the award creation atomic by moving the dedup guard
into the write path in `_award_node_upgrade` using a transactional approach and
a uniqueness-enforced lookup on the dedup key (for example via an idempotent
create/get-or-create pattern tied to `dedup_key`), so overlapping sync runs
cannot award the same upgrade twice.

472-486: 🎯 Functional Correctness | 🟠 Major

node_version_<network> can still regress when one of an operator's wallets doesn't report this cycle.

by_operator is built only from wallets present in normalized (line 473-476), so highest = max(versions, key=parse_version) (line 481) is computed only from wallets that reported this run. If an operator has multiple wallets and one has a transient reporting gap, highest can be lower than the already-stored version, and since the write is gated on != (not "newer than"), the field silently regresses — flipping an already-upgraded operator back to warning/shame on the public Wall of Shame via compute_version_status's fallback read. This was flagged previously and is still present.

🐛 Proposed fix: never let the stored version regress
             field = f'node_version_{network}'
             for operator, versions in by_operator.items():
                 try:
-                    highest = max(versions, key=parse_version)
+                    current = getattr(operator, field, None) or ''
+                    candidates = versions + ([current] if _SEMVER_RE.match(current) else [])
+                    highest = max(candidates, key=parse_version)
                     if getattr(operator, field, None) != highest:
                         Validator.objects.filter(pk=operator.pk).update(**{field: highest})
                         setattr(operator, field, highest)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/validators/grafana_service.py` around lines 472 - 486, The operator
version update in the wallet aggregation path can still regress when some
wallets are missing from the current run. In the logic around by_operator,
highest, and the Validator.objects.filter(...).update step, compare the computed
version against the already stored node_version_<network> and only write it when
the new value is newer, not merely different. Keep the update in the same loop,
but guard against downgrades so compute_version_status cannot fall back to an
older stored version.

348-415: 🩺 Stability & Availability | 🟠 Major

Read-then-write rollup update is still racy under concurrent sync runs.

_record_history reads existing snapshots into existing, computes latched status/sample counts in Python, then upserts via bulk_create(update_conflicts=True). Two overlapping sync runs for the same wallet/day (retry, overlapping cron/manual trigger) both read the same prev state and the second upsert clobbers the first's contribution, silently losing a sample count or un-latching a shame verdict. This directly corrupts the data the new clean-streak/days-in-shame reporting is built on. This issue was raised previously and remains unresolved.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/validators/grafana_service.py` around lines 348 - 415, _make
_record_history concurrency-safe by removing the read-then-write rollup pattern
that can lose updates when overlapping sync runs touch the same wallet/day. In
ValidatorWalletService._record_history, do not compute latched statuses and
sample totals from an in-memory `existing` snapshot; instead update
ValidatorWalletStatusSnapshot atomically in the database within a transaction,
using DB-side increments/merge semantics for the daily snapshot keyed by
`(wallet, date)`. Keep the same latching behavior for `_latch` and
`_latch_version`, but ensure repeated upserts cannot clobber another run’s
metrics_samples, logs_samples, or status transitions._
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/validators/streaks.py`:
- Around line 48-58: The _has_observation helper in streaks.py is missing
version rollup state, so version-only snapshots are treated as unobserved.
Update _has_observation(snap) to also consider snap.version_status when deciding
whether a snap counts as observed, alongside the existing metrics/logs sample
and status checks. Keep the change localized to _has_observation so version-only
snapshots like a shame state are not skipped and broken_by tracking can include
version.

In `@backend/validators/tests/test_streaks.py`:
- Around line 76-80: The current streak test only covers a shame version
alongside metrics/logs still marked on, so it misses the version-only case.
Extend test_version_shame_breaks_streak in test_streaks.py to add a regression
scenario where _snap() records version='shame' while metrics/logs are unknown
and sample counts are zero, then assert _streak() still reports a broken streak
with 'version' in broken_by.

In `@backend/validators/views.py`:
- Around line 895-916: The per-group network streak build in
_build_validator_groups is rescanning operator_network_wallet_ids for every
group, causing quadratic work on the hot path. Pre-group the wallet IDs by
operator key once before the group loop, then have the network_streaks
construction read only that operator’s precomputed entries instead of filtering
the full dict each time. Keep the existing streak calculation and sorting
behavior intact, but use the pre-grouped structure to simplify the branching in
_build_validator_groups.

---

Outside diff comments:
In `@backend/validators/views.py`:
- Around line 3-4: The `_build_validator_groups` path is rescanning
`operator_network_wallet_ids` repeatedly when rebuilding `network_streaks`,
making the grouping logic unnecessarily quadratic. Refactor the code in the
validator grouping flow to precompute the per-operator/per-network streak data
once, then reuse that cached structure inside `_build_validator_groups` instead
of iterating over every `(op_key, net)` pair for each group.

---

Duplicate comments:
In `@backend/validators/grafana_service.py`:
- Around line 521-531: The `_award_node_upgrade` dedup logic still has a
check-then-create race between the `already_awarded`/`pending` `.exists()`
queries and the later `contribution.save()`. Make the award creation atomic by
moving the dedup guard into the write path in `_award_node_upgrade` using a
transactional approach and a uniqueness-enforced lookup on the dedup key (for
example via an idempotent create/get-or-create pattern tied to `dedup_key`), so
overlapping sync runs cannot award the same upgrade twice.
- Around line 472-486: The operator version update in the wallet aggregation
path can still regress when some wallets are missing from the current run. In
the logic around by_operator, highest, and the
Validator.objects.filter(...).update step, compare the computed version against
the already stored node_version_<network> and only write it when the new value
is newer, not merely different. Keep the update in the same loop, but guard
against downgrades so compute_version_status cannot fall back to an older stored
version.
- Around line 348-415: _make _record_history concurrency-safe by removing the
read-then-write rollup pattern that can lose updates when overlapping sync runs
touch the same wallet/day. In ValidatorWalletService._record_history, do not
compute latched statuses and sample totals from an in-memory `existing`
snapshot; instead update ValidatorWalletStatusSnapshot atomically in the
database within a transaction, using DB-side increments/merge semantics for the
daily snapshot keyed by `(wallet, date)`. Keep the same latching behavior for
`_latch` and `_latch_version`, but ensure repeated upserts cannot clobber
another run’s metrics_samples, logs_samples, or status transitions._

In `@backend/validators/models.py`:
- Around line 141-155: Add help_text to the new observability fields in the
model definitions: metrics_status, logs_status, version_status, and node_version
in the daily summary model, and mirror the same update on the corresponding
fields in ValidatorWalletObservation. Follow the existing style used by fields
like show_in_overview and assets_under_management_usd, and make sure the new
help_text clearly describes each field’s purpose without changing behavior.
- Around line 166-198: The append-only ValidatorWalletObservation table
currently has no retention or archival strategy, so it will grow without bound
and impact rebuild_daily_snapshots and storage. Add a cleanup/retention
mechanism for ValidatorWalletObservation, such as a scheduled purge or archival
job, and define a clear retention window for old rows. Use the
ValidatorWalletObservation model and its observed_at field to identify records
eligible for retention, and ensure the snapshot rebuild path still works with
the retained data.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e9d808e8-1302-4666-ab12-38d9b988923a

📥 Commits

Reviewing files that changed from the base of the PR and between ec3d7a0 and 3ae689a.

📒 Files selected for processing (21)
  • CHANGELOG.md
  • backend/CLAUDE.md
  • backend/tally/settings.py
  • backend/users/serializers.py
  • backend/validators/grafana_service.py
  • backend/validators/management/commands/rebuild_daily_snapshots.py
  • backend/validators/migrations/0015_validatorwalletstatussnapshot_logs_samples_and_more.py
  • backend/validators/models.py
  • backend/validators/node_version.py
  • backend/validators/serializers.py
  • backend/validators/streaks.py
  • backend/validators/tests/test_api.py
  • backend/validators/tests/test_grafana_service.py
  • backend/validators/tests/test_node_version_sync.py
  • backend/validators/tests/test_node_version_tracking.py
  • backend/validators/tests/test_streaks.py
  • backend/validators/tests/test_version_status.py
  • backend/validators/version_status.py
  • backend/validators/views.py
  • frontend/CLAUDE.md
  • frontend/src/routes/ProfileEdit.svelte
💤 Files with no reviewable changes (1)
  • backend/validators/node_version.py

Comment thread backend/validators/streaks.py
Comment thread backend/validators/tests/test_streaks.py
Comment thread backend/validators/views.py
rasca added 3 commits July 1, 2026 22:18
Every Grafana status sync now captures a per-run observation for each active
validator wallet (on-chain status, metrics, logs, and the node version read
from the Prometheus `version` label) and latches it into a per-day rollup on
the existing daily snapshot. Metrics and logs latch pessimistically — shamed
at any point means the day is shamed — while version latches optimistically:
once a node has upgraded during a day, an earlier stale reading cannot shame
it. Per-day sample counters record whether the node was seen reporting at all,
the building blocks for uptime-streak and days-in-shame reporting.

Version labels are normalised at ingest ('v' prefix stripped, capped to the
column length, and when a node briefly reports two series right after an
upgrade the higher parseable one wins), so bad node-reported data can never
corrupt or abort a whole network's history. The rollup is fully rebuildable
from the raw observation log, whose rows are retained forever by explicit
decision. History writing is best-effort and isolated from the live status
update, so a failure there never corrupts the Wall of Shame status. No points
or public API behaviour changes in this step.

## Claude Implementation Notes
- backend/validators/models.py: New ValidatorWalletObservation (append-only raw log); extend ValidatorWalletStatusSnapshot with metrics_status/logs_status/version_status, node_version, metrics_samples/logs_samples
- backend/validators/grafana_service.py: PromQL adds the `version` label; parse_response returns a 4-tuple with version_by_address (normalised via _normalize_version, capped to _VERSION_MAX_LENGTH, higher parseable version wins on duplicate series via _safe_parse); sync_network computes per-wallet version_status via compute_version_status; _record_history writes observations + latched rollup (worst-of-day _latch for metrics/logs, best-of-day _latch_version for version)
- backend/validators/management/commands/rebuild_daily_snapshots.py: New command to re-materialise rollups from observations; --days N cutoff snapped to the local-day boundary so the oldest day is never rebuilt from partial observations
- backend/validators/migrations/0015_*: New model + snapshot columns
- backend/validators/tests/test_grafana_service.py: version-label parse (normalised), duplicate-series keeps-higher, overlong-label truncation, observation/rollup writes, both latch directions, no-observations-on-failure, rebuild day-boundary regression
- backend/CLAUDE.md, CHANGELOG.md: Document the observation log, rollup columns, latch directions, rebuild command, and retain-forever decision
The Wall of Shame now surfaces how long each validator has gone without being
shamed. Every wallet reports a consecutive clean-day streak and the reasons the
streak was last broken, and each operator gets a per-network streak using
any-node-clean roll-up: a network-day counts as clean if at least one of the
operator's nodes was healthy that day. Streaks are computed on read from the
daily observability rollup, so they cost one extra snapshot query per request
(the endpoint stays cached 60s) and start accumulating from deploy.

## Claude Implementation Notes
- backend/validators/streaks.py: New module. clean_streak(wallet_ids, now, index) walks the daily rollup backward counting consecutive clean days (any-node-clean over the given wallet ids); clean day = active + >=1 metrics & logs sample + no shame dim. A partial today never breaks the streak; broken_by only attributes a reason for observed days (edge-of-history returns []). load_snapshot_index prefetches the window in one query.
- backend/validators/views.py: wall_of_shame builds the snapshot index once and per-wallet streaks, passes them to the serializer via context and into _build_validator_groups; groups gain network_streaks (per-network any-node-clean) and each node entry gains clean_streak_days / clean_streak_broken_by
- backend/validators/serializers.py: WallOfShameSerializer adds clean_streak_days + clean_streak_broken_by (from context, no N+1)
- backend/validators/tests/test_streaks.py: streak counting, shame/gap/version breaks, unsynced-today, any-node-clean operator roll-up
- backend/validators/tests/test_grafana_service.py: endpoint exposes streak fields + network_streaks
- backend/CLAUDE.md, CHANGELOG.md: Document the streak fields
Grafana becomes the single source of truth for validator node versions. The
status sync reads each node's reported version and: promotes the fleet's
highest stable release to the active upgrade target the first time it is seen
(ignoring pre-release and build-tagged versions), keeps each operator's
recorded version in step with what their nodes actually run, and awards the
node-upgrade contribution directly — with the existing sooner-is-better
bonus — the moment a visible operator reaches the target, with no manual
submission or steward review.

Detection covers every reporting node regardless of on-chain status, so a
quarantined validator that upgrades still records it and earns the award.
Versions that packaging cannot parse are excluded from comparisons instead of
aborting the run, and one operator's failure never blocks version updates or
awards for the rest. Because versions are now observed rather than
self-reported, the portal stops accepting manual edits: the profile shows the
detected version read-only, the two backend write paths are closed, and the
old save()-driven pending-submission flow is removed. Dedup on the shared
notes key guarantees nothing is ever awarded twice.

## Claude Implementation Notes
- backend/validators/grafana_service.py: new _sync_node_versions({address_lower: version}) — matches wallets of ANY on-chain status, filters to semver-valid AND PEP 440-parseable versions, auto-creates TargetNodeVersion from the highest stable observed (never blindly supersedes an unparseable active target), writes node_version_<network> via Validator.objects.update() (max across the operator's nodes), per-operator try/except fault isolation; _award_node_upgrade creates a direct approved Contribution (early-bonus 4/3/2/1, dedup on `version {v} [{network}]`, multiplier fallback via _allow_missing_multiplier)
- backend/validators/node_version.py: remove NodeVersionMixin.save() and _create_upgrade_submission (dead once the portal can't write versions); keep fields, validation, comparison helpers, calculate_early_upgrade_bonus
- backend/users/serializers.py: UserProfileUpdateSerializer drops the writable node_version fields and the custom update()
- backend/validators/views.py: ValidatorViewSet.my_profile is GET-only (PATCH → 405)
- frontend/src/routes/ProfileEdit.svelte: node version inputs replaced with read-only display ("Not detected yet" fallback, auto-detected hint); removed the related state/change-tracking/save logic
- backend/validators/tests/test_node_version_sync.py: auto-target stable-only guard, supersede/no-op cases, max-across-nodes, single award + dedup, invisible-operator no-award, quarantined-wallet award, PEP 440-invalid isolation, one-failing-operator isolation
- backend/validators/tests/test_node_version_tracking.py, test_api.py: drop save()-driven submission tests; /validators/me PATCH asserts read-only
- backend/CLAUDE.md, frontend/CLAUDE.md, CHANGELOG.md: document Grafana as source of truth and the read-only portal surface
@rasca rasca force-pushed the feat/grafana-shame-history-streaks branch from 3ae689a to aa67bde Compare July 2, 2026 01:20

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (2)
backend/validators/streaks.py (1)

48-58: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Still missing version_status in _has_observation — regresses broken_by reporting for version-only shame days.

A day where Grafana only observed a version violation (metrics/logs unknown, zero samples, version_status='shame') is still classified as "not observed" here. _is_clean correctly breaks the streak (version_status == 'shame'), but _shame_dims/dims_on will return [] for that day since _has_observation returns False, silently dropping 'version' from broken_by. This is the same gap flagged on a previous commit of this file and remains unaddressed.

🐛 Proposed fix
 def _has_observation(snap):
     """Whether the Grafana sync recorded anything for this day (vs not-yet-synced)."""
     return bool(
         snap is not None
         and (
             snap.metrics_samples > 0
             or snap.logs_samples > 0
             or snap.metrics_status != 'unknown'
             or snap.logs_status != 'unknown'
+            or snap.version_status != 'unknown'
         )
     )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/validators/streaks.py` around lines 48 - 58, The `_has_observation`
helper in `streaks.py` is still treating version-only shame days as “not
observed,” which causes `broken_by` to miss the version dimension. Update
`_has_observation(snap)` to also return true when `snap.version_status` is not
`'unknown'`, alongside the existing metrics/logs sample and status checks, so
`_shame_dims` and `dims_on` can correctly include `'version'` for those days.
backend/validators/tests/test_streaks.py (1)

76-80: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add a version-only regression case.

This test still keeps metrics='on', logs='on', so it doesn't cover the version-only shame scenario (metrics/logs unknown, zero samples) that exposes the _has_observation bug flagged in streaks.py. This mirrors a prior trivial comment on the same test that remains unaddressed.

🧪 Proposed additional test
     def test_version_shame_breaks_streak(self):
         _snap(self.wallet, self.today, version='shame')
         result = self._streak()
         self.assertEqual(result['days'], 0)
         self.assertIn('version', result['broken_by'])
+
+    def test_version_only_shame_breaks_streak_and_reports_dim(self):
+        # No metrics/logs reported at all this day, only a version verdict.
+        _snap(self.wallet, self.today, version='shame',
+              metrics='unknown', logs='unknown', m_samples=0, l_samples=0)
+        result = self._streak()
+        self.assertEqual(result['days'], 0)
+        self.assertIn('version', result['broken_by'])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/validators/tests/test_streaks.py` around lines 76 - 80, The current
`test_version_shame_breaks_streak` still uses `metrics='on'` and `logs='on'`, so
it does not exercise the version-only shame path that triggers the
`_has_observation` issue in `streaks.py`. Add a separate regression test in
`test_streaks.py` using `_snap` with `version='shame'` and no metrics/logs
observations (so they remain unknown/zero samples), then assert `_streak()`
returns `days == 0` and is broken by `version`. Keep the existing
mixed-observation test if needed, but ensure this new case specifically targets
the version-only scenario.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/validators/grafana_service.py`:
- Around line 149-162: The version selection logic in the `version_by_address`
update block should prefer a later parseable version when the existing `prev`
value cannot be parsed. In the address/version merge path, adjust the comparison
in this loop so `_safe_parse(prev)` being `None` does not block replacement by a
valid `version`; use the existing helpers `_normalize_version` and `_safe_parse`
around `version_by_address` to keep the highest parseable version, and fall back
to parseable values over unparseable ones.

In `@backend/validators/version_status.py`:
- Around line 54-57: The version comparison in VersionStatus validation can
incorrectly classify malformed stored versions because
NodeVersionMixin._compare_versions() falls back to lexicographic ordering on
parse errors. Update the logic around matches_target in the validator to
normalize or reject invalid node_version values before calling the comparison,
so bad legacy strings are treated as unknown/shame instead of matching
target.version. Refer to the VersionStatus path and
NodeVersionMixin._compare_versions when making the fix.

---

Duplicate comments:
In `@backend/validators/streaks.py`:
- Around line 48-58: The `_has_observation` helper in `streaks.py` is still
treating version-only shame days as “not observed,” which causes `broken_by` to
miss the version dimension. Update `_has_observation(snap)` to also return true
when `snap.version_status` is not `'unknown'`, alongside the existing
metrics/logs sample and status checks, so `_shame_dims` and `dims_on` can
correctly include `'version'` for those days.

In `@backend/validators/tests/test_streaks.py`:
- Around line 76-80: The current `test_version_shame_breaks_streak` still uses
`metrics='on'` and `logs='on'`, so it does not exercise the version-only shame
path that triggers the `_has_observation` issue in `streaks.py`. Add a separate
regression test in `test_streaks.py` using `_snap` with `version='shame'` and no
metrics/logs observations (so they remain unknown/zero samples), then assert
`_streak()` returns `days == 0` and is broken by `version`. Keep the existing
mixed-observation test if needed, but ensure this new case specifically targets
the version-only scenario.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: bc4eff83-6cdc-4f28-aaa1-c8b5ca311ab0

📥 Commits

Reviewing files that changed from the base of the PR and between 3ae689a and aa67bde.

📒 Files selected for processing (21)
  • CHANGELOG.md
  • backend/CLAUDE.md
  • backend/tally/settings.py
  • backend/users/serializers.py
  • backend/validators/grafana_service.py
  • backend/validators/management/commands/rebuild_daily_snapshots.py
  • backend/validators/migrations/0015_validatorwalletstatussnapshot_logs_samples_and_more.py
  • backend/validators/models.py
  • backend/validators/node_version.py
  • backend/validators/serializers.py
  • backend/validators/streaks.py
  • backend/validators/tests/test_api.py
  • backend/validators/tests/test_grafana_service.py
  • backend/validators/tests/test_node_version_sync.py
  • backend/validators/tests/test_node_version_tracking.py
  • backend/validators/tests/test_streaks.py
  • backend/validators/tests/test_version_status.py
  • backend/validators/version_status.py
  • backend/validators/views.py
  • frontend/CLAUDE.md
  • frontend/src/routes/ProfileEdit.svelte
💤 Files with no reviewable changes (1)
  • backend/validators/node_version.py

Comment thread backend/validators/grafana_service.py
Comment thread backend/validators/version_status.py Outdated
JoaquinBN added 4 commits July 2, 2026 11:16
Grafana's version label is self-reported by the node being judged and
rewarded, so the automatic flows no longer trust a single reporter. An
upgrade target is only auto-created when the new stable release is seen
on portal-known wallets of at least two distinct operators, and a
broadcast notification announces it so validators learn about the grace
period before they can be shamed. Versions from unknown Prometheus
series or banned wallets count for nothing, recorded operator versions
only move forward (a skipped scrape can't flash a downgrade onto the
Wall of Shame), and removing the node-upgrade multiplier now pauses the
auto-award entirely instead of awarding at 1.0.

The shame verdict never falls back to lexicographic version comparison:
unparseable versions read as version-unknown, and a parseable version
series always beats an unparseable duplicate regardless of frame order.
A sync run where a whole datasource comes back empty still updates the
self-healing live statuses but skips the permanent daily history latch,
so an infrastructure blackout can't shame every validator's recorded
day. Version detection also runs before the active-wallet early return,
so networks with zero active wallets still record versions and awards.

Uptime streaks now skip days with no monitoring data instead of
breaking, days spent quarantined or inactive break with an explicit
status reason, version-only rollups count as observed days, the maximum
streak honors the 180-day window, and both snapshot writers share one
day-bucketing function so the (wallet, date) key can never split.

## Claude Implementation Notes
- backend/validators/grafana_service.py: MIN_OPERATORS_FOR_AUTO_TARGET
  consensus guard + known/non-banned wallet restriction in
  _sync_node_versions; _broadcast_auto_target helper; monotonic
  node_version writes; award skipped on missing multiplier
  (_allow_missing_multiplier escape hatch removed); parseability gate
  for version_status in sync_network; datasource-blackout guard around
  _record_history; _sync_node_versions moved before the no-active-
  wallets early return; prefer-parseable rule in parse_response;
  defensive handlers log at exception level
- backend/validators/streaks.py: clean_streak skips unobserved days,
  breaks on non-active snapshot rows, range capped at max_days;
  _has_observation counts version_status; _shame_dims attributes
  'status' from the on-chain column even without Grafana data
- backend/validators/genlayer_validators_service.py: snapshot date uses
  timezone.localdate() to match the Grafana rollup bucketing
- backend/validators/management/commands/rebuild_daily_snapshots.py:
  --days 0 no longer means "all"; summary counts during iteration
  instead of a second full scan
- backend/validators/tests/: coverage for the consensus guard, unknown
  address rejection, banned exclusion, monotonic writes, multiplier
  kill switch, auto-target notification, blackout guard, unparseable
  version verdicts, zero-active-wallet version sync, streak skip/status
  semantics (80 tests, all passing)
- backend/CLAUDE.md, frontend/CLAUDE.md: docs updated to the new
  behavior; validators mutation contract corrected to staff-only;
  ProfileEdit.svelte filename fixed
Closes the still-open CodeRabbit findings: the version verdict shared by
the Wall of Shame and the Grafana sync now compares versions only via
PEP 440 parsing — an unparseable legacy or vendor-format version reads
as 'on' when it exactly equals the target string and 'unknown'
otherwise, never a lexicographic comparison that misorders versions.
The per-operator network-streak rollup pre-groups wallet ids once
instead of rescanning every operator-network pair for every group.

## Claude Implementation Notes
- backend/validators/version_status.py: safe_parse_version added (shared
  helper); compute_version_status verdicts via parsed comparison with
  exact-string-equality escape for vendor formats; 'unknown' when
  incomparable; NodeVersionMixin._compare_versions no longer used here
- backend/validators/grafana_service.py: imports safe_parse_version
  instead of a local duplicate; sync-loop parseability gate removed in
  favor of the hardened shared verdict; parse_response docstring
  documents the parseable-beats-unparseable rule
- backend/validators/views.py: wallet_ids_by_operator pre-grouping
  removes the O(groups x pairs) scan in _build_validator_groups
- backend/validators/tests/test_version_status.py: unparseable-version
  and vendor-format-equality verdict coverage

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/validators/grafana_service.py (1)

340-363: 🩺 Stability & Availability | 🔵 Trivial

Blackout protection covers the permanent rollup but not the live Wall-of-Shame status.

When Prometheus/Loki return empty (rotated datasource UID, token scope, renamed label), _record_history is correctly skipped so the permanent daily rollup isn't shamed. But ValidatorWallet.bulk_update(...) at Line 340 already ran before that check, flipping every wallet's live metrics_status/logs_status to shame for the whole fleet during the same infra blackout. test_datasource_blackout_does_not_latch_history documents this as intentional/self-healing, but it means the public Wall-of-Shame page can show every validator shamed during a Grafana outage even though history is protected.

Worth confirming this is an accepted trade-off (self-heals next good run) rather than an oversight, since the same blackout detection used for history could also gate the live bulk_update.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/validators/grafana_service.py` around lines 340 - 363, The blackout
check in grafana_service.py only guards cls._record_history, but
ValidatorWallet.objects.bulk_update in the same sync path still applies live
metrics_status/logs_status changes during a Prometheus/Loki outage. Update the
Grafana sync flow around the bulk_update block in the validator sync method so
blackout detection also gates the live wallet status write, or otherwise
preserves existing live statuses when prom_addresses or log_counts are empty.
Keep the existing _record_history skip behavior, and make sure the logic still
aligns with test_datasource_blackout_does_not_latch_history and the helper
symbols ValidatorWallet and _record_history.
♻️ Duplicate comments (3)
backend/validators/management/commands/rebuild_daily_snapshots.py (1)

9-19: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Command still depends on grafana_service.py's private latch helpers.

_latch, _latch_version, and _METRICS_SEVERITY are underscore-prefixed internals of grafana_service.py but are imported directly here to compute rollups. This couples the rebuild command to another module's private implementation details.

Extract these helpers (and the severity maps) into a shared module that both grafana_service.py and this command import from.

Also applies to: 63-65

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/validators/management/commands/rebuild_daily_snapshots.py` around
lines 9 - 19, The rebuild command is coupled to private internals from
grafana_service by importing _latch, _latch_version, and _METRICS_SEVERITY
directly. Move these shared rollup helpers and severity mappings into a separate
common module, then update both rebuild_daily_snapshots.Command and
grafana_service to import from that shared location instead of the
underscore-prefixed symbols.
backend/validators/grafana_service.py (2)

385-441: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Rollup read-then-write in _record_history is still racy/non-atomic.

existing snapshot state is read in Python, merged with the new sample into rollups, then persisted via bulk_create(update_conflicts=True). Two overlapping sync runs for the same network/day (retry, manual trigger overlapping cron) can both read the same stale prev, and the second upsert clobbers the first's sample count/latch instead of merging it — this directly feeds the new clean-streak/days-in-shame reporting. Separately, the ValidatorWalletObservation bulk_create and the snapshot bulk_create aren't wrapped in a transaction, so a failure in the second step leaves a persisted raw observation with no matching rollup update (recoverable via rebuild_daily_snapshots, but transiently inconsistent).

🛠️ Direction for a concurrency-safe fix

Wrap the observation insert + rollup upsert in transaction.atomic(), and replace the Python-side merge with either select_for_update() on the existing snapshot rows before merging, or a raw upsert that increments metrics_samples/logs_samples via F() and computes the latch with a CASE expression at the DB level so concurrent runs merge rather than overwrite.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/validators/grafana_service.py` around lines 385 - 441, The
`_record_history` rollup path is doing a read-then-write merge in Python and is
still racy, and the observation insert plus snapshot upsert are not atomic. Wrap
the `ValidatorWalletObservation.objects.bulk_create` and
`ValidatorWalletStatusSnapshot.objects.bulk_create` work in
`transaction.atomic()`, then change the `existing`/`rollups` merge logic to use
database-side concurrency control in `_record_history` and
`ValidatorWalletStatusSnapshot` updates, such as `select_for_update()` on the
current snapshot rows or an upsert that uses `F()`/`CASE` expressions so
overlapping syncs increment sample counts and preserve latch state instead of
overwriting each other.

603-613: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Dedup check-then-create in _award_node_upgrade remains a TOCTOU race.

already_awarded/pending are checked via .exists() well before Contribution.save() persists the new row. Two overlapping sync runs reaching an operator's target version in the same window can both pass the dedup check before either writes, double-awarding the contribution (double points on the leaderboard).

🛠️ Direction for a concurrency-safe fix

Move the dedup decision and insert into one atomic flow (e.g., transaction.atomic() plus a DB-level uniqueness guard on (user, contribution_type, dedup_key) or select_for_update on a locking row) so overlapping sync runs can't both pass the check and award the same dedup_key twice.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/validators/grafana_service.py` around lines 603 - 613, The dedup
logic in `_award_node_upgrade` is vulnerable to a TOCTOU race because
`already_awarded` and `pending` are checked with `.exists()` before the new
`Contribution`/`SubmittedContribution` row is written. Make the check-and-create
path atomic by moving the decision into a single transaction in
`_award_node_upgrade` and enforcing a DB-level uniqueness guard for the `(user,
contribution_type, dedup_key)` combination, or use a locking query such as
`select_for_update` around the relevant row, so overlapping sync runs cannot
award the same `dedup_key` twice.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/validators/grafana_service.py`:
- Around line 47-51: `MIN_OPERATORS_FOR_AUTO_TARGET` is still hardcoded in
`grafana_service` even though `grace_days` was moved to settings; make this
anti-collusion threshold configurable via the existing settings/config pattern
instead of a module constant. Update the validation logic in `grafana_service`
to read the threshold from settings, add a sensible default matching the current
value, and keep the constant only if needed as a fallback or remove it if no
longer used. Use the existing settings access point and the auto-target
validation path to wire it in cleanly.

---

Outside diff comments:
In `@backend/validators/grafana_service.py`:
- Around line 340-363: The blackout check in grafana_service.py only guards
cls._record_history, but ValidatorWallet.objects.bulk_update in the same sync
path still applies live metrics_status/logs_status changes during a
Prometheus/Loki outage. Update the Grafana sync flow around the bulk_update
block in the validator sync method so blackout detection also gates the live
wallet status write, or otherwise preserves existing live statuses when
prom_addresses or log_counts are empty. Keep the existing _record_history skip
behavior, and make sure the logic still aligns with
test_datasource_blackout_does_not_latch_history and the helper symbols
ValidatorWallet and _record_history.

---

Duplicate comments:
In `@backend/validators/grafana_service.py`:
- Around line 385-441: The `_record_history` rollup path is doing a
read-then-write merge in Python and is still racy, and the observation insert
plus snapshot upsert are not atomic. Wrap the
`ValidatorWalletObservation.objects.bulk_create` and
`ValidatorWalletStatusSnapshot.objects.bulk_create` work in
`transaction.atomic()`, then change the `existing`/`rollups` merge logic to use
database-side concurrency control in `_record_history` and
`ValidatorWalletStatusSnapshot` updates, such as `select_for_update()` on the
current snapshot rows or an upsert that uses `F()`/`CASE` expressions so
overlapping syncs increment sample counts and preserve latch state instead of
overwriting each other.
- Around line 603-613: The dedup logic in `_award_node_upgrade` is vulnerable to
a TOCTOU race because `already_awarded` and `pending` are checked with
`.exists()` before the new `Contribution`/`SubmittedContribution` row is
written. Make the check-and-create path atomic by moving the decision into a
single transaction in `_award_node_upgrade` and enforcing a DB-level uniqueness
guard for the `(user, contribution_type, dedup_key)` combination, or use a
locking query such as `select_for_update` around the relevant row, so
overlapping sync runs cannot award the same `dedup_key` twice.

In `@backend/validators/management/commands/rebuild_daily_snapshots.py`:
- Around line 9-19: The rebuild command is coupled to private internals from
grafana_service by importing _latch, _latch_version, and _METRICS_SEVERITY
directly. Move these shared rollup helpers and severity mappings into a separate
common module, then update both rebuild_daily_snapshots.Command and
grafana_service to import from that shared location instead of the
underscore-prefixed symbols.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3aba974a-c0a4-4c8c-99b0-7190e80f2a3a

📥 Commits

Reviewing files that changed from the base of the PR and between aa67bde and 7fe932e.

📒 Files selected for processing (10)
  • CHANGELOG.md
  • backend/CLAUDE.md
  • backend/validators/genlayer_validators_service.py
  • backend/validators/grafana_service.py
  • backend/validators/management/commands/rebuild_daily_snapshots.py
  • backend/validators/streaks.py
  • backend/validators/tests/test_grafana_service.py
  • backend/validators/tests/test_node_version_sync.py
  • backend/validators/tests/test_streaks.py
  • frontend/CLAUDE.md

Comment thread backend/validators/grafana_service.py Outdated
JoaquinBN added 5 commits July 2, 2026 11:35
The node-upgrade award dedup now runs inside a transaction holding a
lock on the user row, so even the residual stale-lock-takeover window
can never double-award the same version. The minimum number of distinct
operators required before a new stable release auto-creates the
fleet-wide upgrade target is now a setting (default 2), tunable without
a code deploy like the shame grace period. The new snapshot and
observation model fields document their intent through help_text,
matching the model's existing convention.

## Claude Implementation Notes
- backend/validators/grafana_service.py: _award_node_upgrade wraps dedup
  check + create in transaction.atomic with select_for_update on the
  user row (no-op on SQLite, real lock on Postgres); module constant
  MIN_OPERATORS_FOR_AUTO_TARGET replaced by min_operators_for_auto_target()
  reading NODE_VERSION_MIN_OPERATORS_FOR_AUTO_TARGET at call time
- backend/tally/settings.py: NODE_VERSION_MIN_OPERATORS_FOR_AUTO_TARGET
  env-driven setting (default 2)
- backend/validators/models.py + migrations/0015: help_text on all new
  ValidatorWalletStatusSnapshot / ValidatorWalletObservation fields;
  migration regenerated in place (same name, help_text-only diff,
  makemigrations --check clean)
- backend/validators/tests/test_node_version_sync.py: threshold
  configurability test via override_settings
- backend/CLAUDE.md: env var documented; constant reference updated
Banned users are blocked from submitting contributions everywhere else,
so the Grafana version sync must not let them in through the back door:
wallets whose linked user is banned no longer count toward the
auto-target quorum, no longer get version write-backs, and can never
receive the direct node-upgrade award (the award gate also re-checks
the ban as a second layer).

## Claude Implementation Notes
- backend/validators/grafana_service.py: wallet query in
  _sync_node_versions filters operator__user__is_banned=False alongside
  the on-chain banned-wallet exclusion; award gate re-checks
  is_banned; docstrings updated
- backend/validators/tests/test_node_version_sync.py: banned-user test
  covering quorum, version write-back, and award paths
Per product decision, a single validator seen running a new stable
release is enough to auto-create the fleet-wide upgrade target; the
operator quorum setting now defaults to 1. The setting remains so the
bar can be raised without a deploy if version spoofing ever becomes a
concern. Known-wallet, banned-wallet, and banned-user restrictions are
unchanged.

## Claude Implementation Notes
- backend/tally/settings.py: NODE_VERSION_MIN_OPERATORS_FOR_AUTO_TARGET
  default 2 -> 1, comment explains the decision and when to raise it
- backend/validators/grafana_service.py: fallback default 1; docstrings
  and comments updated to match
- backend/validators/tests/test_node_version_sync.py: single-operator
  target creation is the default-path test again; quorum test now
  overrides the setting to 2 and covers both rejection and corroboration
- backend/CLAUDE.md: default documented as 1
@JoaquinBN JoaquinBN merged commit 95e042b into dev Jul 2, 2026
3 checks passed
@JoaquinBN JoaquinBN deleted the feat/grafana-shame-history-streaks branch July 2, 2026 15:23
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.

2 participants