Skip to content

orb(bounties): the bounty mirror is inert today, but its import path has an audit gap and a permanent-wedge bug #9080

Description

@JSONbored

Context first: the bounty system is not a value-transfer state machine. There is no create/fund/claim/award/cancel path in src/**. The only writer is POST /v1/internal/bounties/import (src/api/routes.ts ~5030), gated by INTERNAL_JOB_TOKEN, ingesting a Gittensor snapshot; every read is advisory context. No payout webhook, no wallet/address/hotkey storage, no external call that moves money. Verified live: the bounties table is currently EMPTY. So the findings below are latent, not active — filed so they are fixed before the mirror is populated.

1. Transition events persist after all upserts (audit gap). The loop does getBountyupsertBounty → diff-and-collect, then persists events only after the whole loop (await Promise.all(events.map(...))). Any mid-loop failure leaves bounties rows already advanced with zero lifecycle rows recorded — and because the event is derived by diffing against the now-overwritten row, the transition is unrecoverable. The same "state advanced, audit trail didn't" shape as #8997.

2. Upsert cannot survive an id change (permanent wedge). upsertBounty (src/db/repositories.ts ~4984-5008) uses onConflictDoUpdate({target: bounties.id}), but the table also has UNIQUE(repo_full_name, issue_number) (migrations/0001_initial.sql ~103). If upstream re-issues a bounty on the same issue under a new id, the insert conflicts on the unhandled index and throws. No per-item try/catch, no transaction → the route 500s, the rest of the snapshot never imports, and per (1) the whole batch's lifecycle events are lost. It repeats on every subsequent import: the mirror silently freezes until someone edits the DB by hand.

3. classifyBountyLifecycle uses unanchored substring matching (packages/loopover-engine/src/signals/engine.ts ~3832-3846). /complete|paid|resolved|rewarded|awarded|fulfil|merged|claimed|done/completed, so a status of unclaimed (contains claimed) classifies as completed, as do not_completed and undone. avoid matches void → cancelled; renewed matches new → active. isHistoricalBountyLifecycle then continues at ~1470, dropping the issue from contributor opportunity ranking entirely — one mis-vocabularied upstream status silently hides live funded work from every contributor.

4. fundingStatus compares against one hardcoded zero-string (~3946): amount !== 0 && amount !== "0.0000". The !== 0 arm is dead for numbers (numeric 0 is already falsy), and the only zero-string caught is that exact literal — upstream sending "0", "0.0", or "0.00" yields funded, telling contributors unfunded work is paid.

Fix

  1. Write the lifecycle event in the same step as the upsert; wrap each item so one bad row cannot drop the batch.
  2. Add (repo_full_name, issue_number) as a second conflict target.
  3. Anchor the classifier to word boundaries and/or an explicit known-status enum, falling back to ambiguous.
  4. Number(amount) with a finite check and > 0 instead of string equality.

Metadata

Metadata

Assignees

Labels

maintainer-onlyOwner-only work — yields no Gittensor points.orbGittensory Orb related - maintainer self-hosting analytics.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions