Skip to content

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

Open
rasca wants to merge 4 commits into
devfrom
feat/grafana-shame-history-streaks
Open

Grafana-sourced shame history: uptime streaks + auto node-version#867
rasca wants to merge 4 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 across profile and validator settings.
    • Improved Wall of Shame observability: clean uptime streak tracking (with break reasons) plus per-network streak summaries.
    • Added daily observability rollups driven by Grafana sync.
    • Introduced an admin-configurable grace period before older versions are flagged.
  • Bug Fixes
    • Improved version-health verdicting and daily rollup “latching” so metrics/logs and version status remain consistent across repeated syncs.
  • Chores / Tests
    • Added a rebuild utility for daily rollups and expanded test coverage for sync, streaks, and version status.

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

📝 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-days settings 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/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

🚥 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 matches the PR’s main themes: Grafana-sourced observability history, uptime streak 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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 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 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
Comment thread backend/validators/grafana_service.py Outdated
Comment on lines +417 to +433
wallets = list(
ValidatorWallet.objects
.filter(id__in=list(normalized.keys()), operator__isnull=False)
.select_related('operator', 'operator__user')
)
by_operator = {}
for wallet in wallets:
by_operator.setdefault(wallet.operator, []).append(normalized[wallet.id])

field = f'node_version_{network}'
for operator, versions in by_operator.items():
highest = max(versions, key=parse_version)
# (2) Direct update bypasses NodeVersionMixin.save() so we control the
# award path (direct/approved) instead of its pending-submission path.
if getattr(operator, field, None) != highest:
Validator.objects.filter(pk=operator.pk).update(**{field: highest})
setattr(operator, field, highest)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

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

by_operator is built only from wallets present in normalized (i.e. wallets that reported a valid version in this sync run). If an operator has multiple wallets and one temporarily stops reporting metrics (network blip, restart, scrape gap), highest is computed from a smaller set and can be lower than the version already stored. Since the write is gated on != rather than "newer than," this silently downgrades the field.

This field is the fallback source compute_version_status reads from (via wallet.operator.node_version_<network>) when no explicit node_version is passed — which is exactly what the Wall of Shame view does. A transient reporting gap on one wallet can therefore falsely flip an already-upgraded operator back to "warning"/"shame" on the public page.

🐛 Proposed fix: never let the stored version regress
             by_operator = {}
             for wallet in wallets:
                 by_operator.setdefault(wallet.operator, []).append(normalized[wallet.id])
 
             field = f'node_version_{network}'
             for operator, versions in by_operator.items():
-                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)
                 # (2) Direct update bypasses NodeVersionMixin.save() so we control the
                 # award path (direct/approved) instead of its pending-submission path.
                 if getattr(operator, field, None) != highest:
                     Validator.objects.filter(pk=operator.pk).update(**{field: highest})
                     setattr(operator, field, highest)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
wallets = list(
ValidatorWallet.objects
.filter(id__in=list(normalized.keys()), operator__isnull=False)
.select_related('operator', 'operator__user')
)
by_operator = {}
for wallet in wallets:
by_operator.setdefault(wallet.operator, []).append(normalized[wallet.id])
field = f'node_version_{network}'
for operator, versions in by_operator.items():
highest = max(versions, key=parse_version)
# (2) Direct update bypasses NodeVersionMixin.save() so we control the
# award path (direct/approved) instead of its pending-submission path.
if getattr(operator, field, None) != highest:
Validator.objects.filter(pk=operator.pk).update(**{field: highest})
setattr(operator, field, highest)
wallets = list(
ValidatorWallet.objects
.filter(id__in=list(normalized.keys()), operator__isnull=False)
.select_related('operator', 'operator__user')
)
by_operator = {}
for wallet in wallets:
by_operator.setdefault(wallet.operator, []).append(normalized[wallet.id])
field = f'node_version_{network}'
for operator, versions in by_operator.items():
current = getattr(operator, field, None) or ''
candidates = versions + ([current] if _SEMVER_RE.match(current) else [])
highest = max(candidates, key=parse_version)
# (2) Direct update bypasses NodeVersionMixin.save() so we control the
# award path (direct/approved) instead of its pending-submission path.
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 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.

Comment on lines +462 to +472
user = operator.user
dedup_key = f"version {target.version} [{network}]"
already_awarded = Contribution.objects.filter(
user=user, contribution_type=contribution_type, notes__contains=dedup_key,
).exists()
pending = SubmittedContribution.objects.filter(
user=user, contribution_type=contribution_type,
state__in=['pending', 'accepted'], notes__contains=dedup_key,
).exists()
if already_awarded or pending:
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

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

already_awarded/pending are checked via .exists() and only later does contribution.save() persist the new row (line 498). Two overlapping sync runs (e.g. a slow request retried, or sync_all_networks triggered twice) that both reach an operator's target version in the same window could both pass the dedup check before either writes, resulting in a double-awarded contribution (double points on the leaderboard).

