Skip to content

fix(indicators): trust changes reach every score, and aren't dropped - #489

Merged
righel merged 1 commit into
mainfrom
fix/indicator-trust-rebuilds
Oct 22, 2026
Merged

righel merged 1 commit into
mainfrom
fix/indicator-trust-rebuilds

Conversation

@righel

@righel righel commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

Problem

Saving a feed's, MISP server's or organisation's score modifier or cap, or the scoring settings, calls request_rebuild(), which queues a full rebuild of the indicators. Two things kept the change from reaching the stored scores, which is what profiles, exports, sinks and /lookup filter on.

1. Small changes were computed and then dropped. A rebuild only stores a score that moved by refresh_step (5) or more, or crossed the decayed threshold. That rule exists so hourly decay doesn't rewrite every indicator, but it also applied to the rebuild a trust change asked for. Two examples that never took effect until some later change moved the value by 5 or more:

  • a modifier of −3;
  • a cap of 60 on a value at 62. The value stays at 62 and passes a profile's min_score of 61, although its only source is capped at 60.

2. Requests could be lost. REBUILD_QUEUED_KEY was cleared only when a rebuild finished. There were two failure cases:

  • Saved during a running rebuild: the request was merged into the run already going, which had read the trust before the change, and nothing ran after it.
  • Queued rebuild finds a sync holding the lock: rebuild() returned None without clearing the flag. The requested rebuild never ran, and later requests were suppressed until the flag expired (2 h).

Either way the change only landed with the next hourly rebuild.

Fix

  • request_rebuild() queues rebuild_indicators(requested=True).
  • A requested rebuild:
    • writes every score that moved (_Run(exact_scores=True)). The hourly run keeps refresh_step, so incremental exports still carry only real changes.
    • clears REBUILD_QUEUED_KEY when it starts, not when it ends. Requests stay merged until then, since the run reads the latest settings and trust at that moment; one arriving while it runs queues the next.
    • retries every 30 s (REBUILD_RETRY_SECONDS) while a sync or another rebuild holds the lock, instead of giving up. The hourly run still just skips a turn.
  • app.cli rebuild-indicators runs as a requested rebuild.
  • docs/features/indicators.md describes when scores are rewritten after a trust change.

Testing

  • New app/tests/repositories/test_indicator_rebuilds.py: mock-based, 7 tests, passed locally. It covers:
    • requests merging until the rebuild starts;
    • a request made during a requested rebuild queuing the next;
    • the hourly run leaving a pending request alone;
    • the retry while the lock is held, and no retry for the hourly run;
    • exact versus stepped score writes.
  • test_indicator_scoring.py: the tests now patch apply_async and check requested=True. test_organisation_trust also checks end to end that a 3-point trust change (53 → 56) is skipped by the hourly rebuild and written by a requested one. This is an ApiTester test, so it wasn't run locally (the suite wipes the dev data); CI runs it.
  • flake8 and black: no new findings.

🤖 Generated with Claude Code

Changing a feed's, server's or organisation's score modifier or cap (or
the scoring settings) queues a full rebuild. Two things kept the change
from reaching the scores:

- The rebuild only stored a score that moved by refresh_step (5) or more,
  the rule that keeps hourly decay from rewriting every indicator. A
  modifier of -3, or a cap of 60 on a value at 62, was computed and then
  dropped, while profiles, exports and lookups filter on the stored score.
  A requested rebuild now writes every score that moved; the hourly one
  keeps the step.
- A request was merged into any rebuild already running, which had read
  the trust before the change, and the flag was only cleared when that run
  ended, so nothing else was queued. A queued rebuild that found a sync
  holding the lock gave up and left the flag set, suppressing further
  requests until the flag expired. Either way the change waited for the
  next hourly rebuild. A requested rebuild now clears the flag when it
  starts, so a later request queues the next run, and retries every 30
  seconds while the lock is held.

`app.cli rebuild-indicators` runs as a requested rebuild too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

OpenAPI changes

No changes to the OpenAPI spec.

@righel
righel merged commit a036993 into main Oct 22, 2026
5 checks passed
@codecov

codecov Bot commented Oct 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.04762% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 88.56%. Comparing base (e2e4f73) to head (e1ed3f2).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
api/app/cli/__main__.py 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #489      +/-   ##
==========================================
+ Coverage   88.52%   88.56%   +0.03%     
==========================================
  Files         291      292       +1     
  Lines       34172    34263      +91     
==========================================
+ Hits        30252    30345      +93     
+ Misses       3920     3918       -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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