🤖 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 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.

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 on lines +31 to +36
def handle(self, *args, **options):
days = options.get('days')
observations = ValidatorWalletObservation.objects.all()
if days:
cutoff = timezone.now() - timedelta(days=days)
observations = observations.filter(observed_at__gte=cutoff)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

--days 0 silently behaves like "no filter".

if days: treats 0 as falsy, so --days 0 skips the cutoff filter entirely instead of restricting to (effectively) no observations. Use an explicit is not None check.

🐛 Proposed fix
-        if days:
+        if days is not None:
             cutoff = timezone.now() - timedelta(days=days)
             observations = observations.filter(observed_at__gte=cutoff)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def handle(self, *args, **options):
days = options.get('days')
observations = ValidatorWalletObservation.objects.all()
if days:
cutoff = timezone.now() - timedelta(days=days)
observations = observations.filter(observed_at__gte=cutoff)
def handle(self, *args, **options):
days = options.get('days')
observations = ValidatorWalletObservation.objects.all()
if days is not None:
cutoff = timezone.now() - timedelta(days=days)
observations = observations.filter(observed_at__gte=cutoff)
🧰 Tools
🪛 Ruff (0.15.20)

[warning] 31-31: Missing type annotation for *args

(ANN002)


[warning] 31-31: Unused method argument: args

(ARG002)


[warning] 31-31: Missing type annotation for **options

(ANN003)

🤖 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 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.

Comment on lines +90 to +93

self.stdout.write(self.style.SUCCESS(
f'Rebuilt {len(rollups)} daily rollup(s) from {observations.count()} observation(s).'
))

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 | ⚡ Quick win

Avoid a second full scan for the summary count.

observations.count() re-runs the (potentially unfiltered, whole-table) query after .iterator() already streamed it once. Track a counter inside the loop instead of calling .count() at the end.

♻️ Proposed fix
         acc = {}
+        obs_count = 0
         for obs in observations.iterator():
+            obs_count += 1
             key = (obs.wallet_id, timezone.localdate(obs.observed_at))
...
         self.stdout.write(self.style.SUCCESS(
-            f'Rebuilt {len(rollups)} daily rollup(s) from {observations.count()} observation(s).'
+            f'Rebuilt {len(rollups)} daily rollup(s) from {obs_count} observation(s).'
         ))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
self.stdout.write(self.style.SUCCESS(
f'Rebuilt {len(rollups)} daily rollup(s) from {observations.count()} observation(s).'
))
self.stdout.write(self.style.SUCCESS(
f'Rebuilt {len(rollups)} daily rollup(s) from {obs_count} observation(s).'
))
🤖 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 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.

Comment on lines +141 to +155
# Latched worst-of-day observability verdict (from ValidatorWalletObservation).
metrics_status = models.CharField(
max_length=10, choices=ValidatorWallet.GRAFANA_STATUS_CHOICES, default='unknown'
)
logs_status = models.CharField(
max_length=10, choices=ValidatorWallet.GRAFANA_STATUS_CHOICES, default='unknown'
)
version_status = models.CharField(
max_length=10, choices=VERSION_STATUS_CHOICES, default='unknown'
)
node_version = models.CharField(max_length=50, blank=True)
# Number of observations that day where the node was reporting metrics / logs.
metrics_samples = models.PositiveIntegerField(default=0)
logs_samples = models.PositiveIntegerField(default=0)

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

Add help_text to the new status/version fields.

Other fields in this model (e.g. show_in_overview, assets_under_management_usd) consistently document intent via help_text. The new metrics_status/logs_status/version_status/node_version fields on both ValidatorWalletStatusSnapshot and ValidatorWalletObservation don't, which the repo's own ast-grep rule flags as a convention violation.

Also applies to: 166-198

🧰 Tools
🪛 ast-grep (0.44.0)

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

(model-help-text)


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

(model-help-text)


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

(model-help-text)


[info] 150-150: 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)

🤖 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 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.

Source: Linters/SAST tools

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
- **`/profile`** - Edit profile page (authenticated users only)
- Component: `EditProfile.svelte`
- Only allows editing display name and node version
- Allows editing display name and profile fields; node version is shown read-only (Grafana-sourced, auto-detected from node metrics — not editable)

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 | 🟡 Minor | ⚡ Quick win

Use the actual route component filename.

These docs point readers to EditProfile.svelte, but the file in this PR is frontend/src/routes/ProfileEdit.svelte. Please rename the reference so future maintenance and code search land on the right component.

Also applies to: 607-607

🤖 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 `@frontend/CLAUDE.md` at 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.

@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 on lines +48 to +58
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'
)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Count version rollups as observations.

_has_observation() ignores version_status, so a version-only snapshot such as version_status='shame' with unknown metrics/logs is treated as unobserved. That lets today’s version shame be skipped and suppresses broken_by=['version'].

🐛 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'
         )
     )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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'
)
)
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'
)
)
🧰 Tools
🪛 Ruff (0.15.20)

[warning] 48-48: Missing return type annotation for private function _has_observation

(ANN202)

🤖 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 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.

Comment on lines +76 to +80
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'])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add coverage for version-only shame observations.

This test still has metrics/logs marked on, so it misses the case where Grafana records only version_status='shame'. Add a regression case with metrics/logs unknown and zero samples to lock the streak behavior.

🤖 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
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.

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 on lines +149 to +162
version = _normalize_version(labels.get('version'))[:_VERSION_MAX_LENGTH]
if version:
prev = version_by_address.get(addr)
if prev is None:
version_by_address[addr] = version
else:
prev_parsed = _safe_parse(prev)
cur_parsed = _safe_parse(version)
if (
prev_parsed is not None
and cur_parsed is not None
and cur_parsed > prev_parsed
):
version_by_address[addr] = version

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Version-overlap resolution never replaces an unparseable "first" frame with a later valid one.

The replace condition requires prev_parsed is not None, so if the first frame processed for an address has a PEP-440-unparseable label (e.g. 0.6.0-genlayer.1, a format this codebase explicitly expects — see test_pep440_invalid_semver_does_not_abort_other_operators), every subsequent frame — including a valid, higher, parseable version reported in the same batch — is silently ignored. version_by_address[addr] stays pinned to the garbage string, which then poisons ValidatorWalletObservation.node_version, the daily rollup, and compute_version_status for that node until the bad-format series disappears from Prometheus entirely.

🐛 Proposed fix: prefer a parseable version over an unparseable one
                     version = _normalize_version(labels.get('version'))[:_VERSION_MAX_LENGTH]
                     if version:
                         prev = version_by_address.get(addr)
                         if prev is None:
                             version_by_address[addr] = version
                         else:
                             prev_parsed = _safe_parse(prev)
                             cur_parsed = _safe_parse(version)
-                            if (
-                                prev_parsed is not None
-                                and cur_parsed is not None
-                                and cur_parsed > prev_parsed
-                            ):
-                                version_by_address[addr] = version
+                            if prev_parsed is None:
+                                if cur_parsed is not None:
+                                    version_by_address[addr] = version
+                            elif cur_parsed is not None and cur_parsed > prev_parsed:
+                                version_by_address[addr] = version
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
version = _normalize_version(labels.get('version'))[:_VERSION_MAX_LENGTH]
if version:
prev = version_by_address.get(addr)
if prev is None:
version_by_address[addr] = version
else:
prev_parsed = _safe_parse(prev)
cur_parsed = _safe_parse(version)
if (
prev_parsed is not None
and cur_parsed is not None
and cur_parsed > prev_parsed
):
version_by_address[addr] = version
version = _normalize_version(labels.get('version'))[:_VERSION_MAX_LENGTH]
if version:
prev = version_by_address.get(addr)
if prev is None:
version_by_address[addr] = version
else:
prev_parsed = _safe_parse(prev)
cur_parsed = _safe_parse(version)
if prev_parsed is None:
if cur_parsed is not None:
version_by_address[addr] = version
elif cur_parsed is not None and cur_parsed > prev_parsed:
version_by_address[addr] = version
🤖 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 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.

Comment on lines +54 to +57
matches_target = bool(
node_version
and NodeVersionMixin._compare_versions(node_version, target.version)
)

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 | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd node_version.py backend/validators
cat -n backend/validators/node_version.py | sed -n '1,140p'
ast-grep run --pattern '_compare_versions($_, $_)' --lang python backend/validators

Repository: genlayer-foundation/points

Length of output: 5208


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the status calculation path and any tests covering malformed versions.
fd version_status.py backend/validators
cat -n backend/validators/version_status.py | sed -n '1,180p'

printf '\n--- tests mentioning compare_versions / version_status ---\n'
rg -n "_compare_versions|version_matches_or_higher|compute_version_status|node_version" backend -g '*test*' -g '*tests*'

printf '\n--- default path into compute_version_status ---\n'
rg -n "node_version_asimov|node_version_bradbury|version_status" backend

Repository: genlayer-foundation/points

Length of output: 23184


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cat -n backend/validators/tests/test_node_version_tracking.py | sed -n '126,170p'
cat -n backend/validators/tests/test_version_status.py | sed -n '1,140p'

Repository: genlayer-foundation/points

Length of output: 6222


Avoid lexicographic fallback for malformed versions. Legacy operator values still reach this path, and NodeVersionMixin._compare_versions() falls back to node_version >= target_version on parse errors, so invalid strings can be classified incorrectly instead of being treated as unknown/shame. Normalize or reject invalid stored versions before this check.

🤖 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/version_status.py` around lines 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant